Compare commits
8 Commits
96c3bf1dee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a470481d | ||
|
|
45b60e37f7 | ||
|
|
72b79feca9 | ||
|
|
dd7805d341 | ||
|
|
311cc1fee6 | ||
|
|
7443b9da7f | ||
|
|
34035cdc9c | ||
|
|
4becf81066 |
503
Cargo.lock
generated
503
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sgclaw"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-2026.4.9"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -17,7 +17,6 @@ serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] }
|
||||
tungstenite = "0.29"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
|
||||
zeroclaw = { package = "zeroclawlabs", path = "third_party/zeroclaw", default-features = false }
|
||||
zip = "8.4"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,145 +0,0 @@
|
||||
# sgBrowser websocket probe transcript
|
||||
|
||||
Endpoint: `ws://127.0.0.1:12345`
|
||||
Timeout: `1500ms`
|
||||
Cargo target dir override: `D:/data/ideaSpace/rust/sgClaw/claw-new/target_task4`
|
||||
|
||||
## baseline-open
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baseline-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'baseline-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 baseline-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
```
|
||||
|
||||
## open-agent
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "post-open-agent-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.98s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'open-agent::["about:blank","sgOpenAgent"]' --step 'post-open-agent-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 open-agent
|
||||
SEND: ["about:blank","sgOpenAgent"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-open-agent-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## set-auth
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "set-auth::[\"about:blank\",\"sgSetAuthInfo\",\"probe-user\",\"probe-token\"]" --step "post-set-auth-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'set-auth::["about:blank","sgSetAuthInfo","probe-user","probe-token"]' --step 'post-set-auth-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 set-auth
|
||||
SEND: ["about:blank","sgSetAuthInfo","probe-user","probe-token"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-set-auth-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## browser-login
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step 'browser-login::["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]' --step 'post-browser-login-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'browser-login::["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]' --step 'post-browser-login-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 browser-login
|
||||
SEND: ["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-browser-login-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## active-tab
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "post-active-tab-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'active-tab::["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]' --step 'post-active-tab-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 active-tab
|
||||
SEND: ["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 post-active-tab-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## combined-bootstrap
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "combined-open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "combined-active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "combined-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'combined-open-agent::["about:blank","sgOpenAgent"]' --step 'combined-active-tab::["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]' --step 'combined-open::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 combined-open-agent
|
||||
SEND: ["about:blank","sgOpenAgent"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
|
||||
STEP 2 combined-active-tab
|
||||
SEND: ["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
|
||||
STEP 3 combined-open
|
||||
SEND: ["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: <none>
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
## requesturl-variants
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "target-as-requesturl::[\"https://www.zhihu.com/hot\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.94s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'target-as-requesturl::["https://www.zhihu.com/hot","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 target-as-requesturl
|
||||
SEND: ["https://www.zhihu.com/hot","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
```
|
||||
|
||||
```text
|
||||
$ CARGO_TARGET_DIR="/d/data/ideaSpace/rust/sgClaw/claw-new/target_task4" cargo run --manifest-path "/d/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baidu-requesturl::[\"https://www.baidu.com\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.94s
|
||||
Running `target_task4\debug\sgbrowser_ws_probe.exe --ws-url 'ws://127.0.0.1:12345' --timeout-ms 1500 --step 'baidu-requesturl::["https://www.baidu.com","sgBrowerserOpenPage","https://www.zhihu.com/hot"]'`
|
||||
STEP 1 baidu-requesturl
|
||||
SEND: ["https://www.baidu.com","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: received
|
||||
```
|
||||
|
||||
| Sequence | Sent frames | First reply | Final outcome | Decision signal |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| baseline-open | `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | received only welcome banner; no numeric status or callback frame captured | does not satisfy Option A rule |
|
||||
| open-agent | `["about:blank","sgOpenAgent"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| set-auth | `["about:blank","sgSetAuthInfo","probe-user","probe-token"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| browser-login | `["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| active-tab | `["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | step 2 timed out with no reply | does not satisfy Option A rule |
|
||||
| combined-bootstrap | `["about:blank","sgOpenAgent"]` then `["about:blank","sgBrowerserActiveTab","https://www.zhihu.com/hot","probeCallback"]` then `["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | steps 2 and 3 timed out with no reply | does not satisfy Option A rule |
|
||||
| requesturl-variants | `["https://www.zhihu.com/hot","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` and `["https://www.baidu.com","sgBrowerserOpenPage","https://www.zhihu.com/hot"]` | `Welcome! You are client #1` | each one-shot run received only the welcome banner; no numeric status or callback frame captured | does not satisfy Option A rule |
|
||||
|
||||
## Final decision
|
||||
|
||||
**Option B wins.**
|
||||
|
||||
Reason: the strict rule says Option A wins only if at least one sequence reproducibly yields real numeric status and/or callback frames for a real business action. Across the full required matrix, the reachable endpoint consistently returned only the websocket welcome banner on the first reply for each fresh connection, and every follow-on business-action step either timed out or produced no numeric status/callback frame. Therefore the evidence does not validate a raw-websocket bootstrap contract, so Option B is the required outcome.
|
||||
@@ -1,425 +0,0 @@
|
||||
# Claw-WS 开发执行顺序卡片
|
||||
|
||||
> 配套计划:[`2026-04-01-claw-ws-parallel-transport.md`](./2026-04-01-claw-ws-parallel-transport.md)
|
||||
>
|
||||
> 使用方式:严格按卡片顺序执行。每张卡片完成后先跑卡片内测试,再进入下一张。不要跳卡,不要提前接线,不要先写 service/client 再回头抽象底层。
|
||||
|
||||
---
|
||||
|
||||
## 卡片 0:执行前约束
|
||||
|
||||
**目标**
|
||||
先锁定边界,避免实现过程中把 pipe 模式改坏。
|
||||
|
||||
**必须遵守**
|
||||
- 现有 pipe 模式必须保持可用
|
||||
- 新增的是并行 `claw-ws` 模式,不是替换 pipe
|
||||
- v1 只做单客户端、单任务串行
|
||||
- `browser_action` / `superrpa_browser` 外部命名保持稳定
|
||||
- 如果 WS `Eval` 不完整,先禁用相关 browser-script skill 暴露
|
||||
- 不要提前做多客户端、任务队列、管理接口
|
||||
|
||||
**完成标准**
|
||||
- 开发者明确后续所有改动都围绕“抽象复用 + 并行新增”进行
|
||||
|
||||
---
|
||||
|
||||
## 卡片 1:抽共享 SubmitTask Runner
|
||||
|
||||
**目标**
|
||||
把当前 `BrowserMessage::SubmitTask` 的主执行逻辑从 pipe 入口里抽出来,变成共享执行器。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/task_runner_test.rs`
|
||||
2. 先写失败用例:
|
||||
- 空 instruction
|
||||
- 无 LLM 配置
|
||||
- 日志顺序仍然是 `LogEntry` -> `TaskComplete`
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/agent/mod.rs`
|
||||
- `src/lib.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 建 `SubmitTaskRequest`
|
||||
2. 建 `AgentEventSink`
|
||||
3. 建 `run_submit_task(...)`
|
||||
4. 让 pipe 入口只做:
|
||||
- 解包 `BrowserMessage::SubmitTask`
|
||||
- 转成 `SubmitTaskRequest`
|
||||
- 调共享 runner
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里引入 ws backend
|
||||
- 不要改 tool adapter
|
||||
- 不要碰 service/client
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_task_flow_test --test task_runner_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- 老的 `runtime_task_flow_test` 继续绿
|
||||
- 新的 `task_runner_test` 通过
|
||||
- pipe 行为无变化
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: extract shared submit-task runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 2:抽 BrowserBackend 抽象
|
||||
|
||||
**目标**
|
||||
把上层 runtime / orchestration / tool adapter 从 `BrowserPipeTool<T>` 解耦,统一依赖浏览器后端接口。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/browser_backend_capability_test.rs`
|
||||
2. 先写失败用例:
|
||||
- pipe backend 元数据不变
|
||||
- pipe backend 支持 `Eval`
|
||||
- `supports_eval() == false` 时不暴露 browser-script tools
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/browser/mod.rs`
|
||||
- `src/browser/backend.rs`
|
||||
- `src/browser/pipe_backend.rs`
|
||||
- `src/compat/browser_tool_adapter.rs`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/lib.rs`
|
||||
- `tests/browser_backend_capability_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 定义 `BrowserBackend`
|
||||
2. 写 `pipe_backend` 包装当前 `BrowserPipeTool`
|
||||
3. 把上层签名改成 `Arc<dyn BrowserBackend>`
|
||||
4. 保持工具名不变:
|
||||
- `browser_action`
|
||||
- `superrpa_browser`
|
||||
5. 增加 `supports_eval()` gating
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里接浏览器 ws 协议
|
||||
- 不要建 service
|
||||
- 不要加 client 协议
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test browser_backend_capability_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- 现有 browser tool 相关测试不回归
|
||||
- 新 capability test 通过
|
||||
- 上层逻辑已脱离 `BrowserPipeTool<T>` 的硬耦合
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: abstract browser backend from pipe transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 3:写死浏览器 WS 协议 Codec
|
||||
|
||||
**目标**
|
||||
单独做浏览器固定 WebSocket 协议编解码层,不把协议细节散落到 backend 和 service 里。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/browser_ws_protocol_test.rs`
|
||||
2. 先写失败用例:
|
||||
- outbound frame 精确编码
|
||||
- callback payload 解析
|
||||
- 异常格式拒绝
|
||||
- v1 action 覆盖完整
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `tests/browser_ws_protocol_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 按浏览器文档编码数组消息
|
||||
2. 只支持 v1 必需动作:
|
||||
- `Navigate`
|
||||
- `GetText`
|
||||
- `Click`
|
||||
- `Type`
|
||||
- `Eval`
|
||||
3. 定义 callback 解析和关联规则
|
||||
4. 对 unsupported / malformed 早失败
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这张卡里连真实浏览器
|
||||
- 不要写 service 协议
|
||||
- 不要把网络连接逻辑塞进 codec
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_protocol_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- codec 单测全绿
|
||||
- 无网络依赖
|
||||
- 已能作为 backend 的纯协议层基础
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "test: codify fixed browser websocket protocol"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 4:实现 Browser WS Backend
|
||||
|
||||
**目标**
|
||||
在 codec 之上提供和 pipe backend 类似的阻塞式 `invoke(...)` 能力。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/browser_ws_backend_test.rs`
|
||||
2. 先写失败用例:
|
||||
- `0 + 无 callback` 成功
|
||||
- 非 `0` 失败
|
||||
- `0 + callback` 成功
|
||||
- callback timeout
|
||||
- socket drop
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/browser/mod.rs`
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 建长连接管理器
|
||||
2. 先做串行单飞请求
|
||||
3. 发送前过 `MacPolicy`
|
||||
4. 统一即时返回和 callback 返回
|
||||
5. 输出统一 `CommandOutput`
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里接 service 层
|
||||
- 不要做多并发 browser request
|
||||
- 不要直接把浏览器 ws 代码散进 runtime
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- backend 在 mocks/fakes 下稳定通过
|
||||
- invoke 语义与 pipe backend 接近
|
||||
- 可供上层 runtime 直接替换使用
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add browser websocket backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 5:实现 sg_claw Service
|
||||
|
||||
**目标**
|
||||
新增本地长驻服务端,承接 client 请求并复用共享 task runner。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/service_ws_session_test.rs`
|
||||
2. 先写失败用例:
|
||||
- 首个客户端接入成功
|
||||
- 第二个客户端收到 busy
|
||||
- 断开后状态释放
|
||||
- 任务重入被拒绝
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/service/mod.rs`
|
||||
- `src/service/protocol.rs`
|
||||
- `src/service/server.rs`
|
||||
- `src/bin/sg_claw.rs`
|
||||
- `src/lib.rs`
|
||||
- `Cargo.toml`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 定义 client/service 协议
|
||||
2. 实现 service 端事件 sink
|
||||
3. 建单活 session 状态机:
|
||||
- `Idle`
|
||||
- `ClientAttached`
|
||||
- `TaskRunning`
|
||||
4. 路由 `SubmitTask` 到共享 runner
|
||||
5. 保持 pipe 入口不变
|
||||
|
||||
**绝对不要做**
|
||||
- 不要在这一张卡里做 client 交互体验优化
|
||||
- 不要加任务队列
|
||||
- 不要支持多客户端并发
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- 服务端会话锁生效
|
||||
- 共享 runner 可被 service 复用
|
||||
- pipe 模式入口未受影响
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add claw-ws service entrypoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 6:实现 sg_claw_client
|
||||
|
||||
**目标**
|
||||
新增一个薄客户端,提供类似 `claude/codex` 的交互式命令行体验。
|
||||
|
||||
**先做什么**
|
||||
1. 新增 `tests/service_task_flow_test.rs`
|
||||
2. 先写失败用例:
|
||||
- submit-task 送达 service
|
||||
- 日志按顺序流回
|
||||
- completion 只到一次
|
||||
- 完成后断开处理清晰
|
||||
|
||||
**要改哪些文件**
|
||||
- `src/bin/sg_claw_client.rs`
|
||||
- `Cargo.toml`
|
||||
- `tests/service_task_flow_test.rs`
|
||||
|
||||
**实现动作**
|
||||
1. 建立到本地 service 的 ws 连接
|
||||
2. 读取用户输入
|
||||
3. 发送 `SubmitTask`
|
||||
4. 实时打印日志
|
||||
5. 收到 `TaskComplete` 结束本轮
|
||||
|
||||
**绝对不要做**
|
||||
- 不要把 runtime、skills、browser backend 复制进 client
|
||||
- 不要让 client 直接连浏览器
|
||||
- 不要让 client 承担业务逻辑
|
||||
|
||||
**本卡测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test
|
||||
cargo build --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
**通过标准**
|
||||
- client 是薄壳
|
||||
- task flow 正常
|
||||
- 两个新 binary 可编译
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add interactive claw-ws client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 卡片 7:最终接线与回归验证
|
||||
|
||||
**目标**
|
||||
把 ws 路径接起来,同时确认 pipe 路径零回归。
|
||||
|
||||
**先做什么**
|
||||
1. 只增加最小配置项:
|
||||
- `browser_ws_url`
|
||||
- `service_ws_listen_addr`
|
||||
2. 检查外部工具命名保持稳定
|
||||
|
||||
**要改哪些文件**
|
||||
- `Cargo.toml`
|
||||
- `src/lib.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/runtime/engine.rs`(如确有必要)
|
||||
|
||||
**实现动作**
|
||||
1. 接入最小配置面
|
||||
2. 确保 pipe / ws 下工具命名一致
|
||||
3. 跑旧 pipe 回归
|
||||
4. 跑新 ws 测试
|
||||
5. 跑全量 Rust tests
|
||||
6. 编译所有 binary
|
||||
7. 做一次真实本地 smoke test
|
||||
|
||||
**本卡 pipe 回归命令**
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test --test pipe_handshake_test --test pipe_protocol_test --test task_protocol_test
|
||||
```
|
||||
|
||||
**本卡 ws 测试命令**
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test --test browser_ws_protocol_test --test browser_ws_backend_test --test browser_backend_capability_test --test service_ws_session_test --test service_task_flow_test
|
||||
```
|
||||
|
||||
**本卡全量命令**
|
||||
|
||||
```bash
|
||||
cargo test --tests
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
**手工验证**
|
||||
1. 启动浏览器,确认 `ws://127.0.0.1:12345` 可用
|
||||
2. `cargo run --bin sg_claw`
|
||||
3. 新终端运行 `cargo run --bin sg_claw_client`
|
||||
4. 发一个简单浏览器任务
|
||||
5. 确认日志流和单次 completion
|
||||
6. 确认旧 `cargo run` pipe 入口仍可启动
|
||||
|
||||
**通过标准**
|
||||
- pipe 模式零回归
|
||||
- ws 模式可独立工作
|
||||
- 两套模式并行存在
|
||||
|
||||
**完成后提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: wire parallel claw-ws transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一句话执行顺序
|
||||
|
||||
严格按下面顺序做:
|
||||
|
||||
1. 共享 runner
|
||||
2. browser backend 抽象
|
||||
3. ws 协议 codec
|
||||
4. ws backend
|
||||
5. service
|
||||
6. client
|
||||
7. 配置接线 + 回归
|
||||
|
||||
如果顺序乱了,最容易出现的问题是:
|
||||
- 上层重复实现
|
||||
- pipe 被误伤
|
||||
- ws 协议细节扩散到整个工程
|
||||
- service/client 提前写完后又被迫重构
|
||||
@@ -1,687 +0,0 @@
|
||||
# Claw-WS Parallel Transport Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a parallel `claw-ws` transport path that keeps the current pipe mode intact while introducing a long-lived `sg_claw` local service, an interactive `sg_claw_client`, and a browser WebSocket backend at `ws://127.0.0.1:12345`.
|
||||
|
||||
**Architecture:** First extract a transport-agnostic submit-task runner and browser backend abstraction from the current pipe-coupled flow. Keep the existing pipe path as one adapter/backend, then add a fixed-protocol browser WebSocket backend plus a small service/session layer and an interactive CLI client that reuse the same runtime, orchestration, and browser-facing tool adapters.
|
||||
|
||||
**Tech Stack:** Rust 2021, current sgclaw compat runtime, zeroclaw runtime engine, `serde`/`serde_json`, existing `MacPolicy`, and a blocking WebSocket crate for v1 (`tungstenite` preferred over a broad async rewrite).
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Keep the current pipe mode entrypoint and behavior working.
|
||||
- Do **not** replace the existing browser pipe path.
|
||||
- Add a **parallel** WebSocket path only.
|
||||
- v1 supports **one active client session** only.
|
||||
- Reuse existing tool names and runtime behavior whenever possible.
|
||||
- If WS `Eval` support is incomplete, disable eval-dependent browser-script skill exposure in WS mode rather than shipping partial behavior.
|
||||
- Do not broaden v1 with task queues, multi-client support, or admin endpoints.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to reuse
|
||||
|
||||
- Modify: `src/lib.rs` — current pipe bootstrap and receive loop; keep as the legacy pipe entrypoint.
|
||||
- Modify: `src/agent/mod.rs` — current `BrowserMessage::SubmitTask` entrypoint and config-loading flow.
|
||||
- Modify: `src/compat/runtime.rs` — compat runtime and tool assembly.
|
||||
- Modify: `src/compat/orchestration.rs` — direct workflow vs compat runtime routing.
|
||||
- Modify: `src/compat/browser_tool_adapter.rs` — exposes `browser_action` and `superrpa_browser`.
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs` — browser-script skill execution.
|
||||
- Modify: `src/compat/workflow_executor.rs` — direct browser workflows such as Zhihu flows.
|
||||
- Reuse: `src/pipe/browser_tool.rs` — current browser command executor; retain as the pipe backend implementation.
|
||||
- Reuse: `src/pipe/protocol.rs` — `BrowserMessage`, `AgentMessage`, `Action`, `ExecutionSurfaceMetadata`.
|
||||
- Reuse: `src/security/mac_policy.rs` — local action/domain guardrails.
|
||||
- Modify: `src/config/settings.rs` — minimal new config surface for WS mode.
|
||||
- Optional modify: `src/runtime/engine.rs` — only if backend capability wiring requires it.
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/agent/task_runner.rs` — shared submit-task execution entrypoint.
|
||||
- Create: `src/browser/mod.rs` — browser backend exports.
|
||||
- Create: `src/browser/backend.rs` — `BrowserBackend` trait and helpers.
|
||||
- Create: `src/browser/pipe_backend.rs` — wrapper around existing `BrowserPipeTool`.
|
||||
- Create: `src/browser/ws_protocol.rs` — fixed browser WS request/response codec.
|
||||
- Create: `src/browser/ws_backend.rs` — browser WS backend with blocking invoke semantics.
|
||||
- Create: `src/service/mod.rs` — service exports.
|
||||
- Create: `src/service/protocol.rs` — client/service WS message types.
|
||||
- Create: `src/service/server.rs` — single-session `sg_claw` server.
|
||||
- Create: `src/bin/sg_claw.rs` — service binary.
|
||||
- Create: `src/bin/sg_claw_client.rs` — interactive CLI client.
|
||||
- Create: `tests/task_runner_test.rs` — shared submit-task runner regressions.
|
||||
- Create: `tests/browser_backend_capability_test.rs` — backend capability/tool exposure tests.
|
||||
- Create: `tests/browser_ws_protocol_test.rs` — browser WS protocol tests.
|
||||
- Create: `tests/browser_ws_backend_test.rs` — browser WS backend tests.
|
||||
- Create: `tests/service_ws_session_test.rs` — single-session server tests.
|
||||
- Create: `tests/service_task_flow_test.rs` — client/service task flow tests.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract a shared submit-task runner
|
||||
|
||||
**Files:**
|
||||
- Create: `src/agent/task_runner.rs`
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Test: `tests/task_runner_test.rs`
|
||||
- Reuse: `src/compat/runtime.rs`, `src/compat/orchestration.rs`
|
||||
|
||||
- [ ] **Step 1: Write a failing runner regression test**
|
||||
|
||||
Create `tests/task_runner_test.rs` covering:
|
||||
- empty instruction returns the same `TaskComplete` failure summary
|
||||
- missing LLM config still returns the same summary shape
|
||||
- the pipe adapter still emits `LogEntry` before `TaskComplete`
|
||||
|
||||
- [ ] **Step 2: Run the targeted regression tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_task_flow_test --test task_runner_test
|
||||
```
|
||||
|
||||
Expected: `task_runner_test` fails because the shared runner does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Define the transport-neutral request model**
|
||||
|
||||
Create `src/agent/task_runner.rs` with a request struct that mirrors the current pipe payload:
|
||||
|
||||
```rust
|
||||
pub struct SubmitTaskRequest {
|
||||
pub instruction: String,
|
||||
pub conversation_id: Option<String>,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
pub page_url: Option<String>,
|
||||
pub page_title: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Normalize empty strings to `None` at the adapter boundary.
|
||||
|
||||
- [ ] **Step 4: Define an event sink abstraction**
|
||||
|
||||
Add a small trait that can emit the current agent events without depending on a specific transport:
|
||||
|
||||
```rust
|
||||
pub trait AgentEventSink {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError>;
|
||||
}
|
||||
```
|
||||
|
||||
The existing pipe transport should implement this first.
|
||||
|
||||
- [ ] **Step 5: Move submit-task execution into a shared function**
|
||||
|
||||
Extract the body currently inside `BrowserMessage::SubmitTask` handling from `src/agent/mod.rs` into a shared function such as:
|
||||
|
||||
```rust
|
||||
pub fn run_submit_task(
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError>
|
||||
```
|
||||
|
||||
This function must still:
|
||||
- validate empty instruction
|
||||
- load sgclaw settings
|
||||
- log runtime/config info
|
||||
- choose orchestration vs compat runtime
|
||||
- emit `AgentMessage::TaskComplete`
|
||||
|
||||
- [ ] **Step 6: Keep pipe mode as a thin adapter**
|
||||
|
||||
Refactor `handle_browser_message_with_context(...)` in `src/agent/mod.rs` so it only:
|
||||
- pattern matches `BrowserMessage`
|
||||
- converts `SubmitTask` into `SubmitTaskRequest`
|
||||
- forwards into `run_submit_task(...)`
|
||||
|
||||
- [ ] **Step 7: Re-run the runner regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test runtime_task_flow_test --test task_runner_test
|
||||
```
|
||||
|
||||
Expected: both tests pass and pipe behavior remains unchanged.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent/mod.rs src/agent/task_runner.rs src/lib.rs tests/task_runner_test.rs
|
||||
git commit -m "refactor: extract shared submit-task runner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Introduce a browser backend abstraction and wrap the current pipe implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/mod.rs`
|
||||
- Create: `src/browser/backend.rs`
|
||||
- Create: `src/browser/pipe_backend.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Modify: `src/compat/browser_tool_adapter.rs`
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Test: `tests/browser_backend_capability_test.rs`
|
||||
- Reuse: `src/pipe/browser_tool.rs`, `src/security/mac_policy.rs`
|
||||
|
||||
- [ ] **Step 1: Add a failing backend capability test**
|
||||
|
||||
Create `tests/browser_backend_capability_test.rs` to verify:
|
||||
- pipe backend still exposes privileged surface metadata
|
||||
- pipe backend still supports `Eval`
|
||||
- browser-script tool exposure is disabled when `supports_eval()` is false
|
||||
|
||||
- [ ] **Step 2: Run the current browser adapter tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test browser_backend_capability_test
|
||||
```
|
||||
|
||||
Expected: new capability test fails because the backend abstraction does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Define the shared browser interface**
|
||||
|
||||
Create `src/browser/backend.rs`:
|
||||
|
||||
```rust
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError>;
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata;
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the pipe backend as a wrapper**
|
||||
|
||||
Create `src/browser/pipe_backend.rs` that stores the current `BrowserPipeTool<T>` and forwards `invoke(...)` and `surface_metadata()` unchanged.
|
||||
|
||||
Pipe mode must continue using:
|
||||
- `perform_handshake(...)`
|
||||
- `MacPolicy::load_from_path(...)`
|
||||
- `BrowserPipeTool::new(...).with_response_timeout(...)`
|
||||
|
||||
- [ ] **Step 5: Refactor runtime and tool adapters to depend on `Arc<dyn BrowserBackend>`**
|
||||
|
||||
Update:
|
||||
- `src/compat/browser_tool_adapter.rs`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
|
||||
Preserve external tool names:
|
||||
- `browser_action`
|
||||
- `superrpa_browser`
|
||||
|
||||
- [ ] **Step 6: Add capability gating for eval-dependent script tools**
|
||||
|
||||
If `supports_eval()` is false, do **not** expose browser-script skill tools from `build_browser_script_skill_tools(...)` in that backend mode.
|
||||
|
||||
- [ ] **Step 7: Re-run browser adapter tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test browser_backend_capability_test
|
||||
```
|
||||
|
||||
Expected: all three pass.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser src/lib.rs src/compat/browser_tool_adapter.rs src/compat/browser_script_skill_tool.rs src/compat/runtime.rs src/compat/orchestration.rs src/compat/workflow_executor.rs tests/browser_backend_capability_test.rs
|
||||
git commit -m "refactor: abstract browser backend from pipe transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement the fixed browser WebSocket protocol codec in isolation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/ws_protocol.rs`
|
||||
- Test: `tests/browser_ws_protocol_test.rs`
|
||||
- Reuse: `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
|
||||
- [ ] **Step 1: Write failing protocol codec tests**
|
||||
|
||||
Create `tests/browser_ws_protocol_test.rs` covering:
|
||||
- exact outbound frame encoding
|
||||
- callback payload decoding
|
||||
- unknown callback format rejection
|
||||
- mapping coverage for every supported v1 action
|
||||
|
||||
- [ ] **Step 2: Run the protocol tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_protocol_test
|
||||
```
|
||||
|
||||
Expected: fail because the WS protocol codec does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Encode the exact browser frame shapes**
|
||||
|
||||
Create `src/browser/ws_protocol.rs` so it can build exact array-form payloads such as:
|
||||
|
||||
```rust
|
||||
[requesturl, "sgBrowserExcuteJsCodeByArea", target_url, js_code, area]
|
||||
```
|
||||
|
||||
Serialize to the JSON string format required by the browser service.
|
||||
|
||||
- [ ] **Step 4: Define the v1 action mapping table**
|
||||
|
||||
Support only the actions already needed by current sgclaw flows:
|
||||
- `Navigate`
|
||||
- `GetText`
|
||||
- `Click`
|
||||
- `Type`
|
||||
- `Eval`
|
||||
|
||||
Document which browser functions each one maps to and what assumptions they rely on.
|
||||
|
||||
- [ ] **Step 5: Define callback parsing and correlation rules**
|
||||
|
||||
Represent callback-bearing operations explicitly, including the callback function naming or request-correlation strategy the backend will depend on.
|
||||
|
||||
- [ ] **Step 6: Reject unsupported or malformed shapes early**
|
||||
|
||||
Fail fast for:
|
||||
- unsupported actions
|
||||
- malformed callback payloads
|
||||
- missing request correlation metadata
|
||||
|
||||
- [ ] **Step 7: Re-run the protocol tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_protocol_test
|
||||
```
|
||||
|
||||
Expected: pass with no network dependency.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_protocol.rs tests/browser_ws_protocol_test.rs
|
||||
git commit -m "test: codify fixed browser websocket protocol"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Build the browser WS backend with synchronous invoke semantics
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/ws_backend.rs`
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- Test: `tests/browser_ws_backend_test.rs`
|
||||
- Reuse: `CommandOutput`, `PipeError`, `ExecutionSurfaceMetadata`, `MacPolicy`
|
||||
|
||||
- [ ] **Step 1: Write failing backend behavior tests**
|
||||
|
||||
Create `tests/browser_ws_backend_test.rs` covering:
|
||||
- zero return + no callback => success
|
||||
- non-zero return => failure
|
||||
- zero return + callback => success with normalized `CommandOutput`
|
||||
- callback timeout => timeout error
|
||||
- dropped socket => clear failure
|
||||
|
||||
- [ ] **Step 2: Run backend tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test
|
||||
```
|
||||
|
||||
Expected: fail because the WS backend does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Build a long-lived browser connection manager**
|
||||
|
||||
Implement `src/browser/ws_backend.rs` to connect to `ws://127.0.0.1:12345` and expose blocking `invoke(...)` calls.
|
||||
|
||||
Use a dedicated connection loop plus request/response coordination instead of scattering raw socket calls through the runtime.
|
||||
|
||||
- [ ] **Step 4: Preserve local guardrails before send**
|
||||
|
||||
Validate `MacPolicy` before translating an action into the browser WS protocol, matching current pipe backend behavior.
|
||||
|
||||
- [ ] **Step 5: Normalize immediate status returns and delayed callbacks**
|
||||
|
||||
For each `invoke(...)` call:
|
||||
- fail immediately on non-zero return codes
|
||||
- succeed immediately for operations with no data callback
|
||||
- wait for the matching callback for result-bearing operations
|
||||
- convert the final outcome into `CommandOutput`
|
||||
|
||||
- [ ] **Step 6: Keep v1 concurrency intentionally serialized**
|
||||
|
||||
Allow only one in-flight browser request at a time unless the browser callback protocol proves a stable request-id guarantee.
|
||||
|
||||
- [ ] **Step 7: Re-run backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test
|
||||
```
|
||||
|
||||
Expected: pass using mocks/fakes, not the real browser.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/mod.rs src/browser/ws_backend.rs tests/browser_ws_backend_test.rs
|
||||
git commit -m "feat: add browser websocket backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add the `sg_claw` service protocol and single-session server
|
||||
|
||||
**Files:**
|
||||
- Create: `src/service/mod.rs`
|
||||
- Create: `src/service/protocol.rs`
|
||||
- Create: `src/service/server.rs`
|
||||
- Create: `src/bin/sg_claw.rs`
|
||||
- Modify: `src/lib.rs`
|
||||
- Modify: `Cargo.toml`
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Reuse: `AgentMessage::LogEntry`, `AgentMessage::TaskComplete`, `SubmitTaskRequest`, `run_submit_task(...)`
|
||||
|
||||
- [ ] **Step 1: Write failing service session tests**
|
||||
|
||||
Create `tests/service_ws_session_test.rs` to verify:
|
||||
- first client attaches
|
||||
- second client gets `Busy`
|
||||
- disconnect resets session state
|
||||
- overlapping task submission is rejected clearly
|
||||
|
||||
- [ ] **Step 2: Run the session tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test
|
||||
```
|
||||
|
||||
Expected: fail because the service layer does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Define a thin client/service WS protocol**
|
||||
|
||||
In `src/service/protocol.rs`, reuse existing task/event shapes as much as possible:
|
||||
|
||||
```rust
|
||||
ClientMessage::SubmitTask { instruction, conversation_id, messages, page_url, page_title }
|
||||
ClientMessage::Ping
|
||||
ServiceMessage::LogEntry { level, message }
|
||||
ServiceMessage::TaskComplete { success, summary }
|
||||
ServiceMessage::Busy { message }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the service event sink adapter**
|
||||
|
||||
Implement `AgentEventSink` for the service session writer so the shared task runner can stream `LogEntry` and `TaskComplete` over the service WebSocket.
|
||||
|
||||
- [ ] **Step 5: Implement single-active-client session state**
|
||||
|
||||
Model explicit states such as:
|
||||
- `Idle`
|
||||
- `ClientAttached`
|
||||
- `TaskRunning`
|
||||
|
||||
Reject a second client with `ServiceMessage::Busy` and close the socket. Reject overlapping tasks instead of queueing them.
|
||||
|
||||
- [ ] **Step 6: Add the service binary**
|
||||
|
||||
Create `src/bin/sg_claw.rs` that:
|
||||
- loads config
|
||||
- creates the browser WS backend
|
||||
- listens for local client connections
|
||||
- routes `SubmitTask` into `run_submit_task(...)`
|
||||
|
||||
Keep `src/main.rs` and the existing `sgclaw::run()` pipe path unchanged.
|
||||
|
||||
- [ ] **Step 7: Re-run the session tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test
|
||||
```
|
||||
|
||||
Expected: pass without the real browser.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service src/bin/sg_claw.rs src/lib.rs Cargo.toml tests/service_ws_session_test.rs
|
||||
git commit -m "feat: add claw-ws service entrypoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add the `sg_claw_client` interactive CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bin/sg_claw_client.rs`
|
||||
- Modify: `Cargo.toml`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `src/service/protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing client/service task flow tests**
|
||||
|
||||
Create `tests/service_task_flow_test.rs` to verify:
|
||||
- the submit-task request reaches the service
|
||||
- log entries stream in order
|
||||
- the final summary arrives exactly once
|
||||
- disconnect after task completion is handled cleanly
|
||||
|
||||
- [ ] **Step 2: Run the flow tests first**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test
|
||||
```
|
||||
|
||||
Expected: fail because the client does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement a thin interactive client loop**
|
||||
|
||||
Create `src/bin/sg_claw_client.rs` that:
|
||||
- connects to the local `sg_claw` service
|
||||
- reads a line of user input
|
||||
- sends `ClientMessage::SubmitTask`
|
||||
- prints streamed `LogEntry` events as they arrive
|
||||
- ends the turn on `TaskComplete`
|
||||
|
||||
- [ ] **Step 4: Keep the client intentionally dumb**
|
||||
|
||||
Do **not** duplicate runtime logic in the client. Browser access, skills, orchestration, and task execution remain entirely inside the service.
|
||||
|
||||
- [ ] **Step 5: Re-run the flow tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test
|
||||
```
|
||||
|
||||
Expected: pass without the real browser.
|
||||
|
||||
- [ ] **Step 6: Build the new binaries explicitly**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: both binaries compile successfully.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bin/sg_claw_client.rs Cargo.toml tests/service_task_flow_test.rs
|
||||
git commit -m "feat: add interactive claw-ws client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Finish wiring, preserve pipe mode, and verify end-to-end behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `Cargo.toml`
|
||||
- Modify: `src/lib.rs`
|
||||
- Modify: `src/config/settings.rs`
|
||||
- Optional modify: `src/runtime/engine.rs`
|
||||
- Reuse: `tests/browser_tool_test.rs`, `tests/runtime_task_flow_test.rs`, `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add only the minimum config surface for v1**
|
||||
|
||||
Add settings such as:
|
||||
- `browser_ws_url` defaulting to `ws://127.0.0.1:12345`
|
||||
- `service_ws_listen_addr` defaulting to local loopback
|
||||
|
||||
Do **not** change the meaning of existing browser backend/profile settings just to represent service mode.
|
||||
|
||||
- [ ] **Step 2: Keep external browser tool naming stable**
|
||||
|
||||
Verify that the runtime still exposes:
|
||||
- `superrpa_browser`
|
||||
- `browser_action`
|
||||
|
||||
under both pipe and WS modes where the backend supports them.
|
||||
|
||||
- [ ] **Step 3: Re-run the current pipe regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test --test pipe_handshake_test --test pipe_protocol_test --test task_protocol_test
|
||||
```
|
||||
|
||||
Expected: all existing pipe-oriented tests still pass unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the new WS-focused suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test --test browser_ws_protocol_test --test browser_ws_backend_test --test browser_backend_capability_test --test service_ws_session_test --test service_task_flow_test
|
||||
```
|
||||
|
||||
Expected: all new tests pass without launching the real browser.
|
||||
|
||||
- [ ] **Step 5: Run a full Rust test sweep**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --tests
|
||||
```
|
||||
|
||||
Expected: all Rust tests pass.
|
||||
|
||||
- [ ] **Step 6: Build all three binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all three binaries compile.
|
||||
|
||||
- [ ] **Step 7: Perform a manual local smoke test**
|
||||
|
||||
Manual test:
|
||||
1. Start the browser app so `ws://127.0.0.1:12345` is available.
|
||||
2. Run `cargo run --bin sg_claw`.
|
||||
3. In another terminal, run `cargo run --bin sg_claw_client`.
|
||||
4. Submit a simple browser task such as opening a page or fetching visible text.
|
||||
5. Confirm the client prints streaming logs and exactly one final completion summary.
|
||||
6. Confirm the old pipe-mode entry still starts via `cargo run`.
|
||||
|
||||
Expected: both modes work side-by-side.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add Cargo.toml src/lib.rs src/config/settings.rs src/runtime/engine.rs
|
||||
git commit -m "feat: wire parallel claw-ws transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Fast regression checks
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test
|
||||
```
|
||||
|
||||
Expected: current pipe/browser runtime behavior remains green.
|
||||
|
||||
### Full Rust test sweep
|
||||
|
||||
```bash
|
||||
cargo test --tests
|
||||
```
|
||||
|
||||
Expected: all Rust tests pass.
|
||||
|
||||
### Binary build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all three binaries compile.
|
||||
|
||||
### Manual end-to-end verification
|
||||
|
||||
- Browser app listening on `ws://127.0.0.1:12345`
|
||||
- `cargo run --bin sg_claw`
|
||||
- `cargo run --bin sg_claw_client`
|
||||
- submit one browser task
|
||||
- verify streaming logs, final completion, and single-client lock behavior
|
||||
- verify `cargo run` still preserves old pipe bootstrap
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- Keep the current pipe bootstrap in `src/lib.rs` intact until the shared runner and pipe backend wrapper are both green.
|
||||
- Prefer small commits at each task boundary.
|
||||
- Keep the new WS path additive and isolated.
|
||||
- Do not ship partial browser capabilities under stable tool names.
|
||||
- Treat `docs/_tmp_sgbrowser_ws_api_doc.txt` as the browser WS protocol source of truth while implementing `src/browser/ws_protocol.rs`.
|
||||
@@ -1,607 +0,0 @@
|
||||
# WS Browser Backend Auth Replacement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the ws service path’s empty-session-key `BrowserPipeTool` dependency with a ws-native browser backend path so real browser websocket calls work, while preserving legacy pipe behavior exactly.
|
||||
|
||||
**Architecture:** Keep the existing pipe entry untouched and add a ws-only parallel execution seam. The ws service path will construct a `ServiceBrowserWsClient` plus `WsBrowserBackend`, pass that backend through a new ws-only submit-task entry, and let the existing compat/runtime stack consume `Arc<dyn BrowserBackend>` instead of requiring `BrowserPipeTool` on the ws path.
|
||||
|
||||
**Tech Stack:** Rust 2021, current sgclaw agent/task runner, compat runtime/orchestration stack, `tungstenite`, `serde_json`, existing `MacPolicy`, existing `BrowserBackend`/`WsBrowserBackend`, and the current Rust test suite.
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Only change the ws service path.
|
||||
- Do **not** change `src/lib.rs` pipe runtime behavior.
|
||||
- Do **not** change pipe handshake semantics.
|
||||
- Do **not** introduce fake session keys, fake HMAC seeds, or auth bypasses.
|
||||
- Keep legacy `run_submit_task(...)` available for the pipe entry.
|
||||
- If a shared layer must change, add a parallel ws-only entry instead of weakening the pipe path.
|
||||
- Keep the current single-client, single-task service model.
|
||||
- Do not broaden this slice into browser process launch, queueing, multi-client support, or protocol extensions.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/agent/task_runner.rs` — keep the current pipe-oriented submit path and add the ws-only backend-based submit path.
|
||||
- Modify: `src/compat/runtime.rs` — add a backend-driven execution entry that accepts `Arc<dyn BrowserBackend>` directly, while keeping the current pipe-oriented public functions behaviorally unchanged.
|
||||
- Modify: `src/compat/orchestration.rs` — add a matching backend-driven execution entry for orchestration/direct-route flows, while keeping the current pipe-oriented public functions behaviorally unchanged.
|
||||
- Modify: `src/compat/workflow_executor.rs` — add backend-driven sibling APIs for direct-route/fallback execution, while keeping the current pipe-oriented public functions behaviorally unchanged.
|
||||
- Modify: `src/service/server.rs` — replace the ws service’s `BrowserPipeTool::new(..., vec![])` path with a ws-native `WsClient` + `WsBrowserBackend` path.
|
||||
- Modify: `src/service/mod.rs` — only if minimal re-export or call-signature changes are needed around the new ws-only submit path.
|
||||
- Modify: `src/browser/mod.rs` — only if export cleanup is truly needed for the service wiring.
|
||||
- Reuse: `src/agent/mod.rs` — keep the current pipe routing unchanged unless a tiny internal refactor is strictly needed to reuse shared code.
|
||||
- Reuse: `src/browser/backend.rs` — existing shared browser backend trait.
|
||||
- Reuse: `src/browser/ws_backend.rs` — existing ws-native browser backend implementation.
|
||||
- Reuse: `src/browser/ws_protocol.rs` — existing browser websocket protocol codec.
|
||||
- Reuse: `src/compat/browser_tool_adapter.rs` — should already speak `BrowserBackend`; only touch if a narrow ws regression forces it.
|
||||
- Reuse: `src/compat/browser_script_skill_tool.rs` — eval-capability gating already exists; only touch if a narrow ws regression forces it.
|
||||
- Reuse: `src/lib.rs` — pipe entrypoint must remain behaviorally unchanged; verify only.
|
||||
|
||||
### Existing tests to extend
|
||||
|
||||
- Modify: `tests/browser_ws_backend_test.rs` — keep existing ws backend coverage green after the service adapter wiring lands.
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs` — re-verify eval-gating and browser-script behavior after the shared compat/runtime seam changes.
|
||||
- Modify: `tests/service_ws_session_test.rs` — update service-side unit/session tests to exercise the ws-only submit path.
|
||||
- Modify: `tests/service_task_flow_test.rs` — add client→service chain coverage proving the ws path reaches a browser websocket and no longer emits `invalid hmac seed`.
|
||||
- Modify: `src/service/server.rs` under `#[cfg(test)]` if the private service-side ws adapter cannot be exercised from an integration test crate without changing production visibility.
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `tests/browser_ws_service_adapter_test.rs` if the adapter can be exercised through a public seam; otherwise keep the deterministic adapter tests as unit tests in `src/service/server.rs` so no production visibility changes are required.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Lock the ws-only behavior with deterministic failing tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/browser_ws_service_adapter_test.rs`
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `tests/browser_ws_backend_test.rs`, `src/browser/ws_backend.rs`, `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing backend/adapter test**
|
||||
|
||||
Create `tests/browser_ws_service_adapter_test.rs` with one focused test that directly exercises the ws-service adapter layer, without `sg_claw_client`, without LLM planning, and without natural-language tasks.
|
||||
|
||||
Start with the smallest real behavior from the spec:
|
||||
- fake browser websocket server accepts one connection
|
||||
- the ws-service adapter builds the same kind of client the service will use
|
||||
- `WsBrowserBackend.invoke(Action::Navigate, ...)` succeeds on status `0`
|
||||
- the fake server receives one text frame that decodes as a ws `Navigate` call
|
||||
|
||||
- [ ] **Step 2: Run that single new test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test ws_service_backend_navigate_reaches_browser_websocket -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client/adapter does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing deterministic test**
|
||||
|
||||
In the same file, add a test for the forced-close path:
|
||||
- fake browser websocket server accepts a request, then closes/reset the socket before returning a status frame
|
||||
- observe the error at the `WsBrowserBackend.invoke(...)` call site
|
||||
- assert the outward error is exactly `PipeError::PipeClosed`
|
||||
|
||||
- [ ] **Step 4: Run only the forced-close test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test ws_service_backend_maps_browser_disconnect_to_pipe_closed -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client/adapter still does not exist.
|
||||
|
||||
- [ ] **Step 5: Add the third failing deterministic test**
|
||||
|
||||
In the same file, add a callback-timeout test:
|
||||
- fake browser websocket server returns status `0`
|
||||
- it never returns the callback frame
|
||||
- assert the outward error at `invoke(...)` is exactly `PipeError::Timeout`
|
||||
|
||||
Use a tiny response timeout in the backend under test.
|
||||
|
||||
- [ ] **Step 6: Run only the callback-timeout test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test ws_service_backend_times_out_waiting_for_callback -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client/adapter still does not exist.
|
||||
|
||||
- [ ] **Step 7: Add the end-to-end failing regression for the auth bug**
|
||||
|
||||
Extend `tests/service_task_flow_test.rs` with one client→service integration test that:
|
||||
- starts a fake browser websocket server
|
||||
- starts the real `sg_claw` service binary with a temp config pointing `browserWsUrl` to that fake server
|
||||
- starts the real `sg_claw_client`
|
||||
- submits the fixed instruction `打开知乎热榜并读取页面主区域文本`
|
||||
- captures service/client output
|
||||
- asserts the fake browser server received at least one text frame
|
||||
- asserts output does **not** contain `invalid hmac seed: session key must not be empty`
|
||||
|
||||
Do not assert planner details here. This test only proves the service path no longer goes through the empty-session-key auth failure.
|
||||
|
||||
- [ ] **Step 8: Run the integration regression and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test ws_service_submit_task_no_longer_hits_invalid_hmac_seed -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL on the current code because the ws service still constructs `BrowserPipeTool::new(..., vec![])`.
|
||||
|
||||
- [ ] **Step 9: Commit the red tests only after they are all in place**
|
||||
|
||||
Do not commit yet if any required red test was skipped. The next task will make them pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add a ws-only browser-backend execution seam without changing the pipe path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent/task_runner.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Reuse: `src/agent/mod.rs`, `src/browser/backend.rs`
|
||||
- Test: `tests/task_runner_test.rs`, `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the smallest failing runner-level ws entry test**
|
||||
|
||||
Extend `tests/task_runner_test.rs` with a focused test that proves there is a ws-only submit entry accepting `Arc<dyn BrowserBackend>` and an arbitrary event sink, while the old `run_submit_task(...)` signature still exists for pipe mode.
|
||||
|
||||
The test can stay on the missing-LLM-config path so it does not need a real browser call. It should compile only once the new ws-only function exists.
|
||||
|
||||
- [ ] **Step 2: Run the targeted runner test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test ws_only_submit_task_entry_accepts_browser_backend -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL to compile or FAIL to link because the ws-only entry does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the new ws-only submit-task entry in `src/agent/task_runner.rs`**
|
||||
|
||||
Keep the current pipe function intact:
|
||||
|
||||
```rust
|
||||
pub fn run_submit_task<T: Transport + 'static>(... browser_tool: &BrowserPipeTool<T>, ...)
|
||||
```
|
||||
|
||||
Add a parallel entry for the service path, for example:
|
||||
|
||||
```rust
|
||||
pub fn run_submit_task_with_browser_backend(
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- share as much internal logic as possible with the pipe path
|
||||
- do not change `run_submit_task(...)` behavior
|
||||
- do not change `src/agent/mod.rs` pipe wiring except, at most, small internal refactoring to reuse common code
|
||||
|
||||
- [ ] **Step 4: Add a backend-driven compat runtime entry**
|
||||
|
||||
In `src/compat/runtime.rs`, add a parallel entry that accepts `Arc<dyn BrowserBackend>` directly instead of `BrowserPipeTool<T>`.
|
||||
|
||||
Keep the existing pipe-oriented public function in place.
|
||||
|
||||
The backend-driven entry must preserve:
|
||||
- existing log emission order
|
||||
- tool names (`superrpa_browser`, `browser_action`)
|
||||
- existing browser-script tool gating behavior
|
||||
- existing office/screen tool attachment logic
|
||||
- existing conversation seeding and provider setup
|
||||
|
||||
- [ ] **Step 5: Add backend-driven orchestration and workflow-executor entries**
|
||||
|
||||
In `src/compat/orchestration.rs`, add the matching backend-driven entry so direct-route flows and fallback flows can run with `Arc<dyn BrowserBackend>` on the ws path.
|
||||
|
||||
In `src/compat/workflow_executor.rs`, add backend-driven sibling APIs for any direct-route/fallback execution that is currently hard-wired to `BrowserPipeTool<T>`.
|
||||
|
||||
Keep the existing pipe-oriented orchestration and workflow-executor public functions in place.
|
||||
|
||||
- [ ] **Step 6: Route the new ws-only submit entry through the backend-driven compat/orchestration/workflow-executor path**
|
||||
|
||||
Inside `src/agent/task_runner.rs`, make the new ws-only submit entry call the new backend-based compat/orchestration functions, while the old pipe entry keeps calling the old pipe-based functions.
|
||||
|
||||
This is the core compatibility seam, and it must cover both normal compat-runtime execution and direct-route/fallback workflow execution.
|
||||
|
||||
- [ ] **Step 7: Re-run the new runner test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test ws_only_submit_task_entry_accepts_browser_backend -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Re-run the full runner, workflow, and browser-script regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test task_runner_test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Then run the workflow executor unit coverage that protects direct-route behavior:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all existing runner, workflow, and browser-script tests still pass, proving the pipe-facing path, direct-route behavior, and eval-gating stayed stable.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent/task_runner.rs src/compat/runtime.rs src/compat/orchestration.rs src/compat/workflow_executor.rs tests/task_runner_test.rs tests/browser_script_skill_tool_test.rs
|
||||
git commit -m "refactor: add ws-only browser backend submit path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Replace the ws service’s empty-session-key browser tool with a ws-native backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
- Modify: `src/service/mod.rs` only if minimal re-export or signature cleanup is required
|
||||
- Modify: `src/browser/mod.rs` only if export cleanup is needed
|
||||
- Test: `tests/browser_ws_service_adapter_test.rs`
|
||||
- Reuse: `src/browser/ws_backend.rs`, `src/browser/ws_protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write the smallest failing service-side adapter compile target**
|
||||
|
||||
Add a compile-level or construction-level assertion in `tests/browser_ws_service_adapter_test.rs` that the service path can construct the new service-side ws client type used by `serve_client(...)`.
|
||||
|
||||
This should fail until the type exists in `src/service/server.rs`.
|
||||
|
||||
- [ ] **Step 2: Run the adapter test group and watch the constructor test fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the service-side ws client type does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Introduce `ServiceBrowserWsClient` in `src/service/server.rs`**
|
||||
|
||||
Create a narrow client type that owns the real websocket connection to `browser_ws_url` and implements `WsClient`:
|
||||
|
||||
Required responsibilities only:
|
||||
- lazily connect on first use
|
||||
- send raw text frames
|
||||
- receive raw text frames with timeout
|
||||
- map close/reset to exactly `PipeError::PipeClosed`
|
||||
- map connect failure to exactly `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
- map timeouts to exactly `PipeError::Timeout`
|
||||
|
||||
Do **not** duplicate `WsBrowserBackend` responsibilities here.
|
||||
|
||||
- [ ] **Step 4: Remove ws-path use of `BrowserPipeTool::new(..., vec![])`**
|
||||
|
||||
In `serve_client(...)`, replace this shape:
|
||||
|
||||
```rust
|
||||
let transport = Arc::new(ServiceBrowserTransport::new(...));
|
||||
let browser_tool = BrowserPipeTool::new(transport.clone(), mac_policy.clone(), vec![])
|
||||
```
|
||||
|
||||
with the ws-native shape:
|
||||
|
||||
```rust
|
||||
let ws_client = Arc::new(ServiceBrowserWsClient::new(...));
|
||||
let browser_backend: Arc<dyn BrowserBackend> = Arc::new(
|
||||
WsBrowserBackend::new(ws_client, mac_policy.clone(), initial_request_url(...))
|
||||
.with_response_timeout(BROWSER_RESPONSE_TIMEOUT)
|
||||
);
|
||||
```
|
||||
|
||||
Then route the task through the new ws-only submit entry from Task 2.
|
||||
|
||||
- [ ] **Step 5: Delete or narrow old ws-path transport code that duplicated protocol handling**
|
||||
|
||||
Remove the service-only callback polling / response queue logic that existed solely to feed `BrowserPipeTool`.
|
||||
|
||||
Keep only what is still needed for:
|
||||
- service client websocket I/O (`sg_claw_client` ↔ `sg_claw`)
|
||||
- browser websocket I/O (`sg_claw` ↔ `browser_ws_url`)
|
||||
|
||||
Do not leave two competing ws protocol implementations in `src/service/server.rs`.
|
||||
|
||||
- [ ] **Step 6: Re-run deterministic adapter/backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including:
|
||||
- navigate success
|
||||
- disconnect => `PipeError::PipeClosed`
|
||||
- callback timeout => `PipeError::Timeout`
|
||||
|
||||
- [ ] **Step 7: Re-run existing ws backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, confirming the service adapter change did not break the existing ws backend semantics.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs src/service/mod.rs src/browser/mod.rs tests/browser_ws_service_adapter_test.rs
|
||||
git commit -m "feat: switch ws service to ws-native browser backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Prove the auth bug is gone and pipe mode is unchanged
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/service_ws_session_test.rs`
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `src/lib.rs`, `src/service/mod.rs`, `src/compat/workflow_executor.rs`
|
||||
|
||||
- [ ] **Step 1: Update service session tests for the new ws-only call path**
|
||||
|
||||
Adjust any service session tests that still call `handle_client_message(...)` through the old ws-path `BrowserPipeTool` assumption.
|
||||
|
||||
Prefer one of these narrow approaches:
|
||||
- overload `handle_client_message(...)` with a backend-based service entry used only in ws tests, or
|
||||
- keep `handle_client_message(...)` pipe-oriented and test the ws path through `serve_client(...)` and the real service binary instead
|
||||
|
||||
Choose the option that changes the fewest existing tests and leaves the pipe path simplest.
|
||||
|
||||
- [ ] **Step 2: Run the focused service session file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Make the auth-regression integration test pass**
|
||||
|
||||
Re-run the exact end-to-end regression from Task 1:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test ws_service_submit_task_no_longer_hits_invalid_hmac_seed -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, with evidence that:
|
||||
- the fake browser websocket server received at least one frame
|
||||
- output no longer contains `invalid hmac seed: session key must not be empty`
|
||||
|
||||
- [ ] **Step 4: Add one explicit mandatory assertion for browser websocket connect failures**
|
||||
|
||||
Add one focused assertion that a browser websocket connect failure surfaces outward as:
|
||||
|
||||
```rust
|
||||
PipeError::Protocol("browser websocket connect failed: ...")
|
||||
```
|
||||
|
||||
Do not leave this semantic implied.
|
||||
|
||||
- [ ] **Step 5: Add one explicit ws direct-route regression**
|
||||
|
||||
Add one focused regression that proves a ws-backed browser backend can traverse a direct-route/fallback path that currently flows through `src/compat/workflow_executor.rs`.
|
||||
|
||||
Keep it deterministic and narrow. Prefer a fake backend plus direct function invocation over a planner-dependent natural-language end-to-end test.
|
||||
|
||||
- [ ] **Step 6: Run the ws-focused regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Then run the workflow-executor direct-route coverage:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all ws-focused and direct-route workflow tests pass.
|
||||
|
||||
- [ ] **Step 7: Run the required pipe and browser-script regression suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all required pipe and browser-script regressions pass unchanged.
|
||||
|
||||
- [ ] **Step 8: Run the full relevant verification sweep**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: full mixed ws+pipe verification passes in fresh runs.
|
||||
|
||||
- [ ] **Step 9: Build the affected binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all three binaries compile.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/service_ws_session_test.rs tests/service_task_flow_test.rs tests/browser_ws_service_adapter_test.rs src/compat/workflow_executor.rs
|
||||
git commit -m "test: verify ws auth replacement and pipe regressions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual smoke verification against the real browser
|
||||
|
||||
**Files:**
|
||||
- Reuse only: no code changes unless a verified bug is found during smoke work
|
||||
|
||||
- [ ] **Step 1: Start the real browser websocket target**
|
||||
|
||||
Confirm the real sgBrowser endpoint is reachable at the configured `browserWsUrl`.
|
||||
|
||||
- [ ] **Step 2: Start the real ws service**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected: service prints the resolved listen address and browser websocket URL.
|
||||
|
||||
- [ ] **Step 3: Run the minimal browser task through the real client**
|
||||
|
||||
Run from a separate terminal with UTF-8-safe input:
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Submit:
|
||||
|
||||
```text
|
||||
打开知乎热榜并读取页面主区域文本
|
||||
```
|
||||
|
||||
Expected:
|
||||
- browser actions start executing
|
||||
- no `invalid hmac seed: session key must not be empty`
|
||||
- one final completion is returned
|
||||
|
||||
- [ ] **Step 4: Run the old Zhihu skill smoke**
|
||||
|
||||
Submit:
|
||||
|
||||
```text
|
||||
读取知乎热榜数据,并导出 excel 文件
|
||||
```
|
||||
|
||||
Expected: the task enters the real browser action path instead of dying at auth initialization.
|
||||
|
||||
- [ ] **Step 5: Re-check the legacy pipe entry without modifying it**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
Only verify startup behavior appropriate for the current pipe environment. Do not change pipe code during this smoke step.
|
||||
|
||||
- [ ] **Step 6: If a smoke failure appears, stop and debug before editing**
|
||||
|
||||
Any failure found here must be handled with:
|
||||
- a fresh reproducer
|
||||
- a failing automated test if feasible
|
||||
- the smallest scoped fix
|
||||
|
||||
Do not fold speculative smoke fixes into this slice.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Deterministic ws-only tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_service_adapter_test --test browser_ws_backend_test --test browser_ws_protocol_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: ws-native backend and service adapter semantics are green without LLM/planner dependencies.
|
||||
|
||||
### Client→service ws chain tests
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: the ws service path reaches the browser websocket and no longer emits the empty-session-key auth failure.
|
||||
|
||||
### Required pipe and browser-script regressions
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: legacy pipe behavior and browser-script eval-gating remain unchanged.
|
||||
|
||||
### Binary build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: all affected binaries compile.
|
||||
|
||||
### Manual end-to-end verification
|
||||
|
||||
- real sgBrowser running at configured `browserWsUrl`
|
||||
- `cargo run --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"`
|
||||
- `cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"`
|
||||
- run the Zhihu minimal task
|
||||
- run the old Zhihu export task
|
||||
- verify no `invalid hmac seed` appears
|
||||
- verify pipe startup still behaves as before
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- Keep the current pipe bootstrap in `src/lib.rs` untouched.
|
||||
- Prefer adding ws-only functions over changing existing pipe signatures.
|
||||
- Reuse `WsBrowserBackend` for protocol semantics; do not re-implement callback handling inside the service.
|
||||
- Keep `ServiceBrowserWsClient` narrow: connection lifecycle + raw websocket I/O only.
|
||||
- Preserve exact outward error semantics from the spec:
|
||||
- connect failure => `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
- non-zero status => `PipeError::Protocol("browser returned non-zero status: ...")`
|
||||
- callback timeout => `PipeError::Timeout`
|
||||
- close/reset => `PipeError::PipeClosed`
|
||||
- Do not claim success until the mixed ws+pipe verification commands have been run fresh.
|
||||
@@ -1,482 +0,0 @@
|
||||
# WS Browser Bridge Path Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the raw-ws-direct browser execution assumption with a bridge-backed browser integration path that matches the validated FunctionsUI / BrowserAction / CommandRouter model while preserving existing pipe behavior.
|
||||
|
||||
**Architecture:** Keep the current Rust-side browser orchestration flow centered on `Arc<dyn BrowserBackend>`, but stop treating `WsBrowserBackend` as the real production browser surface. Model the validated bridge as two explicit layers: Layer 1 session/lifecycle calls (`sgclawConnect`, `sgclawStart`, `sgclawStop`, `sgclawSubmitTask`) and Layer 2 browser-action execution (`window.sgFunctionsUI(...)`, `window.BrowserAction(...)`, `CommandRouter`). The new backend targets Layer 2 only through a narrow repo-local `BridgeActionTransport` seam, while lifecycle/session concerns stay separate from per-action browser execution.
|
||||
|
||||
**Tech Stack:** Rust 2021, existing `BrowserBackend` abstraction, compat/runtime/orchestration stack, current service/task runner integration, existing bridge-oriented design docs, existing Rust unit/integration test suite.
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do **not** continue extending raw external sgBrowser websocket business-frame handling as the mainline path.
|
||||
- Do **not** modify `src/lib.rs`, pipe handshake behavior, or the working `BrowserPipeTool` path.
|
||||
- Do **not** invent a parallel browser-command contract unrelated to the documented bridge surface.
|
||||
- Do **not** rewrite the whole compat/runtime stack when a narrow adapter will do.
|
||||
- Do **not** assume access to the full SuperRPA browser-host codebase from this repository; encode the validated contract at the nearest seam available here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- export the new bridge contract/transport/backend modules explicitly
|
||||
- Modify: `src/browser/backend.rs`
|
||||
- only if a tiny shared helper or trait documentation update is needed for the new bridge-backed backend
|
||||
- Modify: `src/compat/browser_tool_adapter.rs`
|
||||
- ensure existing browser action mapping remains reusable with the new backend implementation
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- wire the bridge-backed browser backend into the ws service/browser execution path without changing the pipe path
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- only where browser backend wiring requires the bridge-backed path to flow through orchestration
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- preserve direct-route/fallback use of `BrowserBackend` when the backend is bridge-backed instead of websocket-backed
|
||||
- Modify: `src/service/server.rs`
|
||||
- replace the current real-browser execution assumption with bridge-backend construction plus a repo-local bridge transport provider seam for the relevant service path
|
||||
- Modify: `tests/compat_browser_tool_test.rs`
|
||||
- extend browser tool mapping coverage if needed for bridge-backed execution
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- replace raw-ws-direct expectations with bridge-path expectations where appropriate
|
||||
- Modify: `tests/service_ws_session_test.rs`
|
||||
- update service-side tests if they currently assume the real browser path is raw websocket driven
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/browser/bridge_contract.rs`
|
||||
- narrow, explicit contract types that keep lifecycle/session bridge calls separate from browser-action execution requests/replies
|
||||
- Create: `src/browser/bridge_transport.rs`
|
||||
- repo-local `BridgeActionTransport` seam used by the backend and injected by service/runtime wiring
|
||||
- Create: `src/browser/bridge_backend.rs`
|
||||
- new `BrowserBackend` implementation that maps browser actions onto the Layer-2 bridge action contract through `BridgeActionTransport`
|
||||
- Create: `tests/browser_bridge_backend_test.rs`
|
||||
- deterministic unit tests for action-to-bridge mapping and reply/error normalization using a fake bridge transport
|
||||
- Create: `tests/browser_bridge_contract_test.rs`
|
||||
- narrow tests proving the two bridge layers stay explicit and browser-action requests remain semantic rather than raw-websocket-shaped
|
||||
|
||||
### Evidence files to consult during implementation
|
||||
|
||||
- Read: `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- Read: `frontend/archive/sgClaw验证-已归档/testRunner.js`
|
||||
- Read: `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md`
|
||||
- Read: `docs/archive/项目管理与排期/协作时间表.md`
|
||||
- Read: `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Lock the bridge contract in deterministic tests before adding the backend
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/bridge_contract.rs`
|
||||
- Create: `tests/browser_bridge_contract_test.rs`
|
||||
- Reuse as design evidence:
|
||||
- `frontend/archive/sgClaw验证-已归档/testRunner.js`
|
||||
- `docs/archive/项目管理与排期/协作时间表.md`
|
||||
- `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the first failing contract test for named bridge calls**
|
||||
|
||||
Create `tests/browser_bridge_contract_test.rs` with one focused test that encodes the bridge naming expectations already evidenced in the repo.
|
||||
|
||||
Start with a test shape like:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn bridge_contract_names_match_documented_bridge_surface() {
|
||||
// assert the contract contains the exact bridge action names
|
||||
}
|
||||
```
|
||||
|
||||
Required expectations:
|
||||
- `sgclawConnect`
|
||||
- `sgclawStart`
|
||||
- `sgclawStop`
|
||||
- `sgclawSubmitTask`
|
||||
- these names live in an explicit lifecycle/session contract type, not in the browser-action request type
|
||||
|
||||
Do **not** invent additional action names in this first test.
|
||||
|
||||
- [ ] **Step 2: Run the single contract test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test bridge_contract_names_match_documented_bridge_surface -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/browser/bridge_contract.rs` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing contract test for browser-action request shaping**
|
||||
|
||||
In the same file, add one focused test proving the bridge contract can represent a browser action request without leaking raw websocket business-frame semantics.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn bridge_contract_represents_browser_action_requests_without_ws_business_frames() {
|
||||
// create a click/navigate/getText style action request and assert shape
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- request shape identifies the intended browser action semantically
|
||||
- request shape is distinct from the lifecycle/session bridge call type
|
||||
- request shape does **not** embed `sgBrowerserOpenPage`, `callBackJsToCpp`, or other raw websocket business-frame names
|
||||
|
||||
- [ ] **Step 4: Run the second contract test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test bridge_contract_represents_browser_action_requests_without_ws_business_frames -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the bridge contract does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Implement the minimal bridge contract module**
|
||||
|
||||
Create `src/browser/bridge_contract.rs` with only the types needed by the tests.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```rust
|
||||
pub enum BridgeLifecycleCall {
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask,
|
||||
}
|
||||
|
||||
impl BridgeLifecycleCall {
|
||||
pub fn bridge_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Connect => "sgclawConnect",
|
||||
Self::Start => "sgclawStart",
|
||||
Self::Stop => "sgclawStop",
|
||||
Self::SubmitTask => "sgclawSubmitTask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BridgeBrowserActionRequest {
|
||||
pub action: String,
|
||||
pub params: serde_json::Value,
|
||||
pub expected_domain: String,
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- model the documented bridge/lifecycle naming explicitly
|
||||
- keep the browser action request semantic, not websocket-frame-shaped
|
||||
- keep the module small and repository-local
|
||||
|
||||
- [ ] **Step 6: Re-run the contract tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/bridge_contract.rs tests/browser_bridge_contract_test.rs
|
||||
git commit -m "test: define sgClaw bridge contract surface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add the repo-local transport seam and bridge-backed `BrowserBackend`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/bridge_transport.rs`
|
||||
- Create: `src/browser/bridge_backend.rs`
|
||||
- Create: `tests/browser_bridge_backend_test.rs`
|
||||
- Reuse: `src/browser/backend.rs`
|
||||
- Reuse: `src/browser/bridge_contract.rs`
|
||||
- Reuse: `src/compat/browser_tool_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing backend test for action mapping**
|
||||
|
||||
Create `tests/browser_bridge_backend_test.rs` with one focused test proving a `BrowserBackend` action is translated into the bridge contract request shape.
|
||||
|
||||
Start with a narrow action such as `Action::Navigate`.
|
||||
|
||||
Required assertions:
|
||||
- `Action::Navigate` becomes one semantic bridge browser-action request
|
||||
- the request preserves action parameters and expected domain
|
||||
- the test does **not** assert any raw websocket payload strings
|
||||
|
||||
- [ ] **Step 2: Run the first backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test bridge_backend_maps_navigate_to_bridge_action_request -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/browser/bridge_backend.rs` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing backend test for reply normalization**
|
||||
|
||||
Add one focused test proving the backend can normalize a successful bridge reply into the existing `CommandOutput` shape expected by `BrowserBackend` callers.
|
||||
|
||||
- [ ] **Step 4: Run the second backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test bridge_backend_normalizes_successful_bridge_reply -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the backend does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Add the third failing backend test for bridge-side errors**
|
||||
|
||||
Add one focused test proving a bridge-side error normalizes into the correct outward `PipeError` semantics for backend callers.
|
||||
|
||||
- [ ] **Step 6: Run the error-path test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test bridge_backend_maps_bridge_failure_to_pipe_error -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the backend does not exist yet.
|
||||
|
||||
- [ ] **Step 7: Implement the minimal transport seam and bridge backend**
|
||||
|
||||
Create `src/browser/bridge_transport.rs` and `src/browser/bridge_backend.rs`.
|
||||
|
||||
The transport seam must:
|
||||
- define the repo-local `BridgeActionTransport` contract used for Layer-2 browser-action execution only
|
||||
- accept semantic `BridgeBrowserActionRequest` values and return semantic success/error replies
|
||||
- remain small, explicit, and easy to fake in tests
|
||||
|
||||
The backend must:
|
||||
- implement the existing `BrowserBackend` trait
|
||||
- translate supported actions into `BridgeBrowserActionRequest`
|
||||
- depend on `BridgeActionTransport` instead of raw websocket payload building
|
||||
- normalize success/error replies into existing backend-facing result types
|
||||
|
||||
Rules:
|
||||
- do not embed raw websocket business-frame names
|
||||
- do not change `BrowserBackend` semantics for existing callers
|
||||
- do not pull lifecycle/session bridge calls into this backend layer
|
||||
|
||||
- [ ] **Step 8: Re-run the bridge backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Re-run browser tool adapter coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_browser_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving the existing browser action mapping remains reusable with the new backend.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/bridge_transport.rs src/browser/bridge_backend.rs tests/browser_bridge_backend_test.rs src/compat/browser_tool_adapter.rs src/browser/mod.rs
|
||||
git commit -m "feat: add bridge-backed browser backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire the bridge-backed backend into the real-browser service path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
- Modify: `src/compat/runtime.rs`
|
||||
- Modify: `src/compat/orchestration.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Modify: `tests/service_task_flow_test.rs`
|
||||
- Modify: `tests/service_ws_session_test.rs`
|
||||
- Reuse: `src/browser/bridge_backend.rs`
|
||||
- Reuse: `src/browser/bridge_contract.rs`
|
||||
- Reuse: `src/browser/bridge_transport.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing service-path test for bridge backend construction**
|
||||
|
||||
Add or update one focused service test proving the real-browser execution path constructs and uses the bridge-backed backend instead of the raw websocket backend assumption.
|
||||
|
||||
The test should observe backend selection at the nearest possible seam.
|
||||
|
||||
- [ ] **Step 2: Run the focused service test and verify it fails**
|
||||
|
||||
Run the narrowest affected service test command.
|
||||
|
||||
Expected: FAIL because the service path is not wired to the bridge backend yet.
|
||||
|
||||
- [ ] **Step 3: Add the minimal service/runtime wiring**
|
||||
|
||||
Change the relevant service/browser execution path so it constructs the new bridge-backed backend, injects the repo-local bridge transport provider at the nearest seam, and passes the backend through the existing runtime/orchestration flow.
|
||||
|
||||
Rules:
|
||||
- keep the pipe path unchanged
|
||||
- keep changes localized
|
||||
- keep lifecycle/session bridge handling separate from per-action browser execution
|
||||
- preserve existing runtime log and task flow behavior where possible
|
||||
|
||||
- [ ] **Step 4: Add one direct-route/fallback regression**
|
||||
|
||||
Add one focused regression proving a bridge-backed backend still works through the direct-route or fallback path exercised by `src/compat/workflow_executor.rs`.
|
||||
|
||||
- [ ] **Step 5: Run the bridge-focused service tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Re-run workflow/runtime regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
cargo test --test compat_browser_tool_test --test browser_script_skill_tool_test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs src/compat/runtime.rs src/compat/orchestration.rs src/compat/workflow_executor.rs tests/service_ws_session_test.rs tests/service_task_flow_test.rs
|
||||
git commit -m "refactor: route real browser path through bridge backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verify bridge-path behavior without pipe regression
|
||||
|
||||
**Files:**
|
||||
- Reuse only unless a failing test proves a minimal fix is still needed
|
||||
|
||||
- [ ] **Step 1: Run bridge/backend unit coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test --test browser_bridge_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run service/runtime bridge-path regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run required pipe regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Build the affected binaries**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Stop if any regression points back to raw websocket assumptions**
|
||||
|
||||
If any test still encodes raw external websocket business-frame assumptions as the real-browser path, update that test to the bridge-backed design rather than patching production code to satisfy the old assumption.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/browser_bridge_contract_test.rs tests/browser_bridge_backend_test.rs tests/service_ws_session_test.rs tests/service_task_flow_test.rs
|
||||
git commit -m "test: verify bridge path and preserve pipe behavior"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Bridge contract tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_contract_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: documented bridge names and semantic browser-action request shaping are locked.
|
||||
|
||||
### Bridge backend tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_bridge_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: backend action mapping and reply/error normalization are green.
|
||||
|
||||
### Service/runtime integration tests
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
cargo test compat::workflow_executor::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: real-browser path uses the bridge-backed backend and direct-route/fallback behavior remains intact.
|
||||
|
||||
### Pipe regressions
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test --test browser_tool_test --test compat_browser_tool_test --test browser_script_skill_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: pipe path remains unchanged.
|
||||
|
||||
### Binary build verification
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw --bin sg_claw --bin sg_claw_client
|
||||
```
|
||||
|
||||
Expected: affected binaries compile.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- The websocket probe work stays in the repository as diagnostic tooling; do not repurpose it into the bridge adapter.
|
||||
- `docs/_tmp_sgbrowser_ws_probe_transcript.md` is evidence that rejected the raw-ws-direct assumption, not a contract to keep satisfying.
|
||||
- Favor one narrow bridge-backed backend over broad runtime rewrites.
|
||||
- If the nearest repo-local seam is still slightly abstract because the external SuperRPA host code is outside this repository, make that abstraction explicit and test it rather than guessing hidden behavior.
|
||||
@@ -1,566 +0,0 @@
|
||||
# WS Browser Integration Surface Correction Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the unvalidated raw-ws-direct assumption with an evidence-backed decision: either prove a minimal sgBrowser bootstrap sequence for raw websocket control, or pivot to the real browser bridge surface.
|
||||
|
||||
**Architecture:** Treat the existing ws-native backend as a protocol/testing asset, not as a validated production integration surface. First build a narrow probe/validation harness that can run candidate bootstrap sequences and capture exact live transcripts from the real endpoint. Then branch decisively: if a reproducible bootstrap sequence yields real status/callback frames, implement that bootstrap path; otherwise stop raw-ws speculation and write the bridge-first implementation slice.
|
||||
|
||||
**Tech Stack:** Rust 2021, existing `src/browser/ws_protocol.rs` / `src/browser/ws_backend.rs`, service websocket infrastructure, `tungstenite`, `serde_json`, current Rust test suite, local sgBrowser websocket documentation.
|
||||
|
||||
---
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do **not** add more speculative production fixes to `src/service/server.rs` just to “try one more thing.”
|
||||
- Do **not** claim raw websocket is the supported path unless the live probe transcript proves it.
|
||||
- Do **not** modify `src/lib.rs`, pipe handshake behavior, or the pipe browser-tool path.
|
||||
- Do **not** implement both the bootstrap architecture and the bridge architecture in the same branch.
|
||||
- Keep the ws-native code unless and until the bridge decision makes specific pieces obsolete.
|
||||
- Prefer a dedicated probe surface over embedding validation logic into production request handling.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- export the new `ws_probe` module so both tests and the probe binary use the same library surface
|
||||
- Modify: `src/browser/ws_protocol.rs`
|
||||
- only if a tiny helper extraction is required for test/probe readability
|
||||
- do not change existing protocol semantics in this slice
|
||||
- Modify: `tests/browser_ws_protocol_test.rs`
|
||||
- add deterministic coverage for any extracted helper used by the probe harness
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/bin/sgbrowser_ws_probe.rs`
|
||||
- standalone diagnostic binary for ordered frame-script probing against a live sgBrowser websocket endpoint
|
||||
- Create: `src/browser/ws_probe.rs`
|
||||
- small reusable probe/transcript module, if needed, to keep the binary and tests focused
|
||||
- Create: `tests/browser_ws_probe_test.rs`
|
||||
- deterministic fake-server tests for transcript capture, timeout reporting, and scripted sequence execution
|
||||
- Create: `docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md` **only if Option B wins after probing**
|
||||
- follow-up bridge design, not part of the initial coding slice
|
||||
- Create: `docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md` **only if Option B wins after probing**
|
||||
- follow-up bridge implementation plan, not part of the initial coding slice
|
||||
- Create: `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- temporary evidence artifact capturing the real endpoint probe matrix and outcomes
|
||||
|
||||
### Files deliberately not changed in the initial slice
|
||||
|
||||
- `src/lib.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/browser/ws_backend.rs`
|
||||
|
||||
Unless the probe results prove a real bootstrap contract, these files stay untouched.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Build a deterministic websocket probe harness before touching production behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `src/browser/ws_probe.rs`
|
||||
- Create: `tests/browser_ws_probe_test.rs`
|
||||
- Reuse: `src/browser/ws_protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing transcript test**
|
||||
|
||||
Create `tests/browser_ws_probe_test.rs` with one focused fake-server test that executes a scripted sequence of outgoing text frames and records all received text frames in order.
|
||||
|
||||
Start with this shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn probe_records_welcome_then_silence_transcript() {
|
||||
// fake server sends one welcome frame and then stays silent
|
||||
// probe result should preserve that exact transcript and mark timeout/silence explicitly
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the probe can connect to the fake websocket server
|
||||
- it can send a scripted first frame
|
||||
- it records the first inbound text frame exactly
|
||||
- it returns a transcript/result object that distinguishes timeout from protocol parse failure
|
||||
|
||||
- [ ] **Step 2: Run the single new test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test probe_records_welcome_then_silence_transcript -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the probe harness does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing probe test for ordered multi-step scripts**
|
||||
|
||||
In the same file, add a test proving the harness can run multiple outgoing frames in a fixed order and keep the transcript segmented by step.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn probe_runs_ordered_frame_script_and_records_per_step_results() {
|
||||
// send bootstrap frame 1, bootstrap frame 2, then minimal action
|
||||
// fake server replies differently at each step
|
||||
// probe result preserves exact order and outcomes
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- outgoing frames are sent in the configured order
|
||||
- inbound frames are attached to the correct step
|
||||
- the probe can stop the sequence on timeout/close if configured
|
||||
|
||||
- [ ] **Step 4: Run the ordered-script test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test probe_runs_ordered_frame_script_and_records_per_step_results -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the probe harness does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Add the third failing probe test for close/reset visibility**
|
||||
|
||||
Add one focused fake-server test that closes the connection after a script step and asserts the transcript reports close/reset rather than generic timeout.
|
||||
|
||||
- [ ] **Step 6: Run the close/reset test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test probe_reports_socket_close_separately_from_timeout -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the probe harness does not exist yet.
|
||||
|
||||
- [ ] **Step 7: Implement the minimal probe module**
|
||||
|
||||
Create `src/browser/ws_probe.rs` with only the types and behavior needed by the tests.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```rust
|
||||
pub struct ProbeStep {
|
||||
pub label: String,
|
||||
pub payload: String,
|
||||
pub expect_reply: bool,
|
||||
}
|
||||
|
||||
pub enum ProbeOutcome {
|
||||
Received(Vec<String>),
|
||||
TimedOut,
|
||||
Closed,
|
||||
ConnectFailed(String),
|
||||
}
|
||||
|
||||
pub struct ProbeStepResult {
|
||||
pub label: String,
|
||||
pub sent: String,
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
pub fn run_probe_script(/* ws url, timeout, steps */) -> Result<Vec<ProbeStepResult>, ProbeError> {
|
||||
// connect, send ordered frames, collect exact transcript
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- do not parse business meaning yet
|
||||
- do not mix this into normal task execution
|
||||
- preserve exact raw text frames in transcript results
|
||||
- keep the module small and diagnostic-oriented
|
||||
|
||||
- [ ] **Step 8: Re-run the new probe tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_probe.rs tests/browser_ws_probe_test.rs
|
||||
git commit -m "test: add sgBrowser websocket probe harness"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add a standalone probe binary for live sgBrowser evidence collection
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bin/sgbrowser_ws_probe.rs`
|
||||
- Create: `src/browser/ws_probe.rs`
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- Create: `tests/browser_ws_probe_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing helper parser test**
|
||||
|
||||
In `tests/browser_ws_probe_test.rs`, add one focused test for a new helper function in `src/browser/ws_probe.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
|
||||
// parse a fixed argv-style slice into a ProbeCliConfig
|
||||
}
|
||||
```
|
||||
|
||||
Create and use this exact helper shape:
|
||||
|
||||
```rust
|
||||
pub struct ProbeCliConfig {
|
||||
pub ws_url: String,
|
||||
pub timeout_ms: u64,
|
||||
pub steps: Vec<ProbeStep>,
|
||||
}
|
||||
|
||||
pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError>
|
||||
```
|
||||
|
||||
The test must assert that these exact arguments parse successfully and preserve step order:
|
||||
|
||||
```text
|
||||
--ws-url ws://127.0.0.1:12345
|
||||
--timeout-ms 1500
|
||||
--step open-agent::["about:blank","sgOpenAgent"]
|
||||
--step open-hot::["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the parser test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test parse_probe_args_accepts_ws_url_timeout_and_ordered_steps -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `parse_probe_args(...)` and `ProbeCliConfig` do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the helper and binary together**
|
||||
|
||||
In `src/browser/ws_probe.rs`, add `ProbeCliConfig` and `parse_probe_args(...)`.
|
||||
|
||||
In `src/browser/mod.rs`, add the module export:
|
||||
|
||||
```rust
|
||||
pub mod ws_probe;
|
||||
```
|
||||
|
||||
In `src/bin/sgbrowser_ws_probe.rs`, implement the binary using only `std::env::args()` plus `parse_probe_args(...)`.
|
||||
|
||||
Required behavior:
|
||||
- accepts a websocket URL
|
||||
- accepts a timeout in milliseconds
|
||||
- accepts repeated ordered steps
|
||||
- runs the probe harness
|
||||
- prints a markdown-friendly transcript including:
|
||||
- step label
|
||||
- exact sent payload
|
||||
- exact received frames, if any
|
||||
- timeout/close outcome
|
||||
|
||||
Output shape can be simple, for example:
|
||||
|
||||
```text
|
||||
STEP 1 bootstrap-open-agent
|
||||
SEND: ["about:blank","sgOpenAgent"]
|
||||
RECV: Welcome! You are client #1
|
||||
OUTCOME: timeout
|
||||
```
|
||||
|
||||
Rules:
|
||||
- no production/browser-runtime integration
|
||||
- no hidden fallback logic
|
||||
- no “best effort” guessing of next steps
|
||||
|
||||
- [ ] **Step 4: Re-run the parser/helper test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test parse_probe_args_accepts_ws_url_timeout_and_ordered_steps -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Build the probe binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgbrowser_ws_probe
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bin/sgbrowser_ws_probe.rs src/browser/ws_probe.rs src/browser/mod.rs tests/browser_ws_probe_test.rs
|
||||
git commit -m "feat: add live sgBrowser websocket probe binary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Run the real endpoint probe matrix and write the evidence transcript
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- Reuse only: `src/bin/sgbrowser_ws_probe.rs`, `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
|
||||
- [ ] **Step 1: Run the no-bootstrap baseline probe**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baseline-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## baseline-open` heading in `docs/_tmp_sgbrowser_ws_probe_transcript.md`.
|
||||
|
||||
- [ ] **Step 2: Run the documented `sgOpenAgent` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "post-open-agent-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## open-agent` heading.
|
||||
|
||||
- [ ] **Step 3: Run the documented `sgSetAuthInfo` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "set-auth::[\"about:blank\",\"sgSetAuthInfo\",\"probe-user\",\"probe-token\"]" --step "post-set-auth-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## set-auth` heading.
|
||||
|
||||
- [ ] **Step 4: Run the documented `sgBrowserLogin` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "browser-login::{\"request\":\"use-json-helper\"}"
|
||||
```
|
||||
|
||||
Before running, replace the placeholder payload with the exact JSON-array frame produced by the helper for:
|
||||
|
||||
```json
|
||||
["about:blank","sgBrowserLogin",{"appName":"probe","userName":"probe","orgName":"probe","menus":[{"name":"probe","normalImg":"x","activeImg":"x","url":"https://www.zhihu.com/hot"}]}]
|
||||
```
|
||||
|
||||
Then add a second step in the same command:
|
||||
|
||||
```json
|
||||
["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]
|
||||
```
|
||||
|
||||
Append the exact output under a `## browser-login` heading.
|
||||
|
||||
- [ ] **Step 5: Run the documented `sgBrowerserActiveTab` candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "post-active-tab-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## active-tab` heading.
|
||||
|
||||
- [ ] **Step 6: Run one combined bootstrap candidate**
|
||||
|
||||
Run exactly:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "combined-open-agent::[\"about:blank\",\"sgOpenAgent\"]" --step "combined-active-tab::[\"about:blank\",\"sgBrowerserActiveTab\",\"https://www.zhihu.com/hot\",\"probeCallback\"]" --step "combined-open::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact output under a `## combined-bootstrap` heading.
|
||||
|
||||
- [ ] **Step 7: Run `requesturl` variants for the minimal action**
|
||||
|
||||
Run exactly these two additional commands:
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "target-as-requesturl::[\"https://www.zhihu.com/hot\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo run --bin sgbrowser_ws_probe -- --ws-url "ws://127.0.0.1:12345" --timeout-ms 1500 --step "baidu-requesturl::[\"https://www.baidu.com\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
```
|
||||
|
||||
Append the exact outputs under `## requesturl-variants`.
|
||||
|
||||
- [ ] **Step 8: Summarize the matrix in the transcript file**
|
||||
|
||||
At the end of `docs/_tmp_sgbrowser_ws_probe_transcript.md`, add this exact table template and fill it in:
|
||||
|
||||
```markdown
|
||||
| Sequence | Sent frames | First reply | Final outcome | Decision signal |
|
||||
| --- | --- | --- | --- | --- |
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Determine which architecture option wins**
|
||||
|
||||
Decision rule:
|
||||
- if at least one sequence reproducibly yields real numeric status and/or callback frames for a real business action, Option A (bootstrap-validated raw websocket) wins
|
||||
- otherwise, Option B (bridge-first) wins
|
||||
|
||||
Do not weaken this decision rule.
|
||||
|
||||
- [ ] **Step 10: Commit the evidence artifact**
|
||||
|
||||
```bash
|
||||
git add docs/_tmp_sgbrowser_ws_probe_transcript.md
|
||||
git commit -m "docs: capture sgBrowser websocket probe evidence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4A: If Option A wins, write the narrow bootstrap implementation slice
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-04-03-ws-browser-bootstrap-contract-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-04-03-ws-browser-bootstrap-contract-plan.md`
|
||||
- Reuse as evidence input:
|
||||
- `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- `docs/_tmp_sgbrowser_ws_api_doc.txt`
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/ws_protocol.rs`
|
||||
|
||||
- [ ] **Step 1: Write one new design doc capturing the proven bootstrap contract**
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
docs/superpowers/specs/2026-04-03-ws-browser-bootstrap-contract-design.md
|
||||
```
|
||||
|
||||
Include:
|
||||
- exact validated sequence
|
||||
- exact required state (`requesturl`, active tab, agent page, auth payload)
|
||||
- exact failure semantics
|
||||
- why this is sufficient evidence to keep raw websocket as the product surface
|
||||
|
||||
- [ ] **Step 2: Write one new implementation plan for the bootstrap path**
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
docs/superpowers/plans/2026-04-03-ws-browser-bootstrap-contract-plan.md
|
||||
```
|
||||
|
||||
Plan only the minimal production changes required to embed the validated bootstrap sequence into the service/browser path.
|
||||
|
||||
- [ ] **Step 3: Commit the bootstrap decision docs**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-03-ws-browser-bootstrap-contract-design.md docs/superpowers/plans/2026-04-03-ws-browser-bootstrap-contract-plan.md
|
||||
git commit -m "docs: capture ws browser bootstrap contract"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Stop after writing the bootstrap plan**
|
||||
|
||||
Do not begin production implementation in the same slice unless the user explicitly asks for execution.
|
||||
|
||||
---
|
||||
|
||||
## Task 4B: If Option B wins, write the bridge-first implementation slice
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md`
|
||||
- Reuse as evidence input:
|
||||
- `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- `frontend/archive/sgClaw验证-已归档/testRunner.js`
|
||||
- `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md`
|
||||
- `docs/archive/项目管理与排期/协作时间表.md`
|
||||
- `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the bridge-path design doc**
|
||||
|
||||
Create `docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md`.
|
||||
|
||||
The design must specify:
|
||||
- why raw websocket is considered non-validated for external control
|
||||
- which bridge surface becomes authoritative
|
||||
- where sgClaw should integrate (`FunctionsUI`, host bridge, `BrowserAction`, `CommandRouter`, or the nearest validated seam in this repo)
|
||||
- how to preserve pipe behavior and existing abstractions where practical
|
||||
|
||||
- [ ] **Step 2: Write the bridge-path implementation plan**
|
||||
|
||||
Create `docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md`.
|
||||
|
||||
The plan must:
|
||||
- identify exact files to touch
|
||||
- describe the narrowest adapter implementation
|
||||
- keep TDD/task granularity as in this document
|
||||
- avoid speculative work outside the bridge slice
|
||||
|
||||
- [ ] **Step 3: Commit the bridge decision docs**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-03-ws-browser-bridge-path-design.md docs/superpowers/plans/2026-04-03-ws-browser-bridge-path-plan.md
|
||||
git commit -m "docs: define bridge-first sgBrowser integration"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Stop after writing the bridge plan**
|
||||
|
||||
Do not start the bridge implementation in the same slice unless the user explicitly asks for execution.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Deterministic probe harness tests
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: transcript capture, ordered scripts, timeout reporting, and close/reset reporting all pass.
|
||||
|
||||
### Probe binary build
|
||||
|
||||
```bash
|
||||
cargo build --bin sgbrowser_ws_probe
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Live evidence collection
|
||||
|
||||
- run the probe matrix against the real configured endpoint
|
||||
- save exact transcripts to `docs/_tmp_sgbrowser_ws_probe_transcript.md`
|
||||
- make the architecture decision using the documented rule
|
||||
|
||||
### Follow-up branch condition
|
||||
|
||||
- if Option A wins, repository contains a bootstrap-contract design + plan
|
||||
- if Option B wins, repository contains a bridge-path design + plan
|
||||
- no production runtime changes are made until that decision is written down
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- The existing `WsBrowserBackend` fix that remembers the last navigated URL remains valid; do not revert it.
|
||||
- The previous auth-replacement work also remains valid; it removed a real bug but did not prove the raw websocket architecture.
|
||||
- Keep the probe tool brutally literal: exact sent frames, exact received frames, explicit timeout/close outcomes.
|
||||
- Resist the temptation to make the probe “smart.” Smart probes hide evidence.
|
||||
- If the real endpoint still replies only with the welcome banner and then silence across the matrix, treat that as a decision, not as an excuse for more guessing.
|
||||
@@ -1,362 +0,0 @@
|
||||
# WS Browser Welcome Frame Compatibility Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the ws service path tolerate the real sgBrowser welcome banner (`Welcome! You are client #...`) without weakening general ws protocol validation or changing pipe behavior.
|
||||
|
||||
**Architecture:** Keep the shared `WsBrowserBackend` strict and implement the compatibility shim only in `ServiceBrowserWsClient`, which is already the real-browser adapter for the ws service path. Add one positive red test for the known welcome frame and one negative red test proving non-matching first text frames still fail as protocol errors, then make the minimal stateful change in `src/service/server.rs` and verify ws + pipe regressions.
|
||||
|
||||
**Tech Stack:** Rust 2021, tungstenite websocket client/server, existing `WsBrowserBackend`, existing `ServiceBrowserWsClient`, existing Rust unit/integration test suite.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/service/server.rs`
|
||||
- Add the one-time per-connection welcome-skip state to `ServiceBrowserWsClient`
|
||||
- Add the minimal helper(s) for detecting and discarding the first known welcome frame
|
||||
- Add focused service-adapter unit tests in the existing `#[cfg(test)]` module
|
||||
- Reuse: `src/browser/ws_backend.rs`
|
||||
- Do not change protocol parsing rules; only verify behavior remains strict for all non-service callers
|
||||
- Reuse: `tests/service_task_flow_test.rs`
|
||||
- Re-run to confirm the ws service path still reaches the browser websocket after the service-side shim
|
||||
- Reuse: `tests/browser_ws_backend_test.rs`
|
||||
- Re-run to prove the shared backend semantics remain unchanged
|
||||
|
||||
### Files deliberately not changed
|
||||
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/lib.rs`
|
||||
|
||||
The design explicitly keeps the welcome-banner workaround out of the shared backend and out of the pipe path.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Reproduce the real welcome-frame failure with focused unit tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 1: Add the positive failing test for the known welcome frame**
|
||||
|
||||
In the existing `#[cfg(test)] mod tests` inside `src/service/server.rs`, add one focused test next to the current ws adapter tests.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn future_server_side_ws_native_adapter_skips_initial_known_welcome_frame() {
|
||||
// fake server sends:
|
||||
// 1. "Welcome! You are client #1"
|
||||
// 2. "0"
|
||||
// backend.invoke(Action::Navigate, ...) should succeed
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the fake websocket server accepts one connection
|
||||
- it sends the welcome banner first, then the numeric success status
|
||||
- `WsBrowserBackend.invoke(Action::Navigate, ...)` returns `Ok(CommandOutput { success: true, .. })`
|
||||
|
||||
- [ ] **Step 2: Run only the positive new test and watch it fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_skips_initial_known_welcome_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL with a protocol error containing `invalid browser status frame: Welcome! You are client #1`.
|
||||
|
||||
- [ ] **Step 3: Add the negative failing test for arbitrary first text**
|
||||
|
||||
In the same `#[cfg(test)]` module, add one negative test proving we do **not** silently skip arbitrary first text frames.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn future_server_side_ws_native_adapter_does_not_skip_unknown_first_text_frame() {
|
||||
// fake server sends:
|
||||
// 1. "Hello from server"
|
||||
// assert invoke(...) fails as PipeError::Protocol(...)
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the first frame is a non-matching text frame such as `Hello from server`
|
||||
- `invoke(...)` fails
|
||||
- the failure remains a protocol error rather than success or timeout
|
||||
|
||||
- [ ] **Step 4: Run only the negative new test and verify the current behavior is already strict**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_does_not_skip_unknown_first_text_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving the current implementation already treats arbitrary first text as a protocol error. Keep that assertion in place before any production change.
|
||||
|
||||
- [ ] **Step 5: Confirm the TDD gate before implementation**
|
||||
|
||||
Do not implement production code before both tests exist and the positive test has failed on current behavior.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add the minimal per-connection welcome-skip state in the service adapter
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 1: Add one-time per-connection state to `ServiceBrowserWsClient`**
|
||||
|
||||
Extend `ServiceBrowserWsClient` with one extra state field that tracks whether the initial welcome candidate has already been consumed for the current websocket connection.
|
||||
|
||||
Allowed shape:
|
||||
|
||||
```rust
|
||||
struct ServiceBrowserWsClient {
|
||||
browser_ws_url: String,
|
||||
browser_socket: Mutex<Option<WebSocket<MaybeTlsStream<TcpStream>>>>,
|
||||
initial_text_frame_checked: Mutex<bool>,
|
||||
}
|
||||
```
|
||||
|
||||
or an equally small equivalent.
|
||||
|
||||
Rules:
|
||||
- state is per connection, not per request
|
||||
- state must survive multiple `invoke(...)` calls while reusing the same socket
|
||||
- do not add broader protocol state machines
|
||||
|
||||
- [ ] **Step 2: Add a narrow welcome-frame matcher**
|
||||
|
||||
In `src/service/server.rs`, add one small helper that recognizes only the known banner prefix:
|
||||
|
||||
```rust
|
||||
fn is_known_welcome_frame(frame: &str) -> bool {
|
||||
frame.starts_with("Welcome! You are client #")
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- no regex needed
|
||||
- no generic “ignore arbitrary text” logic
|
||||
- keep the matcher local to `src/service/server.rs`
|
||||
|
||||
- [ ] **Step 3: Update `recv_text_timeout(...)` to skip at most one initial known banner**
|
||||
|
||||
Modify `impl WsClient for ServiceBrowserWsClient` so that the first text frame received after connection establishment is handled like this:
|
||||
|
||||
1. read the next text frame
|
||||
2. if the initial-frame state is still false:
|
||||
- mark the first-frame check as consumed
|
||||
- if the frame matches `is_known_welcome_frame(...)`, read the next frame and return that next frame instead
|
||||
3. otherwise, return the frame unchanged
|
||||
|
||||
Rules:
|
||||
- skip only once per connection
|
||||
- do not loop indefinitely over multiple text frames
|
||||
- do not swallow unknown first text frames
|
||||
- do not change timeout / close / reset / connect-failure behavior
|
||||
|
||||
- [ ] **Step 4: Reset the one-time state when a fresh socket is created**
|
||||
|
||||
When `with_socket(...)` establishes a brand-new websocket connection, ensure the one-time banner-check state is reset so a new connection can tolerate its own first welcome frame.
|
||||
|
||||
- [ ] **Step 5: Add one reconnect regression in the service adapter tests**
|
||||
|
||||
Add one focused test proving the welcome skip resets on a fresh connection after socket close/reset.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn future_server_side_ws_native_adapter_skips_welcome_again_after_reconnect() {
|
||||
// first connection closes after use
|
||||
// second fresh connection sends the same welcome banner again
|
||||
// both invocations succeed
|
||||
}
|
||||
```
|
||||
|
||||
Required assertion:
|
||||
- the one-time skip is per connection, not global for the client instance
|
||||
|
||||
- [ ] **Step 6: Run the positive new test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_skips_initial_known_welcome_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Run the negative new test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_does_not_skip_unknown_first_text_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, proving unknown first text is still treated as a protocol error.
|
||||
|
||||
- [ ] **Step 8: Run the reconnect regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests::future_server_side_ws_native_adapter_skips_welcome_again_after_reconnect -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Run the full service adapter unit group**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including the existing tests for:
|
||||
- status `0` success
|
||||
- connect failure => `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
- disconnect/reset => `PipeError::PipeClosed`
|
||||
- callback timeout => `PipeError::Timeout`
|
||||
- new known-welcome success path
|
||||
- new unknown-first-frame strictness path
|
||||
- new reconnect reset behavior
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Verify the shared backend stayed strict and the ws service path still works
|
||||
|
||||
**Files:**
|
||||
- Reuse: `tests/browser_ws_backend_test.rs`
|
||||
- Reuse: `tests/service_task_flow_test.rs`
|
||||
- Reuse: `src/browser/ws_backend.rs`
|
||||
|
||||
- [ ] **Step 1: Re-run the shared ws backend tests unchanged**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS. This proves `WsBrowserBackend` semantics remain unchanged for its existing deterministic callers.
|
||||
|
||||
- [ ] **Step 2: Re-run the service task-flow regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including the auth-regression test that proves the ws service path reaches the browser websocket and no longer emits `invalid hmac seed: session key must not be empty`.
|
||||
|
||||
- [ ] **Step 3: Re-run the ws-focused mixed verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Re-run the real manual smoke that originally failed
|
||||
|
||||
**Files:**
|
||||
- Reuse only: no code changes unless a fresh reproducer proves another bug
|
||||
|
||||
- [ ] **Step 1: Confirm real browser websocket reachability**
|
||||
|
||||
Run a reachability check for `ws://127.0.0.1:12345` (or the configured `browserWsUrl`) before starting smoke.
|
||||
|
||||
Expected: reachable.
|
||||
|
||||
- [ ] **Step 2: Start the real ws service**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected: the service prints:
|
||||
- `sg_claw ready: ...`
|
||||
- the resolved `service_ws_listen_addr`
|
||||
- the configured `browser_ws_url`
|
||||
|
||||
- [ ] **Step 3: Re-run the original failing manual smoke**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
printf '打开知乎热榜并读取页面主区域文本\n' | cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `invalid browser status frame: Welcome! You are client #1`
|
||||
- browser actions proceed past the first status frame
|
||||
- if the browser later fails for another reason, capture that new reason exactly
|
||||
|
||||
- [ ] **Step 4: Re-run the old Zhihu export task smoke**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
printf '读取知乎热榜数据,并导出 excel 文件\n' | cargo run --bin sg_claw_client -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `invalid browser status frame: Welcome! You are client #1`
|
||||
- the task reaches the real browser action path beyond connection banner handling
|
||||
|
||||
- [ ] **Step 5: Stop and debug if a new real-browser issue appears**
|
||||
|
||||
If smoke now fails for a different reason, do not piggyback a second fix into this slice without:
|
||||
- capturing the exact new output
|
||||
- writing a new focused spec/plan if the issue is materially different
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Service adapter unit tests
|
||||
|
||||
```bash
|
||||
cargo test service::server::tests -- --nocapture
|
||||
```
|
||||
|
||||
Expected: all service-side ws adapter tests pass, including the new welcome-frame positive/negative cases and reconnect reset case.
|
||||
|
||||
### Shared ws backend + ws service regressions
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test --test browser_ws_protocol_test --test service_ws_session_test --test service_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Real smoke verification
|
||||
|
||||
- `browserWsUrl` reachable
|
||||
- `sg_claw` starts with real config
|
||||
- `sg_claw_client` no longer fails on `Welcome! You are client #...`
|
||||
- Zhihu minimal read task gets past the first status frame
|
||||
- Zhihu export task gets past the first status frame
|
||||
@@ -1,564 +0,0 @@
|
||||
# Zhihu Release WS Function-Callback Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move only the Zhihu direct-execution path to the new Release browser websocket interaction style while keeping the existing pipe protocol and non-Zhihu submit behavior unchanged.
|
||||
|
||||
**Architecture:** Keep `ClientMessage` / `ServiceMessage`, `run_submit_task_with_browser_backend(...)`, and the high-level Zhihu workflow steps unchanged. First prove the exact Release browser interaction contract with transcript-backed probes. Then implement the smallest Zhihu-scoped backend path that follows that proven contract. Do not globally rewire the submit path unless the probe evidence proves there is no narrower safe seam.
|
||||
|
||||
**Tech Stack:** Rust, tungstenite, existing sgclaw service/client pipe protocol, `docs/_tmp_sgbrowser_ws_api_doc.txt`, Release browser websocket at `ws://127.0.0.1:12345`, current Zhihu direct-execution workflow.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The user has now made the target behavior explicit:
|
||||
|
||||
- the browser has changed and the working reference behavior is the user-provided HTML page that connects to `ws://127.0.0.1:12345`
|
||||
- that page sends a bootstrap registration frame: `{"type":"register","role":"web"}`
|
||||
- browser requests are still JSON arrays such as `[window.location.href, "sgBrowserSetTheme", "1"]` and `[window.location.href, "sgBrowerserGetUrls", "showUrls"]`
|
||||
- callback-bearing browser behavior is now centered on page-defined JS callback functions like `showUrls`, not on Rust directly reading a websocket callback frame as the final business result
|
||||
- the existing sgclaw pipe protocol must remain unchanged
|
||||
|
||||
The current sgclaw drift that must be corrected is visible in:
|
||||
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `Action::Navigate` currently emits `sgHideBrowserCallAfterLoaded` with an inline `callBackJsToCpp(...)` string
|
||||
- `src/browser/ws_backend.rs`
|
||||
- Rust currently waits for a browser websocket callback frame and treats that as the action result
|
||||
- `tests/service_ws_session_test.rs:498-605`
|
||||
- `tests/service_task_flow_test.rs:499-635`
|
||||
- existing **generic submit-flow** regressions still lock in the old direct raw-websocket callback-frame assumption
|
||||
- these are useful as non-regression guardrails, but they are not themselves Zhihu-specific regressions
|
||||
|
||||
Zhihu-specific verification must therefore be added explicitly instead of assuming those Baidu-path tests already cover Zhihu.
|
||||
|
||||
The new browser style proves these facts and only these facts so far:
|
||||
|
||||
1. sgclaw must handle a register-first websocket handshake
|
||||
2. browser requests are still `[requesturl, action, ...args]`
|
||||
3. some browser capabilities now return through page-defined callback functions like `showUrls`
|
||||
4. the current direct raw-websocket callback expectation in Zhihu path is no longer a safe assumption
|
||||
|
||||
The production seam is **not** pre-decided here. Task 1 must determine whether Zhihu can be integrated by:
|
||||
- a direct Zhihu-scoped backend with no helper page, or
|
||||
- a helper page / relay design because named page callbacks are the only reliable result path
|
||||
|
||||
Until Task 1 evidence is captured, both remain hypotheses.
|
||||
|
||||
## Evidence to preserve in the implementation
|
||||
|
||||
### Browser websocket API doc
|
||||
From `docs/_tmp_sgbrowser_ws_api_doc.txt`:
|
||||
- `ws://localhost:12345` is the browser websocket endpoint
|
||||
- request frames are array payloads with `requesturl`
|
||||
- `sgBrowerserGetUrls(callback)` uses a callback **function name**: `[requesturl,"sgBrowerserGetUrls", callback]`
|
||||
- `sgBrowserCallAfterLoaded(targetUrl, callback)` and `sgHideBrowserCallAfterLoaded(targetUrl, callback)` use callback strings with parentheses
|
||||
- `callBackJsToCpp(param)` uses `sourceUrl@_@targetUrl@_@callback@_@actionUrl@_@responseTxt`
|
||||
- `sgBrowserRegJsFun(targeturl, funContent)` and `sgBrowserExcuteJsFun(targeturl, funName)` exist and may be useful when the helper page needs durable callback helpers
|
||||
|
||||
### Current working HTML pattern from the user
|
||||
The now-working reference interaction is:
|
||||
|
||||
```html
|
||||
const socket = new WebSocket('ws://127.0.0.1:12345');
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({type: 'register', role: 'web'}));
|
||||
};
|
||||
socket.send(JSON.stringify([window.location.href,"sgBrowerserGetUrls","showUrls"]));
|
||||
function showUrls(urls) {
|
||||
// browser invokes this page-defined callback
|
||||
}
|
||||
```
|
||||
|
||||
That is the browser behavior sgclaw now needs to follow.
|
||||
|
||||
---
|
||||
|
||||
## Critical files
|
||||
|
||||
### Production files to modify
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `src/compat/workflow_executor.rs` (only if a narrow Zhihu-specific correction is required after backend swap)
|
||||
- `src/service/server.rs` (only if the chosen Zhihu-scoped integration seam must be wired here)
|
||||
- `src/service/mod.rs` (only if startup plumbing changes are truly required)
|
||||
- `src/browser/mod.rs`
|
||||
|
||||
### New production files likely needed
|
||||
- `src/browser/zhihu_release_backend.rs`
|
||||
- a Zhihu-scoped `BrowserBackend` adapter that follows the proven Release browser interaction style without changing non-Zhihu routes
|
||||
- `src/service/browser_callback_host.rs` **only if the probe proves a service-controlled helper page is actually required**
|
||||
- service-local helper-page lifecycle and callback relay, if evidence shows the browser cannot be driven safely without it
|
||||
|
||||
### Existing files to preserve
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/service/protocol.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/pipe/*`
|
||||
|
||||
### Existing direct-ws files to review explicitly
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
|
||||
These files currently encode the old direct raw-websocket callback expectation. The implementation must either:
|
||||
- leave them untouched as legacy/direct-contract coverage with no Zhihu production callers, or
|
||||
- update/remove the Zhihu-specific assumptions they currently lock in.
|
||||
|
||||
### Primary test files
|
||||
- `tests/browser_ws_probe_test.rs`
|
||||
- `tests/browser_ws_protocol_test.rs`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
- `tests/service_task_flow_test.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
|
||||
---
|
||||
|
||||
## File structure decisions
|
||||
|
||||
### `src/browser/zhihu_release_backend.rs`
|
||||
Prefer a Zhihu-scoped backend first.
|
||||
|
||||
Responsibilities:
|
||||
- keep the same `BrowserBackend` trait surface
|
||||
- implement only the behavior needed by the current Zhihu direct-execution route
|
||||
- translate `Action::Navigate`, `Action::GetText`, and `Action::Eval` into the proven Release-browser interaction style
|
||||
- normalize results back into `CommandOutput`
|
||||
- avoid affecting non-Zhihu callers
|
||||
|
||||
This is the preferred seam because the user asked to change the current Zhihu flow, not to redesign the whole submit pipeline.
|
||||
|
||||
### `src/service/browser_callback_host.rs` (conditional)
|
||||
Create this file only if Task 1 probe evidence proves that sgclaw must host or control a page in order to receive named callback-function results.
|
||||
|
||||
If it is needed, the plan must keep the design minimal and specific:
|
||||
- one concrete transport only (choose websocket or HTTP, not “websocket or HTTP”)
|
||||
- explicit readiness handshake
|
||||
- explicit request correlation by `request_id`
|
||||
- explicit cleanup when the submit task ends
|
||||
|
||||
If Task 1 shows a simpler seam, do not create this file.
|
||||
|
||||
### `src/browser/ws_protocol.rs`
|
||||
Do not let this file keep only the old direct-callback assumption.
|
||||
|
||||
It should become the shared place for doc-native request builders such as:
|
||||
- browser bootstrap frames proven by the transcript
|
||||
- `sgBrowserCallAfterLoaded` / `sgHideBrowserCallAfterLoaded`
|
||||
- `sgBrowserExcuteJsCodeByArea`
|
||||
- optional `sgBrowserRegJsFun` / `sgBrowserExcuteJsFun`
|
||||
|
||||
But do **not** let `ws_protocol.rs` absorb service-host lifecycle logic.
|
||||
|
||||
### `src/browser/ws_backend.rs` and `tests/browser_ws_backend_test.rs`
|
||||
Handle these explicitly in the implementation:
|
||||
- if they still describe a valid direct browser contract, keep them as isolated legacy/direct-ws coverage only
|
||||
- if their current navigate/callback assumptions conflict with the proven Release Zhihu path, update or narrow those tests so they no longer describe the active Zhihu integration path
|
||||
|
||||
Do not leave the old direct-callback assumptions ambiguously “reviewed”; the implementation must make their status explicit.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Capture the new Release browser contract in a reproducible probe transcript
|
||||
|
||||
**Files:**
|
||||
- Review/modify: `src/browser/ws_probe.rs`
|
||||
- Review/modify: `src/bin/sgbrowser_ws_probe.rs`
|
||||
- Review/modify: `tests/browser_ws_probe_test.rs`
|
||||
- Create: `docs/_tmp_release_ws_callback_host_transcript.md`
|
||||
|
||||
- [ ] **Step 1: Verify current probe coverage against the Release-browser questions**
|
||||
|
||||
Read the existing probe module and tests and check whether they already prove all of the following:
|
||||
- a register-first websocket script can be expressed
|
||||
- a later array action frame can be expressed in the same script
|
||||
- per-step inbound frames/outcomes are preserved separately
|
||||
- timeout/close remain distinguishable in the transcript
|
||||
|
||||
Required result:
|
||||
- identify the exact existing tests that already prove these behaviors
|
||||
- identify the smallest missing Release-specific coverage, if any
|
||||
|
||||
- [ ] **Step 2: Add only the missing regression coverage**
|
||||
|
||||
If current tests do **not** already prove the Release-browser bootstrap shape, add the narrowest failing regression in `tests/browser_ws_probe_test.rs`.
|
||||
|
||||
Preferred shape if coverage is missing:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn probe_supports_register_then_array_action_script() {
|
||||
// fake server expects:
|
||||
// 1. {"type":"register","role":"web"}
|
||||
// 2. ["http://127.0.0.1/helper.html","sgBrowerserGetUrls","showUrls"]
|
||||
}
|
||||
```
|
||||
|
||||
And, if still missing, add one regression proving per-step transcript separation for the register reply and later action reply.
|
||||
|
||||
If those behaviors are already covered, skip new test creation and record the exact test names to rely on.
|
||||
|
||||
- [ ] **Step 3: Run the relevant probe tests**
|
||||
|
||||
Run the narrowest exact tests that prove the Release bootstrap behavior, or the full file if multiple areas changed:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Make the probe binary ergonomic for the Release transcript if needed**
|
||||
|
||||
Only if the current CLI cannot conveniently express the real Release-browser script, make the smallest change needed in `src/bin/sgbrowser_ws_probe.rs` / `src/browser/ws_probe.rs` so it can capture:
|
||||
- register frame behavior
|
||||
- minimal `sgBrowserSetTheme`
|
||||
- minimal `sgBrowerserGetUrls`
|
||||
- exact inbound websocket text per step
|
||||
|
||||
Do not redesign the probe if it already supports this.
|
||||
|
||||
- [ ] **Step 5: Run the live probe against the Release browser and record the real bootstrap**
|
||||
|
||||
Use the probe binary against the real endpoint to capture at minimum:
|
||||
- register frame behavior
|
||||
- minimal `sgBrowserSetTheme`
|
||||
- minimal `sgBrowerserGetUrls`
|
||||
- whether replies come back as websocket text, page-function invocation only, or both
|
||||
|
||||
Save the exact transcript in `docs/_tmp_release_ws_callback_host_transcript.md`.
|
||||
|
||||
Required output in that temp doc:
|
||||
- exact sent frames
|
||||
- exact received websocket frames
|
||||
- the observed rule for when named callback functions are invoked
|
||||
- whether Option A or Option B is supported by evidence
|
||||
|
||||
- [ ] **Step 6: Commit the probe-only slice if code changed**
|
||||
|
||||
If probe code/tests changed:
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_probe.rs src/bin/sgbrowser_ws_probe.rs tests/browser_ws_probe_test.rs docs/_tmp_release_ws_callback_host_transcript.md
|
||||
git commit -m "test: capture release browser ws bootstrap contract"
|
||||
```
|
||||
|
||||
If only the transcript doc changed, stage only that file and use a docs/test-appropriate commit message.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Choose the narrowest Zhihu-only production seam from the probe evidence
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs` (only if required)
|
||||
- Modify: `src/browser/mod.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs` (only if required)
|
||||
- Create: `src/browser/zhihu_release_backend.rs`
|
||||
- Create: `src/service/browser_callback_host.rs` **only if required**
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write down the seam decision in the plan notes before coding**
|
||||
|
||||
Based on the transcript from Task 1, record which one of these is supported by evidence:
|
||||
- Option A: a Zhihu-scoped backend can talk to the Release browser directly with no service-hosted helper page
|
||||
- Option B: a Zhihu-scoped backend needs a service-controlled helper page because named page callbacks are the only reliable way to get business results
|
||||
|
||||
Do not proceed until one option is chosen explicitly from evidence.
|
||||
|
||||
- [ ] **Step 2: Add a failing service/task-flow regression that proves only the Zhihu path changes**
|
||||
|
||||
Update or add focused tests so that:
|
||||
- Zhihu submit flow uses the new Release-browser interaction seam
|
||||
- non-Zhihu behavior is unchanged
|
||||
- pipe messages remain unchanged
|
||||
|
||||
Required assertions:
|
||||
- the new path is activated only for Zhihu route detection
|
||||
- `ClientMessage` / `ServiceMessage` stay identical
|
||||
- existing non-Zhihu submit behavior is not accidentally rerouted
|
||||
|
||||
- [ ] **Step 3: Run the new focused regression and confirm failure first**
|
||||
|
||||
Run the narrowest exact test names you added in:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test <new_test_name> -- --nocapture
|
||||
cargo test --test service_task_flow_test <new_test_name> -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the Zhihu-specific seam does not exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement the chosen seam with the smallest blast radius**
|
||||
|
||||
If Option A won:
|
||||
- add `src/browser/zhihu_release_backend.rs`
|
||||
- wire it only where the Zhihu direct-execution route is selected
|
||||
- leave global submit-path wiring alone
|
||||
|
||||
If Option B won:
|
||||
- add `src/service/browser_callback_host.rs` with one specific transport and one explicit readiness/correlation model
|
||||
- add `src/browser/zhihu_release_backend.rs` to talk to that helper path
|
||||
- wire it only for the Zhihu route
|
||||
|
||||
In both cases:
|
||||
- do not change non-Zhihu callers
|
||||
- do not redesign `run_submit_task_with_browser_backend(...)`
|
||||
- do not change the pipe protocol
|
||||
|
||||
- [ ] **Step 5: Make the status of old direct-ws code explicit**
|
||||
|
||||
Update `src/browser/ws_backend.rs` / `tests/browser_ws_backend_test.rs` only as needed so they no longer ambiguously describe the active Zhihu path.
|
||||
|
||||
Allowed outcomes:
|
||||
- keep them untouched as legacy/direct-ws coverage with no Zhihu production caller
|
||||
- narrow/update the tests so they no longer claim the active Zhihu integration path
|
||||
|
||||
Not allowed:
|
||||
- leaving the plan and code in a state where both old and new paths appear to be the active Zhihu contract
|
||||
|
||||
- [ ] **Step 6: Run focused integration tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit the seam-selection slice**
|
||||
|
||||
Adjust staged files to match the option actually implemented, for example:
|
||||
|
||||
```bash
|
||||
git add src/browser/zhihu_release_backend.rs src/browser/mod.rs src/service/server.rs src/service/browser_callback_host.rs tests/service_ws_session_test.rs tests/service_task_flow_test.rs tests/browser_ws_backend_test.rs
|
||||
git commit -m "feat: route zhihu flow through release browser ws contract"
|
||||
```
|
||||
|
||||
Only stage files that were truly changed.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement Zhihu action mapping on the chosen Release-browser seam
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/ws_protocol.rs`
|
||||
- Modify: `src/browser/zhihu_release_backend.rs`
|
||||
- Test: `tests/browser_ws_protocol_test.rs`
|
||||
- Create: `tests/browser_zhihu_release_backend_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing backend test for Zhihu navigate mapping**
|
||||
|
||||
Create `tests/browser_zhihu_release_backend_test.rs` with a fake transport/relay and assert that `Action::Navigate` for the Zhihu path becomes the exact browser request shape proven by Task 1.
|
||||
|
||||
Start with this shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn zhihu_release_backend_maps_navigate_to_proven_release_frame() {
|
||||
// invoke Action::Navigate
|
||||
// assert exact outbound frame/opcode chosen from transcript evidence
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the call site still uses `BrowserBackend::invoke(...)`
|
||||
- the exact outbound frame matches the recorded Release-browser evidence
|
||||
- request correlation stays deterministic
|
||||
|
||||
- [ ] **Step 2: Run the single new backend test and verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_zhihu_release_backend_test zhihu_release_backend_maps_navigate_to_proven_release_frame -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the backend does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement minimal `Navigate` support**
|
||||
|
||||
In `src/browser/zhihu_release_backend.rs`:
|
||||
- implement `BrowserBackend`
|
||||
- support `Action::Navigate` first
|
||||
- use `ws_protocol.rs` helpers for exact browser-frame construction
|
||||
- do not hardcode speculative opcodes; follow the transcript from Task 1
|
||||
|
||||
- [ ] **Step 4: Add failing tests for `GetText` and `Eval`**
|
||||
|
||||
Add tests proving:
|
||||
- `Action::GetText` returns `CommandOutput.data == {"text": "..."}`
|
||||
- `Action::Eval` returns `CommandOutput.data == {"text": "..."}`
|
||||
- callback or relay failures become `PipeError::Protocol(...)`
|
||||
|
||||
- [ ] **Step 5: Implement `GetText` and `Eval` on the chosen seam**
|
||||
|
||||
Use the smallest proven mechanism:
|
||||
- if the transcript proves page-defined callback functions are required, route through them
|
||||
- if `callBackJsToCpp(...)` to a page context is still part of the proven path, use it deliberately
|
||||
- if `sgBrowserRegJsFun` / `sgBrowserExcuteJsFun` becomes necessary, add it only with test coverage and only for the Zhihu path
|
||||
|
||||
- [ ] **Step 6: Run focused backend/protocol tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_zhihu_release_backend_test -- --nocapture
|
||||
cargo test --test browser_ws_protocol_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit the Zhihu backend slice**
|
||||
|
||||
```bash
|
||||
git add src/browser/ws_protocol.rs src/browser/zhihu_release_backend.rs src/browser/mod.rs tests/browser_ws_protocol_test.rs tests/browser_zhihu_release_backend_test.rs
|
||||
git commit -m "feat: add zhihu release ws backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Keep the Zhihu workflow logic stable and patch only proven mismatches
|
||||
|
||||
**Files:**
|
||||
- Review: `src/compat/workflow_executor.rs`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
- Test: `tests/compat_runtime_test.rs` (only if a focused direct-execution regression is needed)
|
||||
|
||||
- [ ] **Step 1: Write a failing Zhihu-specific regression only if the chosen seam changes route assumptions**
|
||||
|
||||
If the new Zhihu backend changes request-url or target-url handling enough to break hotlist flow, add one focused failing regression for that exact behavior.
|
||||
|
||||
Candidate assertions:
|
||||
- hotlist navigate still logs `navigate https://www.zhihu.com/hot`
|
||||
- follow-up `GetText body` still targets the Zhihu page, not any helper page
|
||||
- extractor `Eval` still runs against Zhihu, not any helper page
|
||||
|
||||
- [ ] **Step 2: Keep the current high-level Zhihu action sequence unless a test proves otherwise**
|
||||
|
||||
`src/compat/workflow_executor.rs` currently does the right high-level work:
|
||||
- navigate to Zhihu hotlist
|
||||
- poll body text until ready
|
||||
- run the extractor script
|
||||
|
||||
Prefer to keep this file unchanged. Only patch it if the new backend needs a narrow explicit `target_url` fix or similar evidence-backed adjustment.
|
||||
|
||||
- [ ] **Step 3: Run the smallest Zhihu-focused verification sweep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test compat_runtime_test zhihu -- --nocapture
|
||||
```
|
||||
|
||||
If the `compat_runtime_test zhihu` filter is too broad or unstable, run the exact focused Zhihu cases that cover hotlist extraction.
|
||||
|
||||
- [ ] **Step 4: Commit only if a Zhihu-specific code change was actually required**
|
||||
|
||||
```bash
|
||||
git add src/compat/workflow_executor.rs tests/service_task_flow_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "fix: keep zhihu workflow aligned with release ws backend"
|
||||
```
|
||||
|
||||
Skip this commit if no production change in `workflow_executor.rs` was needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Prove that pipe behavior and non-Zhihu behavior stayed unchanged
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Test: `tests/service_task_flow_test.rs`
|
||||
- Test: `tests/task_runner_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add or update one regression that proves pipe messages are unchanged**
|
||||
|
||||
Use the smallest existing test seam to assert that `ClientMessage` / `ServiceMessage` payloads remain unchanged while the Zhihu route uses the new browser integration path internally.
|
||||
|
||||
- [ ] **Step 2: Add or update one regression that proves non-Zhihu behavior is unchanged**
|
||||
|
||||
Use a non-Zhihu submit or service-session case and assert it does not take the new Zhihu-specific backend path.
|
||||
|
||||
- [ ] **Step 3: Preserve current runtime regression guards**
|
||||
|
||||
The end-to-end tests must continue asserting that output does **not** contain:
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
- `Cannot drop a runtime in a context where blocking is not allowed`
|
||||
|
||||
- [ ] **Step 4: Run the final focused verification sweep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit the verification sweep**
|
||||
|
||||
```bash
|
||||
git add tests/service_ws_session_test.rs tests/service_task_flow_test.rs tests/task_runner_test.rs tests/browser_ws_backend_test.rs
|
||||
git commit -m "test: constrain zhihu release ws migration scope"
|
||||
```
|
||||
|
||||
Only stage files that were truly changed.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
Do **not** do these in this slice:
|
||||
- change the pipe protocol
|
||||
- change `ClientMessage` / `ServiceMessage`
|
||||
- redesign `run_submit_task_with_browser_backend(...)`
|
||||
- reintroduce any browser bridge surface
|
||||
- keep adding speculative direct-raw-websocket callback patches to `ws_backend.rs`
|
||||
- redesign non-Zhihu workflows unless the new backend abstraction forces a shared fix
|
||||
- create a long-lived external dependency or third-party server just to host the helper page
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Run at minimum:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_ws_probe_test -- --nocapture
|
||||
cargo test --test browser_zhihu_release_backend_test -- --nocapture
|
||||
cargo test --test browser_ws_protocol_test -- --nocapture
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
cargo test --test service_task_flow_test -- --nocapture
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
If Task 2 chose the helper-page / relay design, also run the helper-page-specific backend tests you added for that path.
|
||||
|
||||
Manual verification after code changes:
|
||||
|
||||
1. start the real Release browser/runtime that exposes `ws://127.0.0.1:12345`
|
||||
2. start `sg_claw` with real config
|
||||
3. start `sg_claw_client`
|
||||
4. submit:
|
||||
- `打开知乎热榜,获取前10条数据,并导出 Excel`
|
||||
5. confirm the Zhihu path uses the exact Release-browser interaction seam proven by Task 1
|
||||
6. if Task 2 chose Option B, confirm the helper page / relay path is used only for the Zhihu integration seam
|
||||
7. confirm non-Zhihu behavior is unchanged
|
||||
8. confirm the task completes without:
|
||||
- `timeout while waiting for browser message`
|
||||
- `invalid browser status frame: Welcome! You are client #1`
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
- `Cannot drop a runtime in a context where blocking is not allowed`
|
||||
|
||||
---
|
||||
|
||||
## Expected outcome
|
||||
|
||||
After this slice:
|
||||
- sgclaw still exposes the same pipe/service contract
|
||||
- Zhihu hotlist execution uses the Release-browser websocket contract proven by Task 1
|
||||
- non-Zhihu behavior remains unchanged
|
||||
- old direct-ws Zhihu assumptions are no longer ambiguous in production/tests
|
||||
- if Option A won, Zhihu uses a direct Release-browser backend
|
||||
- if Option B won, Zhihu uses the minimal helper-page / relay seam justified by the probe evidence
|
||||
@@ -1,322 +0,0 @@
|
||||
# Zhihu WS Submit Realignment Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Realign Zhihu submit routes to the documented websocket callback model, removing helper-page bootstrap from the mainline while keeping the existing pipe/service contract unchanged.
|
||||
|
||||
**Architecture:** The change stays inside the existing submit-path backend selection and websocket protocol flow. Zhihu routes stop choosing `BrowserCallbackBackend` and instead use `WsBrowserBackend` when a real browser websocket is configured, preserving the existing pipe fallback in direct runtime when no websocket URL is available.
|
||||
|
||||
**Tech Stack:** Rust, tungstenite websocket client/server, serde_json, cargo test
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/service/server.rs`
|
||||
- Change only the Zhihu route-gated submit-path backend selection
|
||||
- Remove Zhihu submit mainline use of `LiveBrowserCallbackHost` / `BrowserCallbackBackend`
|
||||
- Keep service submit path on `WsBrowserBackend`
|
||||
- Preserve initial request URL derivation for Zhihu routes
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Change only the Zhihu route-gated submit-path backend selection
|
||||
- Remove Zhihu submit mainline use of `LiveBrowserCallbackHost` / `BrowserCallbackBackend`
|
||||
- Keep direct runtime pipe fallback when browser websocket URL is absent
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Replace helper-page bootstrap regression with direct websocket submit regression
|
||||
- Assert no `/sgclaw/browser-helper.html` bootstrap frames are emitted
|
||||
- Assert real-page request ownership on follow-up Zhihu actions
|
||||
- Modify: `src/browser/callback_host.rs`
|
||||
- Remove or rewrite the now-wrong red test that preserves Option-B callback-host startup behavior
|
||||
- Verify: `tests/browser_ws_backend_test.rs`
|
||||
- Reuse existing websocket request-url behavior coverage; extend only if the new regression proves insufficient
|
||||
- Reference: `docs/superpowers/specs/2026-04-04-zhihu-ws-submit-realignment-design.md`
|
||||
|
||||
### Task 1: Rewrite the stale submit regression around the real websocket mainline
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/agent_runtime_test.rs:507-660`
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Rename and rewrite the existing helper-page regression so it asserts the new behavior:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
// arrange runtime context and fake browser ws server
|
||||
// submit Zhihu hotlist request
|
||||
// assert ws frames never contain "/sgclaw/browser-helper.html"
|
||||
// assert first action is navigate to https://www.zhihu.com/hot
|
||||
// assert follow-up action uses real-page requesturl instead of helper page
|
||||
}
|
||||
```
|
||||
|
||||
Use the existing fake ws helpers in the file where possible. Do not add localhost callback-host HTTP plumbing to this rewritten test.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: FAIL because current production code still routes Zhihu submit into `BrowserCallbackBackend` and emits helper-page bootstrap frames.
|
||||
|
||||
- [ ] **Step 3: Keep the regression focused**
|
||||
|
||||
Before touching production code, confirm the rewritten test checks only these behaviors:
|
||||
|
||||
```text
|
||||
- no callback-host bootstrap frame
|
||||
- no helper-page URL
|
||||
- navigate frame still targets https://www.zhihu.com/hot
|
||||
- follow-up websocket action uses real-page request ownership
|
||||
```
|
||||
|
||||
Do not assert unrelated workflow details beyond what is needed to prove the route correction.
|
||||
|
||||
- [ ] **Step 4: Commit the red test**
|
||||
|
||||
```bash
|
||||
git add tests/agent_runtime_test.rs
|
||||
git commit -m "test: rewrite zhihu submit ws routing regression"
|
||||
```
|
||||
|
||||
### Task 2: Switch service Zhihu submit routes off the callback-host backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/service/server.rs:287-328`
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the minimal production change**
|
||||
|
||||
Replace only the Zhihu-route callback-host branch with direct websocket backend selection.
|
||||
|
||||
Minimal target shape:
|
||||
|
||||
```rust
|
||||
fn browser_backend_for_submit(
|
||||
browser_ws_url: &str,
|
||||
mac_policy: &MacPolicy,
|
||||
request: &SubmitTaskRequest,
|
||||
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
|
||||
if should_use_callback_host_backend(request) {
|
||||
return Ok(Arc::new(WsBrowserBackend::new(
|
||||
Arc::new(ServiceWsClient::connect(browser_ws_url)?),
|
||||
mac_policy.clone(),
|
||||
initial_request_url_for_submit_task(request),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Arc::new(WsBrowserBackend::new(
|
||||
Arc::new(ServiceWsClient::connect(browser_ws_url)?),
|
||||
mac_policy.clone(),
|
||||
initial_request_url_for_submit_task(request),
|
||||
)))
|
||||
}
|
||||
```
|
||||
|
||||
After the route-gated branch is removed, simplify further only if the branch becomes redundant without changing non-Zhihu behavior.
|
||||
|
||||
- [ ] **Step 2: Run the rewritten regression**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: still FAIL or advance to a later assertion until the direct-runtime path is corrected too.
|
||||
|
||||
- [ ] **Step 3: Add or update a service-specific regression if needed**
|
||||
|
||||
If the rewritten `agent_runtime_test` does not exercise the service submit path directly, add one narrow service regression before continuing.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn service_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
// fake browser ws
|
||||
// submit Zhihu route through service path
|
||||
// assert no helper bootstrap frame
|
||||
}
|
||||
```
|
||||
|
||||
Run the exact test you end up using:
|
||||
|
||||
`cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test <exact test file> -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit the service-path fix**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs tests/agent_runtime_test.rs
|
||||
git commit -m "fix: route zhihu submit through ws backend"
|
||||
```
|
||||
|
||||
### Task 3: Switch direct runtime Zhihu submit routes off the callback-host backend while keeping pipe fallback
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent/mod.rs:49-100`
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the minimal production change**
|
||||
|
||||
Remove callback-host backend selection from `browser_backend_for_submit(...)`.
|
||||
|
||||
Minimal target behavior:
|
||||
|
||||
```rust
|
||||
if let Some(browser_ws_url) = configured_browser_ws_url(context) {
|
||||
return Ok(Arc::new(WsBrowserBackend::new(
|
||||
Arc::new(ServiceWsClient::connect(&browser_ws_url)?),
|
||||
browser_tool.mac_policy().clone(),
|
||||
initial_request_url_for_submit_task(request),
|
||||
).with_response_timeout(browser_tool.response_timeout())));
|
||||
}
|
||||
|
||||
Ok(Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone())))
|
||||
```
|
||||
|
||||
If `ServiceWsClient` is not reusable from `src/service/server.rs`, extract the smallest shared websocket client helper into the browser module instead of inventing a new abstraction.
|
||||
|
||||
- [ ] **Step 2: Add a focused fallback assertion only if needed**
|
||||
|
||||
If the rewritten regression does not cover the direct-runtime no-websocket case, add one small test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn production_submit_task_keeps_pipe_fallback_when_browser_ws_url_is_unset() {
|
||||
// no SGCLAW_BROWSER_WS_URL
|
||||
// blank/no ws config
|
||||
// assert no websocket bootstrap attempt occurs
|
||||
}
|
||||
```
|
||||
|
||||
Only add this test if current coverage is insufficient.
|
||||
|
||||
- [ ] **Step 3: Run tests to verify green**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
If a fallback test was added, run it immediately after and expect PASS.
|
||||
|
||||
- [ ] **Step 4: Commit the direct-runtime fix**
|
||||
|
||||
```bash
|
||||
git add src/agent/mod.rs tests/agent_runtime_test.rs
|
||||
git commit -m "fix: align runtime zhihu submit with ws contract"
|
||||
```
|
||||
|
||||
### Task 4: Reassess stale callback-host regression coverage only if it blocks the approved slice
|
||||
|
||||
**Files:**
|
||||
- Maybe modify: `src/browser/callback_host.rs:793-810`
|
||||
- Test: `src/browser/callback_host.rs`
|
||||
|
||||
- [ ] **Step 1: Check whether the callback-host red test still blocks the approved Option A slice**
|
||||
|
||||
Inspect whether this test still preserves rejected Option-B behavior and whether it fails or becomes misleading after Tasks 1-3:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn live_callback_host_starts_without_bootstrapping_external_helper_page() {
|
||||
// inspect before editing
|
||||
}
|
||||
```
|
||||
|
||||
If the test is unrelated to the approved Zhihu mainline or remains harmless, leave it unchanged in this slice.
|
||||
|
||||
- [ ] **Step 2: Remove or rewrite only if required by the changed submit-path behavior**
|
||||
|
||||
If the test blocks the approved slice, make the smallest change needed:
|
||||
|
||||
- delete it if it exists only to preserve rejected Option B behavior, or
|
||||
- rewrite it so it no longer asserts callback-host startup as the accepted Zhihu mainline
|
||||
|
||||
- [ ] **Step 3: Run focused callback-host tests only if Step 2 changed code**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_host --lib -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit only if Step 2 changed code**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_host.rs
|
||||
git commit -m "test: clean up stale callback host regression"
|
||||
```
|
||||
|
||||
### Task 5: Run the focused verification sweep
|
||||
|
||||
**Files:**
|
||||
- Verify: `tests/agent_runtime_test.rs`
|
||||
- Verify: `tests/compat_runtime_test.rs`
|
||||
- Verify: any directly affected service/browser websocket tests
|
||||
|
||||
- [ ] **Step 1: Run submit-path regression coverage**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run websocket backend request-url coverage**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" ws_backend_reuses_last_navigated_url_for_followup_requests --test browser_ws_backend_test -- --nocapture`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run Zhihu compat runtime coverage**
|
||||
|
||||
Run: `cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" zhihu --test compat_runtime_test -- --nocapture`
|
||||
|
||||
Expected: PASS for the changed submit-path surface or clear, directly related failures only.
|
||||
|
||||
- [ ] **Step 4: Run affected service submit regression coverage**
|
||||
|
||||
Run the exact service-specific regression from Task 2 if you added one.
|
||||
|
||||
Otherwise, run the narrowest existing service submit test that covers `ClientMessage::SubmitTask` handling for browser routes.
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit the verified slice**
|
||||
|
||||
```bash
|
||||
git add src/service/server.rs src/agent/mod.rs tests/agent_runtime_test.rs src/browser/callback_host.rs
|
||||
git commit -m "fix: realign zhihu submit with browser ws callbacks"
|
||||
```
|
||||
|
||||
### Task 6: Run stronger real-browser validation
|
||||
|
||||
**Files:**
|
||||
- Verify live behavior through existing binaries and real config only
|
||||
|
||||
- [ ] **Step 1: Start the service with the real config**
|
||||
|
||||
Run: `cargo run --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"`
|
||||
|
||||
Expected: service starts without failing at callback-host readiness timeout.
|
||||
|
||||
- [ ] **Step 2: Run the client against the started service**
|
||||
|
||||
Run: `cargo run --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sg_claw_client`
|
||||
|
||||
Expected: for `打开知乎热榜,获取前10条数据,并导出 Excel`, the browser proceeds into real Zhihu page work instead of stalling before page open.
|
||||
|
||||
- [ ] **Step 3: Capture the narrow acceptance evidence**
|
||||
|
||||
Verify all of the following from logs/frames/observed behavior:
|
||||
|
||||
```text
|
||||
- no callback-host readiness timeout
|
||||
- no helper-page bootstrap frame
|
||||
- at least one real-page follow-up browser action after navigate
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit only if live verification required code changes**
|
||||
|
||||
```bash
|
||||
git add <only files changed during live-fix follow-up>
|
||||
git commit -m "fix: tighten zhihu ws submit live validation follow-up"
|
||||
```
|
||||
|
||||
If no further code changes were needed, do not create an extra commit.
|
||||
@@ -1,455 +0,0 @@
|
||||
# Scene Skill Runtime Routing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a first scene-routing slice that recognizes staged business scenes from natural language and dispatches them through browser-backed execution, with `fault-details-report` using direct browser execution and `95598-repair-city-dispatch` using agent-mediated browser execution.
|
||||
|
||||
**Architecture:** Introduce a small registry module that loads the first staged `scene.json` contracts plus runtime dispatch policy from the external `skill_staging` root. Route matched scenes through one of two paths: `direct_browser` scenes execute through compat orchestration without the model choosing tools, while `agent_browser` scenes stay in the existing agent flow but get scene-specific browser-first prompt injection. Both modes must still execute through the existing `BrowserScriptSkillTool` / browser backend path so the final business action uses browser-internal methods.
|
||||
|
||||
**Tech Stack:** Rust, serde/JSON metadata loading, existing compat orchestration/runtime/workflow layers, browser-backed skill tools, focused `cargo test` coverage.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**Create:**
|
||||
- `src/runtime/scene_registry.rs` — load staged scene metadata, attach runtime dispatch policy, expose matching helpers for the first slice.
|
||||
- `tests/scene_registry_test.rs` — focused tests for registry loading, matching, and policy behavior.
|
||||
|
||||
**Modify:**
|
||||
- `src/runtime/mod.rs` — export the new scene registry module/types used by runtime and compat layers.
|
||||
- `src/compat/config_adapter.rs` — verify the moved external `skill_staging` root resolves to the staged `skills` child, and only change path resolution if a targeted regression proves it is insufficient.
|
||||
- `src/runtime/engine.rs` — inject scene-specific browser-first contracts for `agent_browser` scenes and keep existing Zhihu prompt behavior intact.
|
||||
- `src/compat/workflow_executor.rs` — extend route detection and direct execution support for `fault-details-report` using the browser-backed skill path.
|
||||
- `src/compat/orchestration.rs` — let primary orchestration prefer direct execution for `direct_browser` scenes while leaving `agent_browser` scenes in the agent path.
|
||||
- `src/compat/browser_script_skill_tool.rs` — expose the thinnest reusable browser-backed execution helper needed so direct scene execution can reuse the same `browser_script` semantics instead of drifting into a duplicate local path.
|
||||
- `src/compat/runtime.rs` — ensure runtime sees the staged skills root and continues exposing browser-backed scene tools.
|
||||
- `tests/compat_config_test.rs` — add path-resolution coverage for the staged external root.
|
||||
- `tests/runtime_profile_test.rs` — add scene-specific instruction contract assertions.
|
||||
- `tests/browser_script_skill_tool_test.rs` — add coverage for any new reusable direct-execution helper introduced in the browser-script layer.
|
||||
- `tests/compat_runtime_test.rs` — add orchestration/direct-route coverage for the new scene behavior.
|
||||
|
||||
**Reference:**
|
||||
- `docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md`
|
||||
- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\fault-details-report\scene.json`
|
||||
- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\scenes\95598-repair-city-dispatch\scene.json`
|
||||
- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\fault-details-report\SKILL.toml`
|
||||
- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging\skills\95598-repair-city-dispatch\SKILL.toml`
|
||||
|
||||
### Task 1: Add Scene Registry And Matching
|
||||
|
||||
**Files:**
|
||||
- Create: `src/runtime/scene_registry.rs`
|
||||
- Modify: `src/runtime/mod.rs`
|
||||
- Test: `tests/scene_registry_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing registry tests**
|
||||
|
||||
Add tests that prove the first-slice registry can:
|
||||
- load `fault-details-report` with `dispatch_mode = direct_browser`
|
||||
- load `95598-repair-city-dispatch` with `dispatch_mode = agent_browser`
|
||||
- match natural-language phrases like `导出故障明细` and `95598抢修市指监测`
|
||||
- ignore missing/broken scene files without panicking
|
||||
|
||||
Example assertions to include:
|
||||
|
||||
```rust
|
||||
assert_eq!(entry.scene_id, "fault-details-report");
|
||||
assert_eq!(entry.dispatch_mode, DispatchMode::DirectBrowser);
|
||||
assert_eq!(entry.tool_name(), "fault-details-report.collect_fault_details");
|
||||
assert_eq!(entry.expected_domain, "__scene_fault_details__");
|
||||
```
|
||||
|
||||
```rust
|
||||
assert_eq!(matched.scene_id, "95598-repair-city-dispatch");
|
||||
assert_eq!(matched.dispatch_mode, DispatchMode::AgentBrowser);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new registry tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test scene_registry --test scene_registry_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/runtime/scene_registry.rs` and the exported registry APIs do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal scene registry module**
|
||||
|
||||
Create `src/runtime/scene_registry.rs` with:
|
||||
- a small deserialized scene metadata struct for `scene.json`
|
||||
- a `DispatchMode` enum
|
||||
- a single runtime registry-entry struct combining scene metadata plus runtime policy
|
||||
- first-slice hardcoded runtime policy for the two initial scenes
|
||||
- helper methods like:
|
||||
|
||||
```rust
|
||||
pub fn load_first_slice_scene_registry() -> Vec<SceneRegistryEntry>
|
||||
pub fn match_scene_instruction(instruction: &str) -> Option<SceneRegistryEntry>
|
||||
```
|
||||
|
||||
Use deterministic keyword/alias matching only. Do not add embeddings, fuzzy search, or generic scoring infrastructure beyond what the spec requires.
|
||||
|
||||
- [ ] **Step 4: Export the registry from `src/runtime/mod.rs`**
|
||||
|
||||
Expose the new types/helpers needed by runtime and compat layers, for example:
|
||||
|
||||
```rust
|
||||
mod scene_registry;
|
||||
|
||||
pub use scene_registry::{
|
||||
load_first_slice_scene_registry,
|
||||
match_scene_instruction,
|
||||
DispatchMode,
|
||||
SceneRegistryEntry,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the registry tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test scene_registry --test scene_registry_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit Task 1**
|
||||
|
||||
```bash
|
||||
git add src/runtime/scene_registry.rs src/runtime/mod.rs tests/scene_registry_test.rs
|
||||
git commit -m "feat: add staged scene registry matching"
|
||||
```
|
||||
|
||||
### Task 2: Verify Staged Skills Root Resolution
|
||||
|
||||
**Files:**
|
||||
- Modify if needed: `src/compat/config_adapter.rs:94`
|
||||
- Modify if needed: `src/compat/runtime.rs:152`
|
||||
- Test: `tests/compat_config_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the targeted staged-root path test**
|
||||
|
||||
Add a focused test that proves an external configured `skill_staging` root resolves to its `skills` child and preserves current nested-skills behavior.
|
||||
|
||||
Add a test shape like:
|
||||
|
||||
```rust
|
||||
let staged_root = root.join("external/skill_staging");
|
||||
fs::create_dir_all(staged_root.join("skills")).unwrap();
|
||||
fs::create_dir_all(staged_root.join("scenes")).unwrap();
|
||||
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Some(staged_root.clone()),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_skills_dir(&root, &settings), staged_root.join("skills"));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused config test and record the actual result**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_config_test resolve_skills_dir_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: either
|
||||
- PASS immediately, proving current path resolution already supports the staged-root contract, or
|
||||
- FAIL with a concrete staged-root regression that justifies a minimal config fix.
|
||||
|
||||
- [ ] **Step 3: Only if the staged-root test fails, implement the narrowest config fix**
|
||||
|
||||
If the test fails, update `src/compat/config_adapter.rs` so configured external staged roots resolve to the staged skill package directory used by runtime skill loading. Keep the change narrow:
|
||||
- preserve current behavior for normal `skills` roots
|
||||
- add the smallest extra branch needed for the failing staged-root case
|
||||
- do not create a broad path-discovery system
|
||||
|
||||
- [ ] **Step 4: Verify runtime alignment with the resolved staged skills root**
|
||||
|
||||
Confirm `src/compat/runtime.rs` still uses the resolved `skills_dir` as-is. If no runtime code change is needed after the test outcome, leave the file untouched and rely on test coverage.
|
||||
|
||||
- [ ] **Step 5: Run the focused config tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_config_test resolve_skills_dir_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add src/compat/config_adapter.rs src/compat/runtime.rs tests/compat_config_test.rs
|
||||
git commit -m "test: verify staged scene skill root resolution"
|
||||
```
|
||||
|
||||
### Task 3: Inject Agent-Browser Scene Contract For 95598
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/runtime/engine.rs:135`
|
||||
- Test: `tests/runtime_profile_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing instruction-contract tests**
|
||||
|
||||
Add focused tests proving that when the instruction matches `95598-repair-city-dispatch`, `RuntimeEngine::build_instruction(...)` includes a scene-specific browser contract requiring the tool `95598-repair-city-dispatch.collect_repair_orders` first.
|
||||
|
||||
Example assertion pattern:
|
||||
|
||||
```rust
|
||||
let instruction = engine.build_instruction(
|
||||
"请做95598抢修市指监测",
|
||||
Some("https://example.invalid/dispatch"),
|
||||
Some("95598抢修-市指"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(instruction.contains("browser workflow, not a text-only task"));
|
||||
assert!(instruction.contains("generic browser probing only after"));
|
||||
```
|
||||
|
||||
Also add a negative control showing unrelated tasks do not receive this scene contract.
|
||||
|
||||
- [ ] **Step 2: Run the focused runtime-profile tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test runtime_profile_test 95598 -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because no scene-specific contract is injected yet.
|
||||
|
||||
- [ ] **Step 3: Implement minimal scene-aware prompt injection**
|
||||
|
||||
Update `src/runtime/engine.rs` to:
|
||||
- query the new scene matcher
|
||||
- when the matched scene is `agent_browser`, append a scene execution contract section
|
||||
- preserve existing Zhihu prompt sections unchanged
|
||||
|
||||
Keep the contract explicit and narrow, for example:
|
||||
|
||||
```text
|
||||
Scene execution contract:
|
||||
- Matched scene: 95598-repair-city-dispatch
|
||||
- Required tool: 95598-repair-city-dispatch.collect_repair_orders
|
||||
- This is a browser workflow, not a text-only task.
|
||||
- Business data must come from the matched browser-backed scene tool.
|
||||
- Only use generic browser probing after the matched scene tool fails.
|
||||
```
|
||||
|
||||
Do not add hard allowed-tool narrowing in this task; slice one only promises instruction-level enforcement.
|
||||
|
||||
- [ ] **Step 4: Run the focused runtime-profile tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test runtime_profile_test 95598 -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add src/runtime/engine.rs tests/runtime_profile_test.rs
|
||||
git commit -m "feat: inject 95598 scene browser contract"
|
||||
```
|
||||
|
||||
### Task 4: Add Direct Browser Route For Fault Details
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/workflow_executor.rs:58`
|
||||
- Modify: `src/compat/orchestration.rs:9`
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs:101`
|
||||
- Test: `tests/browser_script_skill_tool_test.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing route-detection tests**
|
||||
|
||||
Add focused tests that prove:
|
||||
- natural language like `导出故障明细` is detected as a direct scene route
|
||||
- primary orchestration is selected for that scene
|
||||
- missing scene metadata leaves unrelated routing unchanged
|
||||
|
||||
Target the existing routing seams with test shapes like:
|
||||
|
||||
```rust
|
||||
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"导出故障明细",
|
||||
Some("https://example.invalid/fault"),
|
||||
Some("故障明细"),
|
||||
));
|
||||
```
|
||||
|
||||
and a focused route assertion using the new route enum variant.
|
||||
|
||||
- [ ] **Step 2: Run the focused route tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test fault_details -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because no direct scene route exists yet.
|
||||
|
||||
- [ ] **Step 3: Write the failing browser-script helper tests**
|
||||
|
||||
Add focused tests in `tests/browser_script_skill_tool_test.rs` for the thinnest reusable helper needed by direct scene execution. The tests should prove that the helper:
|
||||
- reads the packaged script from the skill root
|
||||
- wraps args exactly like `BrowserScriptSkillTool`
|
||||
- invokes browser `Eval`
|
||||
- returns normalized serialized output
|
||||
- fails clearly when required fields like `expected_domain` are missing
|
||||
|
||||
- [ ] **Step 4: Run the focused browser-script helper tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the reusable helper does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Implement the reusable browser-backed execution helper**
|
||||
|
||||
Update `src/compat/browser_script_skill_tool.rs` with the smallest reusable helper that direct scene execution can call while preserving the same `browser_script` semantics as normal skill execution. Keep it narrow:
|
||||
- reuse the same script loading and wrapping rules
|
||||
- require explicit `expected_domain`
|
||||
- return normalized serialized output
|
||||
- do not introduce a second browser-script execution model
|
||||
|
||||
- [ ] **Step 6: Implement the direct fault-details route on top of that helper**
|
||||
|
||||
Update `src/compat/workflow_executor.rs` to:
|
||||
- introduce a new direct route variant for `fault-details-report`
|
||||
- extend `detect_route(...)` to return it when the scene matcher says `direct_browser`
|
||||
- build required args from scene runtime policy
|
||||
- call the reusable browser-script execution helper
|
||||
- return normalized serialized tool output
|
||||
|
||||
If required scene args cannot be derived safely, return a clear failure instead of guessing.
|
||||
|
||||
- [ ] **Step 7: Wire primary orchestration to prefer the new direct scene route**
|
||||
|
||||
Update `src/compat/orchestration.rs` so `should_use_primary_orchestration(...)` and the direct execution branch treat the new `fault-details-report` route like the existing direct Zhihu routes.
|
||||
|
||||
- [ ] **Step 8: Run the focused direct-route and helper tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture && cargo test --test compat_runtime_test fault_details -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 9: Commit Task 4**
|
||||
|
||||
```bash
|
||||
git add src/compat/browser_script_skill_tool.rs src/compat/workflow_executor.rs src/compat/orchestration.rs tests/browser_script_skill_tool_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "feat: add direct fault-details scene routing"
|
||||
```
|
||||
|
||||
### Task 5: Verify Tool Exposure, Browser-Surface Fallback, And Mixed Routing Together
|
||||
|
||||
**Files:**
|
||||
- Modify if needed: `src/compat/runtime.rs:142`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
- Test: `tests/runtime_profile_test.rs`
|
||||
- Test: `tests/scene_registry_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing integration-shape tests**
|
||||
|
||||
Add focused assertions that prove the mixed-mode design works together:
|
||||
- staged browser-backed tool names are exposed
|
||||
- `fault-details-report` uses direct routing
|
||||
- `95598-repair-city-dispatch` stays in the agent path but gets scene-specific browser-first instruction injection
|
||||
- browser-surface-disabled turns do not gain scene browser contracts
|
||||
- browser-surface-disabled turns do not trigger `direct_browser` scene execution
|
||||
- missing scene metadata preserves unchanged runtime behavior for unrelated tasks
|
||||
- unrelated Zhihu behavior still works the same way
|
||||
|
||||
Use existing test seams instead of broad integration scaffolding.
|
||||
|
||||
- [ ] **Step 2: Run the focused mixed-routing tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_runtime_test scene_ -- --nocapture && cargo test --test runtime_profile_test scene_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL until the mixed-routing assertions are implemented.
|
||||
|
||||
- [ ] **Step 3: Make the minimum runtime adjustments needed**
|
||||
|
||||
Only if required by the tests, adjust `src/compat/runtime.rs` so the loaded staged skills from the resolved external root are visible in the same way as existing browser-backed skills. Keep the shape of `build_browser_script_skill_tools(...)` and runtime tool assembly intact.
|
||||
|
||||
- [ ] **Step 4: Run the focused mixed-routing tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_runtime_test scene_ -- --nocapture && cargo test --test runtime_profile_test scene_ -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run the broader targeted verification sweep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture && cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_config_test resolve_skills_dir_ -- --nocapture && cargo test --test runtime_profile_test -- --nocapture && cargo test --test compat_runtime_test fault_details -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit Task 5**
|
||||
|
||||
```bash
|
||||
git add src/compat/runtime.rs tests/scene_registry_test.rs tests/compat_runtime_test.rs tests/runtime_profile_test.rs
|
||||
git commit -m "feat: wire staged scene mixed routing"
|
||||
```
|
||||
|
||||
### Task 6: Final Verification And Handoff
|
||||
|
||||
**Files:**
|
||||
- Verify: `src/runtime/scene_registry.rs`
|
||||
- Verify: `src/compat/config_adapter.rs`
|
||||
- Verify: `src/runtime/engine.rs`
|
||||
- Verify: `src/compat/workflow_executor.rs`
|
||||
- Verify: `src/compat/orchestration.rs`
|
||||
- Verify: `tests/scene_registry_test.rs`
|
||||
- Verify: `tests/compat_config_test.rs`
|
||||
- Verify: `tests/runtime_profile_test.rs`
|
||||
- Verify: `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Run the full focused verification set**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test scene_registry_test -- --nocapture && cargo test --test compat_config_test -- --nocapture && cargo test --test runtime_profile_test -- --nocapture && cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: If any test fails, fix only the minimal root cause and re-run the same command**
|
||||
|
||||
Do not broaden scope. Keep fixes limited to scene registry, path resolution, prompt injection, or direct routing.
|
||||
|
||||
- [ ] **Step 3: Review the resulting diff against the spec**
|
||||
|
||||
Manually verify:
|
||||
- `fault-details-report` is direct-browser
|
||||
- `95598-repair-city-dispatch` is agent-browser
|
||||
- both still use browser-backed execution semantics
|
||||
- no broad Zhihu refactor slipped in
|
||||
- the new scene-routing abstraction stays registry-driven
|
||||
|
||||
- [ ] **Step 4: Commit the final verification pass**
|
||||
|
||||
```bash
|
||||
git add src/runtime/scene_registry.rs src/runtime/mod.rs src/compat/config_adapter.rs src/runtime/engine.rs src/compat/workflow_executor.rs src/compat/orchestration.rs src/compat/runtime.rs tests/scene_registry_test.rs tests/compat_config_test.rs tests/runtime_profile_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "test: verify scene skill runtime routing"
|
||||
```
|
||||
@@ -1,406 +0,0 @@
|
||||
# Service Chat Web Console Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone local HTML console that connects to the existing service websocket, submits natural-language tasks with the current `submit_task` payload, and leaves the browser-helper/runtime path untouched.
|
||||
|
||||
**Architecture:** The change stays fully at the presentation edge. A new self-contained HTML file under `frontend/service-console/` reuses the current websocket protocol from `src/service/protocol.rs`, while one narrow Rust integration test guards the page's protocol shape and forbids any reference to `browser-helper.html`, callback-host endpoints, or the browser websocket. No Rust runtime logic changes are part of this slice.
|
||||
|
||||
**Tech Stack:** HTML, CSS, vanilla JavaScript, Rust integration tests, std::fs, Cargo test
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `frontend/service-console/sg_claw_service_console.html`
|
||||
- Standalone local page with inline CSS and JavaScript
|
||||
- Connects to the existing service websocket at `ws://127.0.0.1:42321` by default
|
||||
- Sends existing `ClientMessage::SubmitTask` JSON
|
||||
- Renders inbound `ServiceMessage` rows only
|
||||
- Create: `tests/service_console_html_test.rs`
|
||||
- Source guard for the standalone page
|
||||
- Verifies file location, allowed protocol usage, and forbidden helper/callback references
|
||||
- Reference: `src/service/protocol.rs`
|
||||
- Existing websocket message shape to mirror exactly
|
||||
- Reference: `src/bin/sg_claw_client.rs`
|
||||
- Existing terminal client behavior to mirror for `submit_task`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-service-chat-web-console-design.md`
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do not modify `src/service/server.rs`.
|
||||
- Do not modify `src/browser/callback_host.rs`.
|
||||
- Do not modify `src/browser/callback_backend.rs`.
|
||||
- Do not modify `src/bin/sg_claw_client.rs`.
|
||||
- Do not add an HTTP server.
|
||||
- Do not connect the new page to `ws://127.0.0.1:12345`.
|
||||
- Do not reference `/sgclaw/browser-helper.html` or `/sgclaw/callback/*` anywhere in the new page.
|
||||
|
||||
### Task 1: Add a failing source-guard test for the standalone page
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/service_console_html_test.rs`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-service-chat-web-console-design.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create a focused integration test that resolves the HTML path from `CARGO_MANIFEST_DIR` and asserts the file contract.
|
||||
|
||||
```rust
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn service_console_html_stays_on_service_ws_boundary() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let html_path = manifest_dir
|
||||
.join("frontend")
|
||||
.join("service-console")
|
||||
.join("sg_claw_service_console.html");
|
||||
let source = fs::read_to_string(&html_path)
|
||||
.expect("service console html should exist");
|
||||
|
||||
assert!(source.contains("ws://127.0.0.1:42321"));
|
||||
assert!(source.contains("submit_task"));
|
||||
assert!(!source.contains("/sgclaw/browser-helper.html"));
|
||||
assert!(!source.contains("/sgclaw/callback/ready"));
|
||||
assert!(!source.contains("/sgclaw/callback/events"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/next"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/ack"));
|
||||
assert!(!source.contains("ws://127.0.0.1:12345"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_console_html_stays_on_service_ws_boundary --test service_console_html_test -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the HTML file does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Keep the test narrow**
|
||||
|
||||
Before writing production code, confirm the test guards only the approved boundary:
|
||||
|
||||
```text
|
||||
- file exists at frontend/service-console/sg_claw_service_console.html
|
||||
- service websocket default is present
|
||||
- submit_task payload marker is present
|
||||
- no helper-page path
|
||||
- no callback-host endpoints
|
||||
- no browser websocket URL
|
||||
```
|
||||
|
||||
Do not turn this into an end-to-end browser test.
|
||||
|
||||
- [ ] **Step 4: Commit the red test**
|
||||
|
||||
```bash
|
||||
git add tests/service_console_html_test.rs
|
||||
git commit -m "test: add service console html boundary guard"
|
||||
```
|
||||
|
||||
### Task 2: Implement the standalone HTML console with the approved boundary
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/service-console/sg_claw_service_console.html`
|
||||
- Reference: `src/service/protocol.rs:6`
|
||||
- Reference: `src/bin/sg_claw_client.rs:16`
|
||||
- Test: `tests/service_console_html_test.rs`
|
||||
|
||||
- [ ] **Step 1: Create the HTML file with the minimal structure**
|
||||
|
||||
Write one self-contained page with:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>sgClaw Service Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<input id="wsUrl" value="ws://127.0.0.1:42321" />
|
||||
<button id="connectBtn">连接</button>
|
||||
<div id="connectionState">未连接</div>
|
||||
<div id="messageStream"></div>
|
||||
<textarea id="instructionInput"></textarea>
|
||||
<div id="validationText"></div>
|
||||
<button id="sendBtn" disabled>发送任务</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Keep all CSS and JavaScript inline. Do not add external assets or a build step.
|
||||
|
||||
- [ ] **Step 2: Implement websocket connect/disconnect behavior**
|
||||
|
||||
Add the smallest possible JS behavior, including explicit disconnect on the same button so the UI
|
||||
matches the approved connect/disconnect contract:
|
||||
|
||||
```javascript
|
||||
let socket = null;
|
||||
|
||||
function appendRow(kind, text) {
|
||||
// append a visible row to #messageStream
|
||||
}
|
||||
|
||||
function updateUiState() {
|
||||
const connected = socket && socket.readyState === WebSocket.OPEN;
|
||||
document.getElementById('connectBtn').textContent = connected ? '断开' : '连接';
|
||||
document.getElementById('sendBtn').disabled = !connected;
|
||||
document.getElementById('connectionState').textContent = connected ? '已连接' : '未连接';
|
||||
}
|
||||
|
||||
function connectOrDisconnectService() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = document.getElementById('wsUrl').value.trim() || 'ws://127.0.0.1:42321';
|
||||
socket = new WebSocket(url);
|
||||
updateUiState();
|
||||
socket.addEventListener('open', () => {
|
||||
appendRow('status', 'service websocket connected');
|
||||
updateUiState();
|
||||
});
|
||||
socket.addEventListener('close', () => {
|
||||
appendRow('status', 'service websocket disconnected');
|
||||
updateUiState();
|
||||
});
|
||||
socket.addEventListener('error', () => appendRow('error', 'service websocket error'));
|
||||
socket.addEventListener('message', handleMessage);
|
||||
}
|
||||
```
|
||||
|
||||
Do not add retry loops or background reconnect logic.
|
||||
|
||||
- [ ] **Step 3: Implement submit_task sending with the current message shape**
|
||||
|
||||
Mirror the terminal client payload shape exactly and show inline validation for empty input:
|
||||
|
||||
```javascript
|
||||
function setValidation(message) {
|
||||
document.getElementById('validationText').textContent = message;
|
||||
}
|
||||
|
||||
function sendTask() {
|
||||
const instruction = document.getElementById('instructionInput').value.trim();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!instruction) {
|
||||
setValidation('请输入任务内容。');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidation('');
|
||||
socket.send(JSON.stringify({
|
||||
type: 'submit_task',
|
||||
instruction,
|
||||
conversation_id: '',
|
||||
messages: [],
|
||||
page_url: '',
|
||||
page_title: ''
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Do not add new fields. Do not add conversation replay logic in this slice.
|
||||
|
||||
- [ ] **Step 4: Render existing inbound service messages only**
|
||||
|
||||
Handle the current `ServiceMessage` variants with a minimal dispatcher:
|
||||
|
||||
```javascript
|
||||
function handleMessage(event) {
|
||||
const message = JSON.parse(event.data);
|
||||
switch (message.type) {
|
||||
case 'status_changed':
|
||||
appendRow('status', message.state);
|
||||
break;
|
||||
case 'log_entry':
|
||||
appendRow('log', message.message);
|
||||
break;
|
||||
case 'task_complete':
|
||||
appendRow(message.success ? 'complete' : 'error', message.summary);
|
||||
break;
|
||||
case 'busy':
|
||||
appendRow('error', message.message);
|
||||
break;
|
||||
default:
|
||||
appendRow('error', 'unknown service message: ' + event.data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep the composer enabled during in-flight work so repeated submits surface the existing `busy` response instead of inventing a frontend queue.
|
||||
|
||||
- [ ] **Step 5: Keep the helper boundary explicit in the source**
|
||||
|
||||
Before running tests, inspect the HTML source and confirm:
|
||||
|
||||
```text
|
||||
- no /sgclaw/browser-helper.html
|
||||
- no /sgclaw/callback/*
|
||||
- no ws://127.0.0.1:12345
|
||||
- no browser websocket register frame logic
|
||||
```
|
||||
|
||||
If any such string appears, remove it before testing.
|
||||
|
||||
- [ ] **Step 6: Run the source-guard test to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_console_html_stays_on_service_ws_boundary --test service_console_html_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit the standalone page**
|
||||
|
||||
```bash
|
||||
git add frontend/service-console/sg_claw_service_console.html tests/service_console_html_test.rs
|
||||
git commit -m "feat: add standalone service chat console"
|
||||
```
|
||||
|
||||
### Task 3: Run the focused verification sweep
|
||||
|
||||
**Files:**
|
||||
- Verify: `tests/service_console_html_test.rs`
|
||||
- Reference: `src/service/protocol.rs`
|
||||
- Reference: `src/bin/sg_claw_client.rs`
|
||||
|
||||
- [ ] **Step 1: Re-run the source-guard test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_console_html_stays_on_service_ws_boundary --test service_console_html_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Manually inspect disconnected-send and validation markers in the HTML source**
|
||||
|
||||
Before broader verification, confirm the page source clearly contains all three UI-local rules:
|
||||
|
||||
```text
|
||||
- connect button can disconnect an open websocket
|
||||
- send button starts disabled while disconnected
|
||||
- empty instruction shows inline validation text
|
||||
```
|
||||
|
||||
This inspection stays source-level; do not add extra backend tests for it in this slice.
|
||||
|
||||
- [ ] **Step 3: Run an existing service protocol regression for safety**
|
||||
|
||||
Run the narrow existing protocol coverage to prove the page did not require backend changes:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" submit_task_client_message_converts_into_shared_runner_request --test service_ws_session_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Run an existing terminal-client regression for safety**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" client_submits_first_user_line_to_service --test service_task_flow_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit only if verification required any code change**
|
||||
|
||||
```bash
|
||||
git add frontend/service-console/sg_claw_service_console.html tests/service_console_html_test.rs
|
||||
git commit -m "test: tighten service console verification"
|
||||
```
|
||||
|
||||
If verification required no code changes, do not create an extra commit.
|
||||
|
||||
### Task 4: Perform the manual smoke check
|
||||
|
||||
**Files:**
|
||||
- Verify live behavior only; no new code required
|
||||
|
||||
- [ ] **Step 1: Start the existing service binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" --bin sg_claw -- --config-path "D:/data/ideaSpace/rust/sgClaw/sgclaw_config.json"
|
||||
```
|
||||
|
||||
Expected: service starts and prints its ready line with the service websocket listen address.
|
||||
|
||||
- [ ] **Step 2: Open the standalone page directly**
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
D:/data/ideaSpace/rust/sgClaw/claw-new/frontend/service-console/sg_claw_service_console.html
|
||||
```
|
||||
|
||||
Expected: the page loads through the browser as a local file and shows the default websocket URL `ws://127.0.0.1:42321`.
|
||||
|
||||
- [ ] **Step 3: Connect, disconnect, and reconnect once**
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
- message stream shows websocket connected
|
||||
- clicking the same button disconnects the websocket cleanly
|
||||
- message stream shows websocket disconnected
|
||||
- send button is disabled again while disconnected
|
||||
- reconnect succeeds without reloading the page
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Submit one natural-language task**
|
||||
|
||||
Use a small harmless instruction such as:
|
||||
|
||||
```text
|
||||
打开百度
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
- empty textarea send attempt first shows inline validation without sending a websocket frame
|
||||
- page sends one submit_task payload after valid input
|
||||
- page receives and renders status/log/task_complete or busy rows
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Confirm the helper boundary stayed untouched**
|
||||
|
||||
Verify from the page source and observed behavior:
|
||||
|
||||
```text
|
||||
- the page never loads /sgclaw/browser-helper.html
|
||||
- the page never calls /sgclaw/callback/*
|
||||
- the page never connects to ws://127.0.0.1:12345
|
||||
```
|
||||
|
||||
If the task itself triggers browser automation, that remains owned by the existing Rust runtime rather than by the page.
|
||||
|
||||
- [ ] **Step 6: Commit only if the manual pass required code changes**
|
||||
|
||||
```bash
|
||||
git add frontend/service-console/sg_claw_service_console.html tests/service_console_html_test.rs
|
||||
git commit -m "fix: tighten standalone service console smoke flow"
|
||||
```
|
||||
|
||||
If the manual pass required no code changes, do not create an extra commit.
|
||||
@@ -1,637 +0,0 @@
|
||||
# Zhihu Hotlist Post-Export Auto-Open Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extend the existing Zhihu hotlist Excel and dashboard routes so each route can auto-open its own generated artifact after export, while preserving the current callback-host-backed browser boundary and route exclusivity.
|
||||
|
||||
**Architecture:** Keep orchestration in `src/compat/workflow_executor.rs`, but move post-export side effects into a new `src/compat/artifact_open.rs` helper so workflow routing stays readable. Excel auto-open is a local OS-launch side effect; dashboard auto-open reuses `screen_html_export`'s existing `presentation.url` and sends one narrow, marker-based `Action::Navigate` request through `BrowserCallbackBackend`, with a matching special-case validator in `MacPolicy` so arbitrary `file://` navigation remains blocked.
|
||||
|
||||
**Tech Stack:** Rust, serde_json, std::process::Command, std::path, Cargo tests
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `src/compat/artifact_open.rs`
|
||||
- Define the narrow post-export helper surface for this slice only
|
||||
- Parse and validate generated artifact payload fields passed in by the workflow layer
|
||||
- Open generated `.xlsx` files with the local default app
|
||||
- Build the exact approved local-dashboard navigate payload
|
||||
- Keep one testable internal seam, `open_exported_xlsx_with(output_path, opener)`, so unit tests can prove the generated `.xlsx` path is handed to the launcher without starting a real spreadsheet app
|
||||
- Include unit tests in the same file for exact Excel path handoff and launcher-failure reporting
|
||||
- Modify: `src/compat/mod.rs`
|
||||
- Export the new `artifact_open` module
|
||||
- Modify: `src/compat/workflow_executor.rs`
|
||||
- Keep route detection and artifact generation where they are now
|
||||
- Change `export_xlsx(...)` and `export_screen(...)` so they parse tool payloads, call the route-specific opener, and produce the new success/failure summaries
|
||||
- Modify: `src/browser/callback_backend.rs`
|
||||
- Recognize only the approved local-dashboard navigate request shape at `Action::Navigate`
|
||||
- Keep normal remote navigate behavior unchanged
|
||||
- Continue emitting `sgBrowerserOpenPage` for the approved local-dashboard case so the helper page stays alive and the dashboard opens in a new visible tab
|
||||
- Add focused callback-backend unit tests in the existing test module for approved and malformed local-dashboard requests
|
||||
- Modify: `src/security/mac_policy.rs`
|
||||
- Add a narrow validator for the approved local-dashboard presentation case
|
||||
- Keep `validate(...)` unchanged for ordinary remote-domain flow
|
||||
- Reject malformed marker payloads, non-HTML local paths, and mismatched `file://` / output-path combinations
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Keep the concrete hotlist workflow regressions in this existing integration test file
|
||||
- Extend existing Zhihu hotlist export/screen regressions to assert the new summaries and the dashboard marker payload
|
||||
- Keep the Excel route workflow assertion limited to summary plus “no dashboard navigate marker,” because exact launcher handoff is covered in `src/compat/artifact_open.rs` unit tests
|
||||
- Modify: `tests/browser_tool_test.rs`
|
||||
- Add `MacPolicy` coverage for approved local-dashboard presentation, rejected malformed presentation, and unchanged normal-domain validation in one exact file
|
||||
- Extend the existing `default_rules_allow_zhihu_navigation` area with the new local-dashboard validation tests rather than creating a second policy test location
|
||||
- Reference only if summary wording ripples outward: `tests/agent_runtime_test.rs:173-258`
|
||||
- Existing direct-runtime user-visible summary assertion for Zhihu Excel export
|
||||
- Reference only if summary wording ripples outward: `tests/service_task_flow_test.rs:704-839`
|
||||
- Existing CLI-to-service user-visible summary assertion for Zhihu Excel export
|
||||
- Reference only if summary wording ripples outward: `tests/service_ws_session_test.rs:755-869`
|
||||
- Existing service-binary user-visible summary assertion for Zhihu Excel export
|
||||
- Reference: `tests/compat_screen_html_export_tool_test.rs`
|
||||
- Reuse the exact test seam `screen_html_export_tool_renders_dashboard_html_with_presentation_contract`
|
||||
- Existing proof that `screen_html_export` already returns `presentation.url`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-zhihu-hotlist-post-export-auto-open-design.md`
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do not modify `frontend/service-console/sg_claw_service_console.html`.
|
||||
- Do not modify `src/service/protocol.rs`.
|
||||
- Do not modify `browser-helper.html`.
|
||||
- Do not modify `/sgclaw/callback/*` endpoint contracts.
|
||||
- Do not modify websocket protocol framing or `src/browser/ws_protocol.rs`.
|
||||
- Do not turn Excel-open and dashboard-open into a combined mode.
|
||||
- Do not add a general-purpose local file browser or generic `file://` allowlist.
|
||||
- Do not move post-export decisions into the frontend service console.
|
||||
- Do not require websocket-backend parity in this slice.
|
||||
|
||||
### Task 1: Add failing workflow tests for route-specific post-export actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/compat_runtime_test.rs:2154-2304`
|
||||
- Reference: `src/compat/workflow_executor.rs:375-446`
|
||||
- Reference: `docs/superpowers/specs/2026-04-06-zhihu-hotlist-post-export-auto-open-design.md`
|
||||
|
||||
- [ ] **Step 1: Rewrite the Excel hotlist assertion as a red test for the new summary only**
|
||||
|
||||
Keep the current flow setup, but tighten the expectation so it proves the workflow route now reports post-export open success while staying exclusive from the dashboard path.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open() {
|
||||
// existing setup
|
||||
assert!(summary.contains("已导出并打开知乎热榜 Excel"));
|
||||
assert!(generated.exists());
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, params, .. }
|
||||
if action == &Action::Navigate
|
||||
&& params.get("sgclaw_local_dashboard_open").is_some()
|
||||
)
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Do not try to prove real OS launching in this workflow test. The exact `.xlsx` path handoff to the launcher belongs in `src/compat/artifact_open.rs` unit tests from Task 2.
|
||||
|
||||
- [ ] **Step 2: Rewrite the dashboard hotlist assertion as a red test for browser auto-open**
|
||||
|
||||
Tighten the existing dashboard test so it proves the workflow consumes `presentation.url` and emits the approved compat marker payload.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open() {
|
||||
// existing setup
|
||||
assert!(summary.contains("已在浏览器中打开知乎热榜大屏"));
|
||||
let navigate = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command { action, params, security, .. }
|
||||
if action == &Action::Navigate
|
||||
&& security.expected_domain == "__sgclaw_local_dashboard__" => Some((params, security)),
|
||||
_ => None,
|
||||
}).expect("dashboard route should emit local-dashboard navigate request");
|
||||
|
||||
assert!(navigate.0["url"].as_str().unwrap().starts_with("file://"));
|
||||
assert_eq!(navigate.0["sgclaw_local_dashboard_open"]["source"], json!("compat.workflow_executor"));
|
||||
assert_eq!(navigate.0["sgclaw_local_dashboard_open"]["kind"], json!("zhihu_hotlist_screen"));
|
||||
assert_eq!(navigate.0["sgclaw_local_dashboard_open"]["presentation_url"], navigate.0["url"]);
|
||||
}
|
||||
```
|
||||
|
||||
Also assert that this route still logs `call screen_html_export` and does not invoke the Excel opener path.
|
||||
|
||||
- [ ] **Step 3: Add a missing-`presentation.url` regression in the workflow test module if none exists**
|
||||
|
||||
Put this close to the existing hotlist tests and keep it narrow:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing() {
|
||||
// mock screen_html_export success payload with output_path but no presentation.url
|
||||
// assert summary contains 已生成知乎热榜大屏 <path>,但浏览器自动打开失败:
|
||||
}
|
||||
```
|
||||
|
||||
Use the existing summary/path helpers in the file instead of inventing new parsing helpers.
|
||||
|
||||
- [ ] **Step 4: Run the focused compat runtime tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the workflow still returns artifact-only summaries and has no post-export open handling.
|
||||
|
||||
- [ ] **Step 5: Commit the red workflow tests**
|
||||
|
||||
```bash
|
||||
git add tests/compat_runtime_test.rs
|
||||
git commit -m "test: add hotlist post-export auto-open regressions"
|
||||
```
|
||||
|
||||
### Task 2: Implement the compat post-export opener and update workflow summaries
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/artifact_open.rs`
|
||||
- Modify: `src/compat/mod.rs`
|
||||
- Modify: `src/compat/workflow_executor.rs:375-446`
|
||||
- Test: `src/compat/artifact_open.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add the red unit tests in `src/compat/artifact_open.rs` before writing production code**
|
||||
|
||||
Create the new module with a `#[cfg(test)]` block first so the Excel opener has an exact, non-UI verification seam.
|
||||
|
||||
Target tests:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
|
||||
let mut seen = None;
|
||||
let result = open_exported_xlsx_with(Path::new("C:/tmp/zhihu-hotlist.xlsx"), |path| {
|
||||
seen = Some(path.to_path_buf());
|
||||
Ok(())
|
||||
});
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(seen.unwrap(), PathBuf::from("C:/tmp/zhihu-hotlist.xlsx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_reports_launcher_failure() {
|
||||
let result = open_exported_xlsx_with(Path::new("C:/tmp/zhihu-hotlist.xlsx"), |_path| {
|
||||
Err("launcher failed".to_string())
|
||||
});
|
||||
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
|
||||
}
|
||||
```
|
||||
|
||||
Add one matching dashboard payload test in the same file:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn open_local_dashboard_uses_exact_approved_marker_payload() {
|
||||
// FakeBrowserBackend records invoke(action, params, expected_domain)
|
||||
// assert expected_domain == "__sgclaw_local_dashboard__"
|
||||
// assert params.url == params.sgclaw_local_dashboard_open.presentation_url
|
||||
// assert source/kind/output_path all match the approved contract
|
||||
}
|
||||
```
|
||||
|
||||
This step is mandatory so the Excel route is proven to hand the generated path to the opener without launching a real application.
|
||||
|
||||
- [ ] **Step 2: Run the new unit tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_passes_generated_path_to_launcher --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_reports_launcher_failure --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_local_dashboard_uses_exact_approved_marker_payload --lib -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/compat/artifact_open.rs` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Create the small compat opener module**
|
||||
|
||||
Add one focused helper module rather than embedding side effects directly into `workflow_executor.rs`.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
|
||||
pub enum PostExportOpen {
|
||||
Opened,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
|
||||
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
|
||||
}
|
||||
|
||||
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
|
||||
where
|
||||
F: FnOnce(&Path) -> Result<(), String>,
|
||||
{ /* test seam */ }
|
||||
|
||||
pub fn open_local_dashboard(
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
output_path: &Path,
|
||||
presentation_url: &str,
|
||||
) -> PostExportOpen { /* invoke Action::Navigate with exact marker payload */ }
|
||||
```
|
||||
|
||||
Keep the module tiny. The only dedicated test seam in this file should be `open_exported_xlsx_with(...)`; do not introduce a general launcher trait.
|
||||
|
||||
- [ ] **Step 4: Implement the Windows-first `.xlsx` opener minimally**
|
||||
|
||||
Use a focused local launcher that targets the current environment first.
|
||||
|
||||
Preferred target shape:
|
||||
|
||||
```rust
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", output_path_as_windows_string])
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```text
|
||||
- fail if the path does not exist
|
||||
- do not swallow command-spawn errors
|
||||
- do not open arbitrary user-selected files from outside this workflow
|
||||
- keep cross-platform behavior minimal; only add a fallback branch if required to keep tests/build portable
|
||||
```
|
||||
|
||||
If you need a non-Windows fallback for compilation, keep it obviously minimal and out of the hot path.
|
||||
|
||||
- [ ] **Step 5: Parse payloads in `workflow_executor.rs` and call the new helper**
|
||||
|
||||
Refactor `export_xlsx(...)` and `export_screen(...)` just enough to separate:
|
||||
|
||||
```text
|
||||
- tool execution
|
||||
- payload parsing
|
||||
- route-specific post-export open
|
||||
- summary formatting
|
||||
```
|
||||
|
||||
Minimal target behavior:
|
||||
|
||||
```rust
|
||||
match open_exported_xlsx(&output_path) {
|
||||
PostExportOpen::Opened => format!("已导出并打开知乎热榜 Excel {output_path}"),
|
||||
PostExportOpen::Failed(reason) => format!("已导出知乎热榜 Excel {output_path},但自动打开失败:{reason}"),
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
match open_local_dashboard(browser_backend, &output_path, &presentation_url) {
|
||||
PostExportOpen::Opened => format!("已在浏览器中打开知乎热榜大屏 {output_path}"),
|
||||
PostExportOpen::Failed(reason) => format!("已生成知乎热榜大屏 {output_path},但浏览器自动打开失败:{reason}"),
|
||||
}
|
||||
```
|
||||
|
||||
Change signatures only as much as needed to pass `browser_backend` into the dashboard route. Do not broaden unrelated call chains.
|
||||
|
||||
- [ ] **Step 6: Export the helper module**
|
||||
|
||||
Update `src/compat/mod.rs`:
|
||||
|
||||
```rust
|
||||
pub mod artifact_open;
|
||||
```
|
||||
|
||||
Do not reorder unrelated module exports unless rustfmt does it.
|
||||
|
||||
- [ ] **Step 7: Run the focused library and workflow regressions to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_passes_generated_path_to_launcher --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_reports_launcher_failure --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_local_dashboard_uses_exact_approved_marker_payload --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS for the new library tests and the workflow regressions, unless the dashboard-open path still fails at backend/policy validation.
|
||||
|
||||
- [ ] **Step 8: Commit the compat opener and workflow changes**
|
||||
|
||||
```bash
|
||||
git add src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs tests/compat_runtime_test.rs
|
||||
git commit -m "feat: auto-open zhihu hotlist export artifacts"
|
||||
```
|
||||
|
||||
### Task 3: Add failing backend and security tests for the narrow local-dashboard allowance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_backend.rs:536-840`
|
||||
- Modify: `tests/browser_tool_test.rs` (`default_rules_allow_zhihu_navigation` section plus new local-dashboard validation tests)
|
||||
- Reference: `src/security/mac_policy.rs:56-132`
|
||||
|
||||
- [ ] **Step 1: Add a red callback-backend acceptance test for the approved local-dashboard request shape**
|
||||
|
||||
Extend the existing `src/browser/callback_backend.rs` test module with one focused navigate test.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn callback_backend_accepts_approved_local_dashboard_navigate_request() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({ "navigated": true }))]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend.invoke(
|
||||
Action::Navigate,
|
||||
json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
);
|
||||
|
||||
assert!(output.unwrap().success);
|
||||
assert_eq!(host.requests()[0].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBrowerserOpenPage",
|
||||
"file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
]));
|
||||
}
|
||||
```
|
||||
|
||||
Do not weaken any existing normal-domain tests.
|
||||
|
||||
- [ ] **Step 2: Add red rejection tests in exact files**
|
||||
|
||||
Put malformed-request rejection in `src/browser/callback_backend.rs` next to the acceptance test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() {}
|
||||
```
|
||||
|
||||
Put policy-only validation in `tests/browser_tool_test.rs` so all public `MacPolicy` assertions stay in one place:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn mac_policy_rejects_non_html_local_dashboard_presentation() {}
|
||||
|
||||
#[test]
|
||||
fn default_rules_allow_zhihu_navigation() {
|
||||
let policy = MacPolicy::load_from_path(...).unwrap();
|
||||
policy.validate(&Action::Navigate, "www.zhihu.com").unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
Do not create a second `MacPolicy` regression location.
|
||||
|
||||
- [ ] **Step 3: Run the focused backend/policy tests to verify red**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_accepts_approved_local_dashboard_navigate_request --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" mac_policy_rejects_non_html_local_dashboard_presentation --test browser_tool_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" default_rules_allow_zhihu_navigation --test browser_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: the new local-dashboard tests FAIL; `default_rules_allow_zhihu_navigation` should still PASS.
|
||||
|
||||
- [ ] **Step 4: Commit the red backend/security tests**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_backend.rs tests/browser_tool_test.rs
|
||||
git commit -m "test: lock local dashboard navigate boundary"
|
||||
```
|
||||
|
||||
### Task 4: Implement the narrow callback-backend and MacPolicy allowance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/browser/callback_backend.rs:300-351`
|
||||
- Modify: `src/security/mac_policy.rs:56-132`
|
||||
- Maybe modify: `src/security/mod.rs:9-27`
|
||||
- Test: `src/browser/callback_backend.rs:536-840`
|
||||
- Test: `tests/browser_tool_test.rs` (`default_rules_allow_zhihu_navigation` section plus new local-dashboard validation tests)
|
||||
|
||||
- [ ] **Step 1: Add a narrow local-dashboard validation helper in `MacPolicy`**
|
||||
|
||||
Keep `validate(...)` unchanged for ordinary domain flow. Add one small explicit helper instead.
|
||||
|
||||
Target shape:
|
||||
|
||||
```rust
|
||||
pub fn validate_local_dashboard_presentation(
|
||||
&self,
|
||||
action: &Action,
|
||||
expected_domain: &str,
|
||||
presentation_url: &str,
|
||||
output_path: &str,
|
||||
) -> Result<(), SecurityError> {
|
||||
// require Action::Navigate
|
||||
// require expected_domain == "__sgclaw_local_dashboard__"
|
||||
// require file:// URL
|
||||
// require .html path
|
||||
// require normalized file URL path matches output_path
|
||||
}
|
||||
```
|
||||
|
||||
If you need a new `SecurityError` variant for malformed local-dashboard input, add the smallest one that keeps error text clear.
|
||||
|
||||
- [ ] **Step 2: Recognize only the exact approved request shape in `BrowserCallbackBackend::invoke(...)`**
|
||||
|
||||
Before the normal `self.mac_policy.validate(&action, expected_domain)?` path runs, detect the one approved special case.
|
||||
|
||||
Minimal target behavior:
|
||||
|
||||
```rust
|
||||
if let Some(local_dashboard) = approved_local_dashboard_request(&action, ¶ms, expected_domain) {
|
||||
self.mac_policy.validate_local_dashboard_presentation(
|
||||
&action,
|
||||
expected_domain,
|
||||
&local_dashboard.presentation_url,
|
||||
&local_dashboard.output_path,
|
||||
)?;
|
||||
} else {
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
}
|
||||
```
|
||||
|
||||
The helper should require all of these fields exactly:
|
||||
|
||||
```text
|
||||
- action == Action::Navigate
|
||||
- expected_domain == "__sgclaw_local_dashboard__"
|
||||
- params.url exists
|
||||
- params.sgclaw_local_dashboard_open.source == "compat.workflow_executor"
|
||||
- params.sgclaw_local_dashboard_open.kind == "zhihu_hotlist_screen"
|
||||
- params.sgclaw_local_dashboard_open.output_path exists
|
||||
- params.sgclaw_local_dashboard_open.presentation_url exists and equals params.url
|
||||
```
|
||||
|
||||
Anything else must continue down the normal rejection path.
|
||||
|
||||
- [ ] **Step 3: Keep `build_command(Action::Navigate, ...)` simple**
|
||||
|
||||
Do not add a second browser opcode or change the callback-host runtime contract. The approved local-dashboard case should still flow into the existing navigate command builder so the emitted command stays:
|
||||
|
||||
```rust
|
||||
json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowerserOpenPage",
|
||||
target_url,
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused backend/security tests to verify green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_accepts_approved_local_dashboard_navigate_request --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" mac_policy_rejects_non_html_local_dashboard_presentation --test browser_tool_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" default_rules_allow_zhihu_navigation --test browser_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Re-run the dashboard workflow regression after backend validation lands**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit the backend/security implementation**
|
||||
|
||||
```bash
|
||||
git add src/browser/callback_backend.rs src/security/mac_policy.rs src/security/mod.rs tests/browser_tool_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "fix: allow approved local dashboard auto-open"
|
||||
```
|
||||
|
||||
If `src/security/mod.rs` did not change, omit it from the commit.
|
||||
|
||||
### Task 5: Run the focused verification sweep
|
||||
|
||||
**Files:**
|
||||
- Verify: `src/compat/artifact_open.rs`
|
||||
- Verify: `tests/compat_runtime_test.rs`
|
||||
- Verify: `tests/compat_screen_html_export_tool_test.rs`
|
||||
- Verify: `tests/browser_tool_test.rs`
|
||||
- Verify: `src/browser/callback_backend.rs` test module
|
||||
- Reference only if summary wording ripples outward: `tests/agent_runtime_test.rs:173-258`
|
||||
- Reference only if summary wording ripples outward: `tests/service_task_flow_test.rs:704-839`
|
||||
- Reference only if summary wording ripples outward: `tests/service_ws_session_test.rs:755-869`
|
||||
|
||||
- [ ] **Step 1: Re-run the library and workflow regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_passes_generated_path_to_launcher --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_exported_xlsx_with_reports_launcher_failure --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" open_local_dashboard_uses_exact_approved_marker_payload --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open --test compat_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing --test compat_runtime_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Re-run the tool contract regression that the dashboard route depends on**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" screen_html_export_tool_renders_dashboard_html_with_presentation_contract --test compat_screen_html_export_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Re-run the callback-backend and policy boundary tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_accepts_approved_local_dashboard_navigate_request --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields --lib -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" mac_policy_rejects_non_html_local_dashboard_presentation --test browser_tool_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" default_rules_allow_zhihu_navigation --test browser_tool_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Re-run outward-facing summary regressions only if needed**
|
||||
|
||||
Only if the updated summary text breaks existing assertions, run exactly these existing regressions and adjust only the affected expectation text:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap --test agent_runtime_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" client_to_service_regression_routes_zhihu_without_helper_bootstrap_or_invalid_hmac_seed_output --test service_task_flow_test -- --exact
|
||||
cargo test --manifest-path "D:/data/ideaSpace/rust/sgClaw/claw-new/Cargo.toml" service_binary_submit_flow_routes_zhihu_without_helper_bootstrap --test service_ws_session_test -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS for any test you had to touch. Skip this step entirely if those files needed no edits.
|
||||
|
||||
- [ ] **Step 5: Inspect scope before finishing with exact git commands**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --name-only -- src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs src/browser/callback_backend.rs src/security/mac_policy.rs src/security/mod.rs tests/compat_runtime_test.rs tests/browser_tool_test.rs tests/agent_runtime_test.rs tests/service_task_flow_test.rs tests/service_ws_session_test.rs
|
||||
git diff --stat -- src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs src/browser/callback_backend.rs src/security/mac_policy.rs src/security/mod.rs tests/compat_runtime_test.rs tests/browser_tool_test.rs tests/agent_runtime_test.rs tests/service_task_flow_test.rs tests/service_ws_session_test.rs
|
||||
```
|
||||
|
||||
Confirm the diff only touches:
|
||||
|
||||
```text
|
||||
- compat workflow/orchestration
|
||||
- compat post-export helper module
|
||||
- callback backend narrow local-dashboard acceptance
|
||||
- MacPolicy narrow local-dashboard validation
|
||||
- focused related tests
|
||||
```
|
||||
|
||||
Confirm it does **not** touch:
|
||||
|
||||
```text
|
||||
- frontend/service-console/
|
||||
- src/service/protocol.rs
|
||||
- browser-helper.html
|
||||
- callback-host endpoint contracts
|
||||
- websocket transport/protocol files
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit only if verification required additional code changes**
|
||||
|
||||
```bash
|
||||
git add src/compat/artifact_open.rs src/compat/mod.rs src/compat/workflow_executor.rs src/browser/callback_backend.rs src/security/mac_policy.rs tests/compat_runtime_test.rs tests/browser_tool_test.rs tests/agent_runtime_test.rs tests/service_task_flow_test.rs tests/service_ws_session_test.rs
|
||||
git commit -m "test: tighten hotlist post-export auto-open verification"
|
||||
```
|
||||
|
||||
If verification required no further code changes, do not create an extra commit.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Config-Owned Direct Skill Contract Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Validate the `directSubmitSkill` control surface early and prevent malformed direct-skill configs from entering the submit routing path, without changing the current happy-path direct execution behavior.
|
||||
|
||||
**Architecture:** Keep the existing direct-submit runtime and submit-task seam intact for valid configs. Move `directSubmitSkill` format validation into the normal `SgClawSettings` load path so malformed config fails before routing begins, while leaving valid-but-unresolvable `skill.tool` targets as direct runtime errors in the current direct path.
|
||||
|
||||
**Tech Stack:** Rust 2021, `serde` config parsing, current `BrowserMessage::SubmitTask` path, current direct skill runtime, Rust integration tests.
|
||||
|
||||
---
|
||||
|
||||
## Execution Context
|
||||
|
||||
- Follow @superpowers:test-driven-development for the Rust code changes in this plan.
|
||||
- Follow @superpowers:verification-before-completion before claiming any task is done.
|
||||
- Do **not** create a git worktree unless the user explicitly asks. This project prefers staying in the current checkout.
|
||||
- Keep scope tight: this plan does **not** add per-skill dispatch metadata, docs changes, intent classification, or LLM routing changes.
|
||||
|
||||
## File Map
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/config/settings.rs`
|
||||
- validate `directSubmitSkill` during config normalization
|
||||
- keep the stored field as `Option<String>` so the current direct runtime API stays stable
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
- add a failing config-load regression for malformed `directSubmitSkill`
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- add a failing submit-path regression proving malformed config is rejected before direct routing begins
|
||||
|
||||
### Existing files to read but not broaden
|
||||
|
||||
- Reuse without redesign: `src/agent/mod.rs`
|
||||
- Reuse without redesign: `src/compat/direct_skill_runtime.rs`
|
||||
- Reuse without redesign: `docs/superpowers/specs/2026-04-09-config-owned-direct-skill-dispatch-design.md`
|
||||
|
||||
### No new files expected
|
||||
|
||||
This slice should fit in the existing config and tests surfaces only.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Validate `directSubmitSkill` Before Submit Routing
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Modify: `src/config/settings.rs`
|
||||
- Read only: `src/agent/mod.rs`
|
||||
- Read only: `src/compat/direct_skill_runtime.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing config test for malformed `directSubmitSkill`**
|
||||
|
||||
Add this focused test to `tests/compat_config_test.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-invalid-direct-submit-skill-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"providers": [],
|
||||
"skillsDir": "skill_lib",
|
||||
"directSubmitSkill": "fault-details-report"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.expect_err("expected invalid directSubmitSkill format");
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("directSubmitSkill"));
|
||||
assert!(message.contains("skill.tool"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused config test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test sgclaw_settings_reject_invalid_direct_submit_skill_format -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the current config loader accepts the malformed string instead of rejecting it early.
|
||||
|
||||
- [ ] **Step 3: Write the failing agent regression for malformed config**
|
||||
|
||||
Add this focused test to `tests/agent_runtime_test.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let workspace_root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-invalid-direct-submit-workspace-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
let config_path = workspace_root.join("sgclaw_config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"providers": [],
|
||||
"skillsDir": skill_root,
|
||||
"directSubmitSkill": "fault-details-report"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(matches!(
|
||||
sent.last(),
|
||||
Some(AgentMessage::TaskComplete { success, summary })
|
||||
if !success && summary.contains("skill.tool")
|
||||
));
|
||||
assert!(direct_submit_mode_logs(&sent).is_empty());
|
||||
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused agent test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test submit_task_rejects_invalid_direct_submit_skill_config_before_routing -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the malformed config currently loads, enters the direct-submit branch, and emits `direct_skill_primary` before failing later.
|
||||
|
||||
- [ ] **Step 5: Implement the minimal config validation**
|
||||
|
||||
In `src/config/settings.rs`, add a small helper that validates the normalized `directSubmitSkill` string during `SgClawSettings::new(...)`.
|
||||
|
||||
Recommended implementation shape:
|
||||
|
||||
```rust
|
||||
fn normalize_direct_submit_skill(raw: Option<String>) -> Result<Option<String>, ConfigError> {
|
||||
let value = normalize_optional_value(raw);
|
||||
let Some(value) = value.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some((skill_name, tool_name)) = value.split_once('.') else {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"directSubmitSkill",
|
||||
format!("must use skill.tool format, got {value}"),
|
||||
));
|
||||
};
|
||||
|
||||
if skill_name.trim().is_empty() || tool_name.trim().is_empty() {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"directSubmitSkill",
|
||||
format!("must use skill.tool format, got {value}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(value.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
Then use it here:
|
||||
|
||||
```rust
|
||||
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
|
||||
```
|
||||
|
||||
Rules:
|
||||
- do not change the public field type from `Option<String>`
|
||||
- do not move parsing responsibility into `src/agent/mod.rs`
|
||||
- do not redesign `src/compat/direct_skill_runtime.rs`
|
||||
- keep valid-but-unresolvable `skill.tool` targets as runtime errors in the direct path
|
||||
|
||||
- [ ] **Step 6: Re-run the two focused tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test sgclaw_settings_reject_invalid_direct_submit_skill_format -- --nocapture
|
||||
cargo test --test agent_runtime_test submit_task_rejects_invalid_direct_submit_skill_config_before_routing -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Re-run the broader regression suites**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
cargo build --bin sgclaw
|
||||
```
|
||||
|
||||
Expected: PASS, including:
|
||||
- the direct-submit happy path
|
||||
- the existing no-LLM fallback behavior when `directSubmitSkill` is absent
|
||||
- unchanged browser-script helper semantics
|
||||
- clean binary build
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Config validation
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: malformed `directSubmitSkill` is rejected early, while the existing direct-only config shape still loads.
|
||||
|
||||
### Submit-path behavior
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- malformed `directSubmitSkill` never reaches direct routing
|
||||
- valid configured direct skill still succeeds without LLM config
|
||||
- no direct skill configured still returns the existing no-LLM message
|
||||
|
||||
### Browser-script helper safety
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: current browser-script execution semantics remain unchanged.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw
|
||||
```
|
||||
|
||||
Expected: the main binary compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Notes For The Engineer
|
||||
|
||||
- The paired spec is `docs/superpowers/specs/2026-04-09-config-owned-direct-skill-dispatch-design.md`.
|
||||
- Do **not** add sgClaw-specific dispatch metadata under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` in this slice.
|
||||
- Do **not** turn this into a per-skill registry task yet. This plan only hardens the current config-owned bootstrap contract.
|
||||
- Keep the current direct target example as `fault-details-report.collect_fault_details`; avoid hard-coding that name into new generic APIs.
|
||||
- If you discover a need for broader policy routing (`direct_browser` / `llm_agent` by skill), stop and write a new spec/plan instead of expanding this one.
|
||||
@@ -0,0 +1,520 @@
|
||||
# Direct Skill Invocation Without LLM Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the current pipe submit-task flow accept natural-language input but directly invoke one fixed staged browser skill without calling any model, while reserving a clean switch back to LLM-based routing later.
|
||||
|
||||
**Architecture:** Keep the existing `BrowserMessage::SubmitTask` entrypoint and add one narrow pre-routing seam before the current compat/LLM chain. When a new config field points to a fixed direct-submit skill, sgClaw loads that skill package from the configured external skills root, finds the target `browser_script` tool, executes it through the existing browser-script wrapper, and returns the result directly. When the field is absent, the current behavior stays unchanged. This preserves a future path where each skill can later declare `direct_browser` or `llm_agent` dispatch without rewriting the submit pipeline again.
|
||||
|
||||
**Tech Stack:** Rust 2021, existing `BrowserPipeTool`, current submit-task agent entrypoint, current browser-script skill executor, current sgClaw JSON config loader, `zeroclaw` skill manifest loader.
|
||||
|
||||
---
|
||||
|
||||
## Recommended First Skill
|
||||
|
||||
Use `fault-details-report.collect_fault_details` from:
|
||||
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
|
||||
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
|
||||
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
|
||||
Why this one first:
|
||||
- it is clearly a report/export skill
|
||||
- it exposes exactly one browser-script tool: `collect_fault_details`
|
||||
- it has the smallest contract surface (`period` only)
|
||||
- its current JS is deterministic and simple, so the first slice can focus on plumbing instead of browser scraping complexity
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
- Do **not** redesign the existing submit-task protocol.
|
||||
- Do **not** remove or rewrite the current LLM/compat path; leave it as the fallback/default path.
|
||||
- Do **not** introduce generic NL intent routing in this slice; this is one fixed direct skill only.
|
||||
- Do **not** modify `third_party/zeroclaw` skill manifest schema in phase 1.
|
||||
- Do **not** add Excel export wiring in the first slice unless a test explicitly requires it.
|
||||
- Do **not** invent a new browser-script execution model; reuse the existing wrapper semantics.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Existing files to modify
|
||||
|
||||
- Modify: `src/config/settings.rs`
|
||||
- add a minimal config field for one direct-submit skill name
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- add a narrow pre-routing branch before the current compat/LLM path
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs`
|
||||
- expose the smallest reusable helper for direct browser-script execution
|
||||
- Modify: `src/compat/mod.rs` or the nearest module export surface
|
||||
- export the new narrow direct-skill runtime module if needed
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
- add config coverage for the new direct-submit field
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
- add coverage for the reusable direct-execution helper
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- prove submit-task can bypass the model and directly invoke the fixed skill
|
||||
|
||||
### New files to create
|
||||
|
||||
- Create: `src/compat/direct_skill_runtime.rs`
|
||||
- small runtime for loading one configured skill, resolving one configured tool, deriving minimal args, and executing it directly
|
||||
|
||||
### Files to reuse without changing behavior
|
||||
|
||||
- Reuse: `src/compat/runtime.rs`
|
||||
- Reuse: `src/compat/orchestration.rs`
|
||||
- Reuse: `src/compat/config_adapter.rs`
|
||||
- Reuse: `third_party/zeroclaw/src/skills/mod.rs`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add A Minimal Direct-Submit Skill Config Field
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/settings.rs`
|
||||
- Modify: `tests/compat_config_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing config test for the new field**
|
||||
|
||||
In `tests/compat_config_test.rs`, add a focused config-load test proving the browser config file can declare one fixed direct-submit skill.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn sgclaw_settings_load_direct_submit_skill_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-direct-skill-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging",
|
||||
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = sgclaw::config::SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.direct_submit_skill.as_deref(),
|
||||
Some("fault-details-report.collect_fault_details")
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused config test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test sgclaw_settings_load_direct_submit_skill_from_browser_config -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the config field does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal config field**
|
||||
|
||||
In `src/config/settings.rs`, add:
|
||||
- `direct_submit_skill: Option<String>` to `SgClawSettings`
|
||||
- `direct_submit_skill: Option<String>` to `RawSgClawSettings`
|
||||
- field normalization in `SgClawSettings::new(...)`
|
||||
|
||||
Recommended JSON key shape:
|
||||
|
||||
```rust
|
||||
#[serde(rename = "directSubmitSkill", alias = "direct_submit_skill", default)]
|
||||
direct_submit_skill: Option<String>,
|
||||
```
|
||||
|
||||
Rules:
|
||||
- trim empty values to `None`
|
||||
- keep `DeepSeekSettings` unchanged for this slice unless a compile error proves it must mirror the field
|
||||
- do not alter unrelated config semantics
|
||||
|
||||
- [ ] **Step 4: Re-run the focused config test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test sgclaw_settings_load_direct_submit_skill_from_browser_config -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Re-run the broader config file tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 1**
|
||||
|
||||
```bash
|
||||
git add src/config/settings.rs tests/compat_config_test.rs
|
||||
git commit -m "feat: add direct submit skill config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract A Reusable Browser-Script Direct Execution Helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/browser_script_skill_tool.rs`
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing helper test**
|
||||
|
||||
In `tests/browser_script_skill_tool_test.rs`, add a focused test proving direct code can execute a packaged browser script without constructing a full `Tool` object first.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
|
||||
// build temp skill script
|
||||
// call the helper directly
|
||||
// assert Action::Eval was sent with wrapped args and normalized domain
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- the helper reads the packaged JS file
|
||||
- it wraps args with `const args = ...`
|
||||
- it normalizes URL-like `expected_domain`
|
||||
- it returns the serialized payload string on success
|
||||
|
||||
- [ ] **Step 2: Run the helper test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_runs_packaged_script_with_expected_domain -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the helper does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the second failing helper test for required-domain validation**
|
||||
|
||||
Add a focused failure-path test proving the helper rejects missing or invalid `expected_domain` before any browser command is sent.
|
||||
|
||||
- [ ] **Step 4: Run the validation test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_rejects_missing_expected_domain -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because the helper does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Implement the minimal reusable helper**
|
||||
|
||||
In `src/compat/browser_script_skill_tool.rs`, extract the smallest reusable function, for example:
|
||||
|
||||
```rust
|
||||
pub async fn execute_browser_script_tool<T: Transport + 'static>(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- reuse the current path validation, script loading, wrapping, `Action::Eval`, and payload formatting logic already used by `BrowserScriptSkillTool::execute`
|
||||
- do not change outward behavior of `BrowserScriptSkillTool`
|
||||
- keep the helper narrow and browser-script-only
|
||||
|
||||
- [ ] **Step 6: Refactor `BrowserScriptSkillTool::execute` to call the helper**
|
||||
|
||||
Keep existing behavior and tests green while removing duplicate execution logic.
|
||||
|
||||
- [ ] **Step 7: Re-run the browser-script tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add src/compat/browser_script_skill_tool.rs tests/browser_script_skill_tool_test.rs
|
||||
git commit -m "refactor: extract direct browser script execution helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add A Narrow Direct Skill Runtime For One Fixed Skill
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/direct_skill_runtime.rs`
|
||||
- Modify: `src/compat/mod.rs` or nearest module export point
|
||||
- Reuse: `src/compat/config_adapter.rs`
|
||||
- Reuse: `third_party/zeroclaw/src/skills/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing direct-runtime test**
|
||||
|
||||
Add a focused test in `tests/agent_runtime_test.rs` or a new narrow compat test proving code can resolve the configured external skills root, load `fault-details-report`, find `collect_fault_details`, and execute it directly.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn direct_skill_runtime_executes_fault_details_report_without_provider() {
|
||||
// config points at skill_staging root
|
||||
// direct_submit_skill points at fault-details-report.collect_fault_details
|
||||
// browser response returns report-artifact payload
|
||||
// assert no provider/http path is touched
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused direct-runtime test and verify it fails**
|
||||
|
||||
Run the narrowest test command for the new test.
|
||||
|
||||
Expected: FAIL because the direct runtime does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement `src/compat/direct_skill_runtime.rs`**
|
||||
|
||||
Add a narrow runtime with responsibilities only to:
|
||||
- resolve the configured skills dir with `resolve_skills_dir_from_sgclaw_settings(...)`
|
||||
- load skills from that directory with `load_skills_from_directory(...)`
|
||||
- parse the configured tool name into `skill_name` + `tool_name`
|
||||
- find the matching skill and matching tool
|
||||
- verify `tool.kind == "browser_script"`
|
||||
- derive the minimal argument object
|
||||
- call the new browser-script helper
|
||||
- return the output string or a clear `PipeError`
|
||||
|
||||
Do **not** add generic routing, scenes, or model fallback here.
|
||||
|
||||
- [ ] **Step 4: Keep argument derivation intentionally minimal**
|
||||
|
||||
For the first slice, derive only:
|
||||
- `expected_domain` from `page_url` when present, otherwise fail with a clear message
|
||||
- `period` from the instruction using a narrow deterministic pattern such as `YYYY-MM`
|
||||
|
||||
If the period cannot be derived, return a concise error telling the user to provide it explicitly. Do not guess.
|
||||
|
||||
- [ ] **Step 5: Re-run the focused direct-runtime test**
|
||||
|
||||
Run the same test command again.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add src/compat/direct_skill_runtime.rs src/compat/mod.rs tests/agent_runtime_test.rs
|
||||
git commit -m "feat: add fixed direct skill runtime"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Insert The Pre-Routing Seam In Submit-Task Entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write the first failing submit-path bypass test**
|
||||
|
||||
In `tests/agent_runtime_test.rs`, add a focused regression proving that when `directSubmitSkill` is configured, `BrowserMessage::SubmitTask` can succeed without any model/provider being configured.
|
||||
|
||||
Test shape:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
|
||||
// config contains skillsDir + directSubmitSkill, but no reachable provider
|
||||
// natural-language instruction includes period and page_url
|
||||
// expect TaskComplete success from direct skill result
|
||||
}
|
||||
```
|
||||
|
||||
Required assertions:
|
||||
- task succeeds even if provider would be unavailable
|
||||
- output contains the report artifact payload
|
||||
- no summary like `未配置大语言模型`
|
||||
|
||||
- [ ] **Step 2: Run the bypass test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test submit_task_uses_direct_skill_mode_without_llm_configuration -- --nocapture
|
||||
```
|
||||
|
||||
Expected: FAIL because submit-task still goes into the current LLM-oriented path.
|
||||
|
||||
- [ ] **Step 3: Add the second failing priority test**
|
||||
|
||||
Add one focused test proving the direct-submit branch runs before the existing compat/LLM branch.
|
||||
|
||||
The easiest assertion is that the mode log becomes something new like:
|
||||
- `direct_skill_primary`
|
||||
|
||||
and the normal mode logs do not appear for that turn.
|
||||
|
||||
- [ ] **Step 4: Run the priority test and verify it fails**
|
||||
|
||||
Run the narrow test command for the new test.
|
||||
|
||||
Expected: FAIL because the mode does not exist yet.
|
||||
|
||||
- [ ] **Step 5: Add the narrow pre-routing branch in `src/agent/mod.rs`**
|
||||
|
||||
In `handle_browser_message_with_context(...)`, after config load/logging and before the existing `should_use_primary_orchestration(...)` / `compat::runtime` path:
|
||||
- check `settings.direct_submit_skill`
|
||||
- if present, emit mode log `direct_skill_primary`
|
||||
- call the new direct runtime
|
||||
- send `TaskComplete` and return immediately
|
||||
|
||||
Rules:
|
||||
- if `direct_submit_skill` is absent, keep existing behavior byte-for-byte where possible
|
||||
- do not modify `compat::runtime.rs` or `compat::orchestration.rs` for this slice
|
||||
- do not silently fall through to LLM when direct execution fails; return the direct error clearly so the first slice is debuggable
|
||||
|
||||
- [ ] **Step 6: Re-run the focused submit-path tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test submit_task_uses_direct_skill_mode_without_llm_configuration -- --nocapture
|
||||
cargo test --test agent_runtime_test direct_skill_mode_logs_direct_skill_primary -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Re-run existing no-LLM submit regression coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS, including existing cases where no direct skill is configured and the old no-LLM failure still applies.
|
||||
|
||||
- [ ] **Step 8: Commit Task 4**
|
||||
|
||||
```bash
|
||||
git add src/agent/mod.rs tests/agent_runtime_test.rs
|
||||
git commit -m "feat: route submit tasks through fixed direct skill mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Lock The Future Migration Seam Without Implementing LLM Dispatch Yet
|
||||
|
||||
**Files:**
|
||||
- Modify only if needed: `src/config/settings.rs`
|
||||
- Modify only if needed: `src/compat/direct_skill_runtime.rs`
|
||||
- Reuse: docs/plan only unless code needs one tiny naming fix
|
||||
|
||||
- [ ] **Step 1: Keep the config naming compatible with future per-skill dispatch**
|
||||
|
||||
Document and preserve this future meaning in code naming:
|
||||
- current field: one fixed direct skill for submit-task bootstrap
|
||||
- future model: each skill can declare dispatch mode such as `direct_browser` or `llm_agent`
|
||||
|
||||
Prefer neutral names in helper code like:
|
||||
- `direct skill mode`
|
||||
- `direct submit skill`
|
||||
|
||||
Avoid hard-coding `fault_details` into generic APIs.
|
||||
|
||||
- [ ] **Step 2: Add one small negative test for fallback behavior**
|
||||
|
||||
Add a focused test proving that when `directSubmitSkill` is not configured, submit-task still behaves exactly as before and can still return the existing no-LLM message.
|
||||
|
||||
If an existing test already proves this, keep it and do not add another.
|
||||
|
||||
- [ ] **Step 3: Re-run the focused end-to-end verification set**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Build the main binary**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit Task 5**
|
||||
|
||||
```bash
|
||||
git add src/config/settings.rs src/compat/direct_skill_runtime.rs src/compat/browser_script_skill_tool.rs src/agent/mod.rs tests/compat_config_test.rs tests/browser_script_skill_tool_test.rs tests/agent_runtime_test.rs
|
||||
git commit -m "test: verify fixed direct skill submit path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Config loading
|
||||
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: `directSubmitSkill` loads correctly and existing config behavior remains intact.
|
||||
|
||||
### Browser-script helper
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: direct helper preserves the existing browser-script execution semantics.
|
||||
|
||||
### Submit-path bypass
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: configured direct skill bypasses the model path, while unconfigured submit-task behavior stays unchanged.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cargo build --bin sgclaw
|
||||
```
|
||||
|
||||
Expected: the binary compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Notes For The Engineer
|
||||
|
||||
- The key to keeping this slice small is to avoid changing `compat::runtime.rs` and `compat::orchestration.rs`; they remain the future LLM path.
|
||||
- `fault-details-report.collect_fault_details` is only the bootstrap skill. The plumbing must stay generic enough that the configured tool name can later point to another staged browser skill.
|
||||
- Phase 1 should not add per-skill dispatch metadata to the external skill manifests yet. Keep that decision in sgClaw config first; move it into skill metadata only after the direct path is proven useful.
|
||||
- Once the intranet model is ready, the clean next step is to add a dispatch policy layer that chooses between `direct_browser` and `llm_agent` before the current compat path is entered, reusing this same pre-routing seam.
|
||||
@@ -0,0 +1,672 @@
|
||||
# Fault Details Full Skill Alignment Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade `fault-details-report.collect_fault_details` into a real staged browser skill that matches the original fault-details workflow, and make `claw-new` interpret the returned artifact status correctly in the direct-submit path.
|
||||
|
||||
**Architecture:** Keep routing and direct-skill selection in `claw-new`, but move all fault-details collection, normalization, classification, summary, export, and report-log behavior into the staged skill under `skill_staging`. Implement the staged skill as a true browser-eval entrypoint that remains valid in page context, while exposing testable pure helpers through an environment-safe export guard for `node:test`; then add a narrow Rust artifact interpreter in `src/compat/direct_skill_runtime.rs` so `ok` / `partial` / `empty` map to successful task completion while `blocked` / `error` map to failed completion.
|
||||
|
||||
**Tech Stack:** Rust 2021, `serde_json`, existing `BrowserPipeTool` / `browser_script` runtime, `node:test`, staged skill fixtures, Cargo integration tests.
|
||||
|
||||
---
|
||||
|
||||
## Execution Context
|
||||
|
||||
- Follow @superpowers:test-driven-development for every behavior change.
|
||||
- Follow @superpowers:verification-before-completion before claiming each task is done.
|
||||
- Do **not** create a git worktree unless the user explicitly asks. This repo preference is already established.
|
||||
- Keep scope tight. Do **not** add a new browser protocol, new dispatch metadata, new UI opener behavior, or Rust-side fault classification logic.
|
||||
- Keep the current direct path bootstrap requirement intact: the user instruction must still include an explicit `YYYY-MM`, but the staged skill must treat the page-selected range as the source of truth for collection once execution begins.
|
||||
- Preserve parity with the original package’s real behavior: port the original classification table, `qxxcjl`-based reason heuristics, canonical detail mapping, summary aggregation rules, localhost export call, and report-log call into the staged skill rather than implementing a fixture-only subset.
|
||||
|
||||
## File Map
|
||||
|
||||
### Existing files to modify in `claw-new`
|
||||
|
||||
- Modify: `src/compat/direct_skill_runtime.rs`
|
||||
- add narrow structured artifact parsing and status-to-summary mapping
|
||||
- keep direct-skill routing/config ownership unchanged
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- add direct-submit regressions for `ok`, `partial`, `empty`, `blocked`, and `error`
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
- add browser-script execution-shape regression for browser-eval return payloads used by fault-details
|
||||
|
||||
### Existing files to modify in `skill_staging`
|
||||
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
- replace empty shell with browser-eval entrypoint plus parity helpers
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
|
||||
- deterministic fixture coverage for normalization, classification, summary, artifact contract, export/logging degradation, and entrypoint shape helpers
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
|
||||
- align tool description with real collection/export/report-log behavior
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.md`
|
||||
- align written contract with actual runtime behavior and artifact fields
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/collection-flow.md`
|
||||
- align flow with page-range/query/export/report-log sequence
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/data-quality.md`
|
||||
- make canonical columns, original classification tables, reason heuristics, summary rules, and partial semantics explicit
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
|
||||
- keep scene output/state contract aligned with real staged artifact behavior
|
||||
|
||||
### Existing files to read but not redesign
|
||||
|
||||
- Read only: `docs/superpowers/specs/2026-04-10-fault-details-full-skill-alignment-design.md`
|
||||
- Read only: `src/agent/mod.rs`
|
||||
- Read only: `src/compat/browser_script_skill_tool.rs`
|
||||
- Read only: `D:/desk/智能体资料/大四区报告监测项/故障明细/index.html`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add staged-skill red tests for normalization, summary, and artifact-contract semantics
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
|
||||
- Read only: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
- Read only: `D:/desk/智能体资料/大四区报告监测项/故障明细/index.html`
|
||||
|
||||
- [ ] **Step 1: Write the failing staged-skill test file**
|
||||
|
||||
Add `collect_fault_details.test.js` using `node:test` and `assert/strict`. Cover these behaviors with fixed fixtures:
|
||||
|
||||
```javascript
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
DETAIL_COLUMNS,
|
||||
SUMMARY_COLUMNS,
|
||||
normalizeDetailRow,
|
||||
deriveSummaryRows,
|
||||
determineArtifactStatus,
|
||||
buildFaultDetailsArtifact,
|
||||
buildBrowserEntrypointResult
|
||||
} = require('./collect_fault_details.js');
|
||||
|
||||
test('normalizeDetailRow maps canonical detail fields from raw repair rows', () => {
|
||||
const row = normalizeDetailRow({
|
||||
qxdbh: 'QX-1',
|
||||
bxsj: '2026-03-09 08:00:00',
|
||||
cityName: '国网兰州供电公司',
|
||||
maintOrgName: '城关供电服务班',
|
||||
maintGroupName: '抢修一班',
|
||||
bdzMc: '110kV东岗变',
|
||||
xlmc10: '10kV东岗线',
|
||||
byqmc: '东岗1号变',
|
||||
yjflMc: '电网故障',
|
||||
ejflMc: '线路故障',
|
||||
sjflMc: '低压线路',
|
||||
qxxcjl: '现场检查:低压线路断线,已处理完成',
|
||||
gzms: '客户报修停电'
|
||||
}, {
|
||||
companyName: '国网兰州供电公司'
|
||||
});
|
||||
|
||||
assert.equal(row.slsj, '2026-03-09 08:00:00');
|
||||
assert.equal(row.gssgs, '甘肃省电力公司');
|
||||
assert.equal(row.gddw, '城关供电服务班');
|
||||
assert.equal(row.gds, '抢修一班');
|
||||
assert.equal(row.clzt, '处理完成');
|
||||
assert.equal(row.bdz, '110kV东岗变');
|
||||
assert.equal(row.line, '10kV东岗线');
|
||||
assert.equal(row.pb, '东岗1号变');
|
||||
});
|
||||
|
||||
test('deriveSummaryRows groups normalized rows by gds and computes counters', () => {
|
||||
const rows = [
|
||||
{ gds: '抢修一班', gddw: '城关供电服务班', sgs: '国网兰州供电公司', sxfl1: '无效', sxfl2: '无效', gzsb: '' },
|
||||
{ gds: '抢修一班', gddw: '城关供电服务班', sgs: '国网兰州供电公司', sxfl1: '有效', sxfl2: '用户侧', gzsb: '表后线' },
|
||||
{ gds: '抢修一班', gddw: '城关供电服务班', sgs: '国网兰州供电公司', sxfl1: '有效', sxfl2: '电网侧', dwcFl: '低压故障', gzsb: '低压线路' }
|
||||
];
|
||||
|
||||
const summaryRows = deriveSummaryRows(rows, { companyName: '国网兰州供电公司' });
|
||||
assert.equal(summaryRows.length, 1);
|
||||
assert.equal(summaryRows[0].className, '抢修一班');
|
||||
assert.equal(summaryRows[0].allCount, 3);
|
||||
assert.equal(summaryRows[0].wxCount, 1);
|
||||
assert.equal(summaryRows[0].khcCount, 0);
|
||||
assert.equal(summaryRows[0].dyGzCount, 1);
|
||||
assert.equal(summaryRows[0].dyxlCount, 1);
|
||||
assert.equal(summaryRows[0].bhxCount, 1);
|
||||
});
|
||||
|
||||
test('determineArtifactStatus follows blocked > error > partial > empty > ok precedence', () => {
|
||||
assert.equal(determineArtifactStatus({ blockedReason: 'missing_session', fatalError: null, partialReasons: [], detailRows: [{}] }), 'blocked');
|
||||
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: 'parse_failed', partialReasons: [], detailRows: [{}] }), 'error');
|
||||
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: null, partialReasons: ['export_failed'], detailRows: [{}] }), 'partial');
|
||||
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: null, partialReasons: [], detailRows: [] }), 'empty');
|
||||
assert.equal(determineArtifactStatus({ blockedReason: null, fatalError: null, partialReasons: [], detailRows: [{}] }), 'ok');
|
||||
});
|
||||
|
||||
test('buildFaultDetailsArtifact keeps canonical fields, selected range, counts, and downstream results', () => {
|
||||
const artifact = buildFaultDetailsArtifact({
|
||||
period: '2026-03',
|
||||
selectedRange: { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' },
|
||||
detailRows: [{ qxdbh: 'QX-1' }],
|
||||
summaryRows: [{ index: 1 }],
|
||||
partialReasons: ['report_log_failed'],
|
||||
downstream: {
|
||||
export: { attempted: true, success: true, path: 'http://localhost/export.xlsx' },
|
||||
report_log: { attempted: true, success: false, error: '500' }
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(artifact.type, 'report-artifact');
|
||||
assert.equal(artifact.status, 'partial');
|
||||
assert.deepEqual(artifact.selected_range, { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' });
|
||||
assert.equal(artifact.counts.detail_rows, 1);
|
||||
assert.equal(artifact.counts.summary_rows, 1);
|
||||
assert.deepEqual(artifact.partial_reasons, ['report_log_failed']);
|
||||
});
|
||||
|
||||
test('buildFaultDetailsArtifact keeps required top-level fields for blocked artifact', () => {
|
||||
const artifact = buildFaultDetailsArtifact({
|
||||
period: '2026-03',
|
||||
blockedReason: 'selected_range_unavailable',
|
||||
partialReasons: ['selected_range_unavailable']
|
||||
});
|
||||
|
||||
assert.equal(artifact.type, 'report-artifact');
|
||||
assert.equal(artifact.report_name, 'fault-details-report');
|
||||
assert.equal(artifact.period, '2026-03');
|
||||
assert.equal(artifact.status, 'blocked');
|
||||
assert.deepEqual(artifact.partial_reasons, ['selected_range_unavailable']);
|
||||
assert.equal('downstream' in artifact, false);
|
||||
});
|
||||
|
||||
test('buildFaultDetailsArtifact keeps known selected range and counts on late error', () => {
|
||||
const artifact = buildFaultDetailsArtifact({
|
||||
period: '2026-03',
|
||||
selectedRange: { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' },
|
||||
detailRows: [],
|
||||
summaryRows: [],
|
||||
fatalError: 'summary_failed',
|
||||
partialReasons: ['summary_failed']
|
||||
});
|
||||
|
||||
assert.equal(artifact.status, 'error');
|
||||
assert.deepEqual(artifact.selected_range, { start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' });
|
||||
assert.equal(artifact.counts.detail_rows, 0);
|
||||
assert.equal(artifact.counts.summary_rows, 0);
|
||||
});
|
||||
|
||||
test('buildBrowserEntrypointResult returns blocked artifact when selected range is unavailable', async () => {
|
||||
const artifact = await buildBrowserEntrypointResult({
|
||||
period: '2026-03'
|
||||
}, {
|
||||
readSelectedRange: async () => null
|
||||
});
|
||||
|
||||
assert.equal(artifact.status, 'blocked');
|
||||
assert.ok(artifact.partial_reasons.includes('selected_range_unavailable'));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the staged-skill test file and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
|
||||
```
|
||||
|
||||
Expected: FAIL because `collect_fault_details.js` does not export these helpers yet and still only returns an empty shell.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement staged-skill parity helpers and a valid browser entrypoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
- Test: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
|
||||
|
||||
- [ ] **Step 1: Implement the helper exports and browser entrypoint shape needed to satisfy the red tests**
|
||||
|
||||
Refactor `collect_fault_details.js` so the file remains a valid browser-eval script in page context while still supporting `node:test` through an environment-safe export guard.
|
||||
|
||||
Required implementation pieces:
|
||||
|
||||
```javascript
|
||||
const DETAIL_COLUMNS = [/* existing canonical columns */];
|
||||
const SUMMARY_COLUMNS = [/* existing summary columns */];
|
||||
|
||||
function normalizeDetailRow(raw, context) {
|
||||
// map qxdbh/gssgs/sgs/gddw/gds/slsj/clzt/bdz/line/pb
|
||||
// derive sxfl1/sxfl2/sxfl3/gzsb/gzyy from the original package rules
|
||||
}
|
||||
|
||||
function deriveSummaryRows(detailRows, context) {
|
||||
// group by gds and compute all original package counters
|
||||
}
|
||||
|
||||
function determineArtifactStatus({ blockedReason, fatalError, partialReasons, detailRows }) {
|
||||
// blocked > error > partial > empty > ok
|
||||
}
|
||||
|
||||
function buildFaultDetailsArtifact({
|
||||
period,
|
||||
selectedRange,
|
||||
detailRows,
|
||||
summaryRows,
|
||||
partialReasons,
|
||||
blockedReason,
|
||||
fatalError,
|
||||
downstream
|
||||
}) {
|
||||
// return report-artifact with columns, sections, counts, status, partial_reasons, downstream
|
||||
}
|
||||
|
||||
async function buildBrowserEntrypointResult(input, deps = defaultBrowserDeps()) {
|
||||
// read selected range from page
|
||||
// collect raw rows from page query
|
||||
// normalize rows
|
||||
// derive summary
|
||||
// attempt export + report log
|
||||
// return final artifact
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
DETAIL_COLUMNS,
|
||||
SUMMARY_COLUMNS,
|
||||
normalizeDetailRow,
|
||||
deriveSummaryRows,
|
||||
determineArtifactStatus,
|
||||
buildFaultDetailsArtifact,
|
||||
buildBrowserEntrypointResult
|
||||
};
|
||||
}
|
||||
|
||||
return await buildBrowserEntrypointResult(args);
|
||||
```
|
||||
|
||||
Rules:
|
||||
- keep `DETAIL_COLUMNS` and `SUMMARY_COLUMNS` canonical and stable
|
||||
- keep helper functions self-contained in this file unless a separate pure helper file becomes necessary for runtime validity
|
||||
- keep the browser entrypoint compatible with current `eval` wrapper
|
||||
- keep browser runtime free of unguarded Node-only assumptions
|
||||
- do **not** invent a new protocol or callback surface
|
||||
|
||||
- [ ] **Step 2: Re-run the staged-skill test file and verify it now reaches deeper failures or passes the initial helper coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
|
||||
```
|
||||
|
||||
Expected: either PASS for the Task 1 cases, or fail only on the still-missing full parity/export/history specifics added in Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add red tests for full classification parity, downstream partials, and empty-result export semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js`
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
- Read only: `D:/desk/智能体资料/大四区报告监测项/故障明细/index.html`
|
||||
|
||||
- [ ] **Step 1: Extend the staged-skill tests with failing parity and downstream cases**
|
||||
|
||||
Add focused failing tests such as:
|
||||
|
||||
```javascript
|
||||
test('normalizeDetailRow derives gzyy from qxxcjl text heuristics', () => {
|
||||
const row = normalizeDetailRow({
|
||||
qxxcjl: '现场检查:客户表后线烧损,已恢复送电',
|
||||
ejflMc: '客户侧故障',
|
||||
sjflMc: '表后线'
|
||||
}, { companyName: '国网兰州供电公司' });
|
||||
|
||||
assert.equal(row.gzsb, '表后线');
|
||||
assert.equal(row.gzyy, '表后线烧损');
|
||||
});
|
||||
|
||||
test('buildBrowserEntrypointResult returns partial when export fails after detail collection succeeds', async () => {
|
||||
const artifact = await buildBrowserEntrypointResult({ period: '2026-03' }, {
|
||||
readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }),
|
||||
queryFaultRows: async () => [{ qxdbh: 'QX-1', bxsj: '2026-03-09 08:00:00', maintGroupName: '抢修一班' }],
|
||||
readCompanyContext: () => ({ companyName: '国网兰州供电公司' }),
|
||||
exportWorkbook: async () => {
|
||||
throw new Error('export_failed');
|
||||
},
|
||||
writeReportLog: async () => ({ success: true })
|
||||
});
|
||||
|
||||
assert.equal(artifact.status, 'partial');
|
||||
assert.ok(artifact.partial_reasons.includes('export_failed'));
|
||||
assert.equal(artifact.counts.detail_rows, 1);
|
||||
assert.equal(artifact.downstream.export.attempted, true);
|
||||
assert.equal(artifact.downstream.export.success, false);
|
||||
});
|
||||
|
||||
test('buildBrowserEntrypointResult returns error when normalized detail rows cannot be produced', async () => {
|
||||
const artifact = await buildBrowserEntrypointResult({ period: '2026-03' }, {
|
||||
readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }),
|
||||
queryFaultRows: async () => [{ qxdbh: '', bxsj: '' }],
|
||||
readCompanyContext: () => ({ companyName: '国网兰州供电公司' })
|
||||
});
|
||||
|
||||
assert.equal(artifact.status, 'error');
|
||||
assert.ok(artifact.partial_reasons.includes('detail_normalization_failed'));
|
||||
});
|
||||
|
||||
test('buildBrowserEntrypointResult keeps canonical rows empty for empty result and omits downstream before attempts', async () => {
|
||||
const artifact = await buildBrowserEntrypointResult({ period: '2026-03' }, {
|
||||
readSelectedRange: async () => ({ start: '2026-03-08 16:00:00', end: '2026-03-09 16:00:00' }),
|
||||
queryFaultRows: async () => [],
|
||||
readCompanyContext: () => ({ companyName: '国网兰州供电公司' })
|
||||
});
|
||||
|
||||
assert.equal(artifact.status, 'empty');
|
||||
assert.deepEqual(artifact.rows, []);
|
||||
assert.equal('downstream' in artifact, false);
|
||||
});
|
||||
```
|
||||
|
||||
Also add fixture cases derived from the original package’s full classification table and summary counters so the staged skill is forced toward parity, not a subset implementation.
|
||||
|
||||
- [ ] **Step 2: Run the staged-skill test file and verify it fails on the new cases**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
|
||||
```
|
||||
|
||||
Expected: FAIL on missing full classification parity or downstream partial/error behavior.
|
||||
|
||||
- [ ] **Step 3: Implement the full business logic needed to satisfy the new tests**
|
||||
|
||||
In `collect_fault_details.js`:
|
||||
- port the original classification table and `qxxcjl` text heuristics for `sxfl1`, `sxfl2`, `sxfl3`, `gzsb`, `gzyy`
|
||||
- port the original summary derivation rules and counters completely
|
||||
- add required-field validation so structurally unusable normalized rows escalate to `error`
|
||||
- add downstream `exportWorkbook` and `writeReportLog` stages that record `{attempted, success, path, error}`
|
||||
- keep collection success distinct from downstream failures so export/logging failures become `partial`, not full failure
|
||||
- keep placeholder rows, if needed for downstream empty-export payloads, downstream-only and never in canonical returned `rows`
|
||||
- include both `period` and `selected_range` in the artifact
|
||||
- omit `downstream` when export/report-log have not been attempted yet
|
||||
|
||||
- [ ] **Step 4: Re-run the staged-skill test file and verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Align staged-skill metadata and reference docs with the implemented behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.md`
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/collection-flow.md`
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/references/data-quality.md`
|
||||
- Modify: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
|
||||
|
||||
- [ ] **Step 1: Update the staged metadata/docs to match the implemented runtime contract**
|
||||
|
||||
Required changes:
|
||||
- `SKILL.toml`: description must say the tool collects rows, derives summary, attempts localhost export, and records report history
|
||||
- `SKILL.md`: artifact example must include `selected_range`, `counts`, `status`, `partial_reasons`, and `downstream`
|
||||
- `references/collection-flow.md`: sequence must explicitly include page-selected range -> raw query -> normalization -> summary -> export -> report-log
|
||||
- `references/data-quality.md`: document the original classification tables, `qxxcjl` heuristics, summary rules, partial/error escalation rules, and empty-result semantics explicitly enough to match the implemented helpers
|
||||
- `scene.json`: keep inputs/outputs/status semantics aligned with the richer artifact; do not add routing policy there
|
||||
|
||||
- [ ] **Step 2: Read the updated staged docs and verify they match the implemented JS behavior**
|
||||
|
||||
Read and confirm:
|
||||
- descriptions no longer claim “artifact shell” behavior
|
||||
- docs do not move routing ownership out of `claw-new`
|
||||
- docs do not promise auto-opening/downloading behavior in this slice
|
||||
- docs reflect blocked/error field-presence rules and downstream-attempt semantics
|
||||
|
||||
Expected: staged metadata/docs accurately reflect the implemented collector.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Rust red tests for artifact-status interpretation in the direct-submit runtime
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
- Modify: `src/compat/direct_skill_runtime.rs`
|
||||
- Read only: `src/compat/browser_script_skill_tool.rs`
|
||||
|
||||
- [ ] **Step 1: Add failing direct-submit runtime tests for structured artifact statuses**
|
||||
|
||||
Extend `tests/agent_runtime_test.rs` with focused regressions that use the existing temp skill-root harness but return real `report-artifact` payloads:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn submit_task_treats_partial_report_artifact_as_success_with_warning_summary() {
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
serde_json::json!({
|
||||
"text": {
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"period": "2026-03",
|
||||
"selected_range": { "start": "2026-03-08 16:00:00", "end": "2026-03-09 16:00:00" },
|
||||
"columns": ["qxdbh"],
|
||||
"rows": [{ "qxdbh": "QX-1" }],
|
||||
"sections": [{ "name": "summary-sheet", "columns": ["index"], "rows": [{ "index": 1 }] }],
|
||||
"counts": { "detail_rows": 1, "summary_rows": 1 },
|
||||
"status": "partial",
|
||||
"partial_reasons": ["report_log_failed"],
|
||||
"downstream": {
|
||||
"export": { "attempted": true, "success": true, "path": "http://localhost/export.xlsx" },
|
||||
"report_log": { "attempted": true, "success": false, "error": "500" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
// ... invoke handle_browser_message_with_context(...)
|
||||
// assert TaskComplete.success == true
|
||||
// assert summary contains partial/report_log_failed/detail_rows=1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_empty_report_artifact_as_success() { /* status=empty => success=true */ }
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_blocked_report_artifact_as_failure() { /* status=blocked => success=false */ }
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_error_report_artifact_as_failure() { /* status=error => success=false */ }
|
||||
```
|
||||
|
||||
Also add one focused helper regression to `tests/browser_script_skill_tool_test.rs` that proves the browser-script helper can return a structured object payload used by the fault-details path without flattening required fields away.
|
||||
|
||||
Suggested test name:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_preserves_structured_report_artifact_payload() { /* ... */ }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused Rust tests and verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test submit_task_treats_partial_report_artifact_as_success_with_warning_summary -- --nocapture
|
||||
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_preserves_structured_report_artifact_payload -- --nocapture
|
||||
```
|
||||
|
||||
Expected: the new `agent_runtime_test` case fails because `execute_direct_submit_skill` still returns raw JSON text and `src/agent/mod.rs` still marks all direct-submit results as success when no Rust-side interpretation exists.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Implement narrow Rust artifact interpretation without moving business rules into Rust
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/direct_skill_runtime.rs`
|
||||
- Modify: `tests/agent_runtime_test.rs`
|
||||
- Modify: `tests/browser_script_skill_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Implement a narrow structured-artifact interpreter in `src/compat/direct_skill_runtime.rs`**
|
||||
|
||||
Add a small internal result type and parser, for example:
|
||||
|
||||
```rust
|
||||
struct DirectSubmitOutcome {
|
||||
success: bool,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
fn interpret_direct_submit_output(output: &str) -> DirectSubmitOutcome {
|
||||
// parse JSON if possible
|
||||
// if type == "report-artifact", read status/counts/partial_reasons/downstream
|
||||
// map ok/partial/empty => success=true
|
||||
// map blocked/error => success=false
|
||||
// build concise summary with report_name, period, detail_rows, summary_rows, status, partial reasons
|
||||
// fall back to raw output text when payload is not a recognized artifact
|
||||
}
|
||||
```
|
||||
|
||||
Then change the public entrypoint shape from `Result<String, PipeError>` to a narrow result carrying `success` and `summary`, or add a second helper that `src/agent/mod.rs` can use without changing routing ownership.
|
||||
|
||||
Rules:
|
||||
- do **not** reimplement fault normalization/classification/summary in Rust
|
||||
- do **not** add fault-specific branching in `src/agent/mod.rs`
|
||||
- keep unrecognized non-artifact outputs working as before
|
||||
- keep explicit `YYYY-MM` derivation and configured `skill.tool` resolution unchanged
|
||||
|
||||
- [ ] **Step 2: Update the submit-path caller to use the interpreted success flag**
|
||||
|
||||
Adjust the direct-submit branch so `TaskComplete.success` comes from the artifact interpretation instead of blindly treating every `Ok(summary)` as success.
|
||||
|
||||
Implementation target:
|
||||
- keep the direct path in `src/agent/mod.rs`
|
||||
- keep error handling narrow
|
||||
- if needed, return a dedicated direct-submit outcome from `execute_direct_submit_skill`
|
||||
|
||||
- [ ] **Step 3: Re-run the focused Rust tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test submit_task_treats_partial_report_artifact_as_success_with_warning_summary -- --nocapture
|
||||
cargo test --test agent_runtime_test submit_task_treats_empty_report_artifact_as_success -- --nocapture
|
||||
cargo test --test agent_runtime_test submit_task_treats_blocked_report_artifact_as_failure -- --nocapture
|
||||
cargo test --test agent_runtime_test submit_task_treats_error_report_artifact_as_failure -- --nocapture
|
||||
cargo test --test browser_script_skill_tool_test execute_browser_script_tool_preserves_structured_report_artifact_payload -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run the full verification sweep for the staged skill and direct runtime
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] **Step 1: Run the staged-skill deterministic test file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run the relevant Rust regression suites**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run the broader compatibility coverage and build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo build --bin sgclaw
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manually verify the requirements against the approved spec**
|
||||
|
||||
Checklist:
|
||||
- staged skill now reads page-selected range instead of inventing a month window after entry
|
||||
- staged skill returns canonical detail rows and summary rows
|
||||
- staged skill ports the original classification table, `qxxcjl` heuristics, and summary counters with parity coverage
|
||||
- staged skill records downstream export/report-log outcome
|
||||
- staged skill distinguishes `ok` / `partial` / `empty` / `blocked` / `error`
|
||||
- `blocked` / `error` artifacts keep the required top-level fields, and preserve known `selected_range` / `counts` when failure happens late enough
|
||||
- `downstream` is omitted when export/report-log were not attempted and included with attempted/success flags once they were attempted
|
||||
- empty-result canonical `rows` stay empty even if downstream export uses a placeholder transport row
|
||||
- `claw-new` maps `ok` / `partial` / `empty` to success and `blocked` / `error` to failure
|
||||
- no new routing metadata was added to `SKILL.toml` or `scene.json`
|
||||
- no new browser protocol or opener/UI behavior was introduced
|
||||
|
||||
Expected: all checklist items satisfied before calling the work complete.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### Staged skill behavior
|
||||
|
||||
```bash
|
||||
node "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.test.js"
|
||||
```
|
||||
|
||||
Expected: deterministic fixture coverage passes for normalization, full classification parity, summary derivation, artifact shape, empty semantics, and downstream partial semantics.
|
||||
|
||||
### Direct-submit runtime mapping
|
||||
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
- valid artifact `ok` / `partial` / `empty` completes successfully
|
||||
- valid artifact `blocked` / `error` completes as failure
|
||||
- existing invalid config regression still passes
|
||||
- existing direct-submit happy path still passes
|
||||
|
||||
### Browser-script helper safety
|
||||
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
Expected: current browser-script execution semantics remain intact while returning structured artifact payloads.
|
||||
|
||||
### Compatibility/build
|
||||
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
cargo build --bin sgclaw
|
||||
```
|
||||
|
||||
Expected: no regressions in compat execution/config loading; main binary builds cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Notes For The Engineer
|
||||
|
||||
- The paired spec is `docs/superpowers/specs/2026-04-10-fault-details-full-skill-alignment-design.md`.
|
||||
- Keep all fault business transforms in `skill_staging`, not in Rust.
|
||||
- Keep direct routing config-owned via `skillsDir` + `directSubmitSkill`.
|
||||
- Do **not** broaden this slice into LLM routing, generic dispatch policy, new browser opcodes, or export auto-open behavior.
|
||||
- If the original package reveals extra classification rules that are needed for parity, add them only inside `collect_fault_details.js` and its staged references/tests, not in `claw-new`.
|
||||
@@ -0,0 +1,808 @@
|
||||
# TQ Lineloss Deterministic Skill Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a staged `tq-lineloss-report.collect_lineloss` browser-script skill plus a `。。。` deterministic submit path in `claw-new` that extracts and normalizes company/month/week parameters without LLM, executes through the existing pipe browser-script seam, and does not regress Zhihu hotlist behavior.
|
||||
|
||||
**Architecture:** Keep the new behavior behind a narrow deterministic branch that activates only when the raw instruction ends with the exact suffix `。。。`. `claw-new` owns deterministic trigger detection, explicit scene matching, semantic extraction, canonical normalization, prompt-or-execute control flow, and artifact interpretation; the staged skill owns page inspection, source/API collection, row normalization, export/report-log behavior, and final artifact generation. Reuse the existing `browser_script` execution seam already used by the direct browser path so the backend can later swap from pipe to ws without changing the deterministic contract.
|
||||
|
||||
**Tech Stack:** Rust 2021, Cargo tests, existing `BrowserPipeTool` / `execute_browser_script_tool` seam, staged skill packaging under `claw/claw/skills/skill_staging`, browser-side JavaScript, deterministic string parsing and normalization.
|
||||
|
||||
---
|
||||
|
||||
## Execution Context
|
||||
|
||||
- Follow @superpowers:test-driven-development for every behavior change.
|
||||
- Follow @superpowers:verification-before-completion before claiming each task is done.
|
||||
- Do **not** create a git worktree unless the user explicitly asks.
|
||||
- Keep the new behavior as a narrow branch; do **not** redesign the whole runtime into a general registry engine in this slice.
|
||||
- Preserve `src/runtime/engine.rs:147-159` and `src/runtime/engine.rs:265-286` behavior unless a failing regression test proves a change is required.
|
||||
- Do **not** add ws runtime requirements on `main`; keep ws-readiness isolated to backend-neutral contracts only.
|
||||
- Never fall back to page defaults for missing company, mode, or period in deterministic mode.
|
||||
- If a deterministic request does not match the lineloss whitelist scene, return a deterministic mismatch prompt instead of falling through to ordinary orchestration.
|
||||
|
||||
## File Map
|
||||
|
||||
### New or modified files in `claw-new`
|
||||
|
||||
- Create: `src/compat/deterministic_submit.rs`
|
||||
- suffix detection, deterministic scene match, prompt-or-execute decision
|
||||
- Create: `src/compat/tq_lineloss/mod.rs`
|
||||
- public normalization and artifact helpers
|
||||
- Create: `src/compat/tq_lineloss/contracts.rs`
|
||||
- canonical request/result data structures and status semantics
|
||||
- Create: `src/compat/tq_lineloss/org_resolver.rs`
|
||||
- alias generation, canonical label/code resolution, ambiguity handling
|
||||
- Create: `src/compat/tq_lineloss/period_resolver.rs`
|
||||
- month/week extraction, contradiction detection, canonical payload building
|
||||
- Create: `src/compat/tq_lineloss/org_units.rs`
|
||||
- checked-in canonical unit dictionary derived from the real source tree data
|
||||
- Modify: `src/compat/mod.rs`
|
||||
- export the deterministic and lineloss modules
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- insert the deterministic branch before ordinary LLM interpretation, but only when the exact suffix is present
|
||||
- Modify only if code duplication would otherwise occur: `src/compat/direct_skill_runtime.rs`
|
||||
- extract narrow shared browser-script execution helpers without changing current configured direct-submit behavior
|
||||
- Read but avoid changing unless tests force it: `src/runtime/engine.rs`
|
||||
- existing Zhihu hotlist routing/prompt logic must remain intact
|
||||
|
||||
### New staged skill package in `claw`
|
||||
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.md`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.toml`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/collection-flow.md`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/data-quality.md`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/assets/scene-snapshot/index.html`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js`
|
||||
- Create if staging conventions require it: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json`
|
||||
|
||||
### Tests
|
||||
|
||||
- Create: `tests/deterministic_submit_test.rs`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Modify only if end-to-end submit coverage requires it: `tests/runtime_task_flow_test.rs`
|
||||
|
||||
---
|
||||
|
||||
## Locked contracts
|
||||
|
||||
### Deterministic trigger contract
|
||||
|
||||
- Trigger only when the raw instruction ends with the exact suffix `。。。`.
|
||||
- No suffix: current behavior unchanged.
|
||||
- Suffix + unsupported scene: explicit deterministic mismatch prompt.
|
||||
- Suffix is not permission for arbitrary browser actions; only fixed deterministic scenes are allowed.
|
||||
- Negative cases must stay non-deterministic or mismatched exactly as designed:
|
||||
- ASCII `...` is not the trigger
|
||||
- `。。。。` is not the trigger
|
||||
- `。。。` appearing in the middle of the instruction is not the trigger
|
||||
- any trailing whitespace after `。。。` is not the trigger in this slice
|
||||
|
||||
### Canonical org contract
|
||||
|
||||
The resolver must output both display and backend values:
|
||||
|
||||
```rust
|
||||
pub struct ResolvedOrg {
|
||||
pub label: String,
|
||||
pub code: String,
|
||||
}
|
||||
```
|
||||
|
||||
Required supported inputs include:
|
||||
- `兰州公司`
|
||||
- `天水公司`
|
||||
- `国网兰州供电公司`
|
||||
- `城关供电分公司`
|
||||
- `榆中县供电公司`
|
||||
- normalized shorthand such as `榆中县公司`
|
||||
|
||||
Rules:
|
||||
- derive aliases from the real unit tree data
|
||||
- require uniqueness before execution
|
||||
- ambiguous aliases prompt and stop
|
||||
- missing company prompts and stop
|
||||
|
||||
### Canonical period contract
|
||||
|
||||
```rust
|
||||
pub enum PeriodMode {
|
||||
Month,
|
||||
Week,
|
||||
}
|
||||
|
||||
pub struct ResolvedPeriod {
|
||||
pub mode: PeriodMode,
|
||||
pub mode_code: String,
|
||||
pub value: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
```
|
||||
|
||||
Required supported inputs include:
|
||||
- `月累计 2026-03`
|
||||
- `月累计 2026年3月`
|
||||
- `周累计 2026年第12周`
|
||||
|
||||
Rules:
|
||||
- month and week intent are mutually exclusive
|
||||
- missing mode prompts and stop
|
||||
- missing period prompts and stop
|
||||
- bare `第12周` is incomplete in this slice and must prompt for year instead of guessing
|
||||
- derive the real backend `period_mode_code` values and request payload field names from the source page/API contract before implementation; do not ship placeholder enum echoes such as `month`/`week` unless the source materials prove those are the real backend codes
|
||||
- never use page-selected defaults in deterministic mode
|
||||
|
||||
### Artifact contract
|
||||
|
||||
Lock the field names now so `claw-new` can interpret status without re-embedding business logic:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "report-artifact",
|
||||
"report_name": "tq-lineloss-report",
|
||||
"status": "ok",
|
||||
"org": {
|
||||
"label": "国网兰州供电公司",
|
||||
"code": "008df5db70319f73e0508eoac23e0c3c"
|
||||
},
|
||||
"period": {
|
||||
"mode": "month",
|
||||
"mode_code": "<real-backend-mode-code>",
|
||||
"value": "2026-03",
|
||||
"payload": {
|
||||
"<real-backend-field>": "<real-backend-value>"
|
||||
}
|
||||
},
|
||||
"columns": [],
|
||||
"rows": [],
|
||||
"counts": {
|
||||
"rows": 0
|
||||
},
|
||||
"export": {
|
||||
"attempted": false,
|
||||
"status": "skipped",
|
||||
"message": null
|
||||
},
|
||||
"reasons": []
|
||||
}
|
||||
```
|
||||
|
||||
Status mapping in `claw-new`:
|
||||
- `ok` -> task success
|
||||
- `partial` -> task success with partial summary
|
||||
- `blocked` -> task failure
|
||||
- `error` -> task failure
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold the staged skill package and written contract
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.md`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/SKILL.toml`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/collection-flow.md`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/references/data-quality.md`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/assets/scene-snapshot/index.html`
|
||||
- Create if required: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json`
|
||||
|
||||
- [ ] **Step 1: Write the failing package contract files**
|
||||
|
||||
Create the package using `fault-details-report` as the structure reference. Lock one tool only:
|
||||
|
||||
```toml
|
||||
[[tools]]
|
||||
name = "collect_lineloss"
|
||||
kind = "browser_script"
|
||||
description = "Collect 台区线损月/周累计线损率 rows using normalized company and period parameters and return a structured report artifact."
|
||||
```
|
||||
|
||||
Declare required args in `SKILL.toml`:
|
||||
- `expected_domain`
|
||||
- `org_label`
|
||||
- `org_code`
|
||||
- `period_mode`
|
||||
- `period_mode_code`
|
||||
- `period_value`
|
||||
- `period_payload`
|
||||
|
||||
- [ ] **Step 2: Write `SKILL.md` before implementation**
|
||||
|
||||
Document:
|
||||
- when to use / when not to use
|
||||
- required normalized args only
|
||||
- blocked/error semantics
|
||||
- exact returned artifact fields
|
||||
- no raw natural-language values passed to backend requests
|
||||
|
||||
- [ ] **Step 3: Write the reference docs**
|
||||
|
||||
`references/collection-flow.md` must describe:
|
||||
- relevant page state
|
||||
- month request mapping
|
||||
- week request mapping
|
||||
- export/report-log flow if retained
|
||||
|
||||
`references/data-quality.md` must define:
|
||||
- canonical output columns
|
||||
- required field coverage
|
||||
- status semantics
|
||||
- partial/error rules
|
||||
- org/period normalization assumptions
|
||||
|
||||
- [ ] **Step 4: Add scene metadata if the current staging registry needs it**
|
||||
|
||||
Keep it narrow: one scene, one tool, one artifact type.
|
||||
|
||||
- [ ] **Step 5: Add an automated staged-skill load/resolve check**
|
||||
|
||||
Add `tests/deterministic_submit_test.rs` coverage that loads the staged skills root used by runtime tests, resolves `tq-lineloss-report.collect_lineloss`, and asserts the tool is discoverable with the required args:
|
||||
- `expected_domain`
|
||||
- `org_label`
|
||||
- `org_code`
|
||||
- `period_mode`
|
||||
- `period_mode_code`
|
||||
- `period_value`
|
||||
- `period_payload`
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_submit_discovers_tq_lineloss_skill_contract -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL before the package is fully wired, PASS once the staged skill contract is discoverable and complete.
|
||||
|
||||
- [ ] **Step 6: Verify structural parity with `fault-details-report`**
|
||||
|
||||
Run a manual file-layout diff and confirm there are no placeholder descriptions or missing required docs.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report" "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/tq-lineloss-report/scene.json"
|
||||
git commit -m "feat: scaffold tq lineloss staged skill contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add browser-side JS red tests and implement the staged collector
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js`
|
||||
- Create: `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing JS tests first**
|
||||
|
||||
Cover deterministic pure helpers for:
|
||||
- missing normalized args -> blocked/error contract
|
||||
- month request shape uses `org_code` + canonical month payload
|
||||
- week request shape uses `org_code` + canonical week payload
|
||||
- artifact field names and counts
|
||||
- partial/error status shaping
|
||||
- no raw user-entered org text leakage into request fields
|
||||
|
||||
Example test skeleton:
|
||||
|
||||
```javascript
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
validateArgs,
|
||||
buildMonthRequest,
|
||||
buildWeekRequest,
|
||||
normalizeRows,
|
||||
buildArtifact
|
||||
} = require('./collect_lineloss.js');
|
||||
|
||||
test('buildMonthRequest uses canonical org code and month payload', () => {
|
||||
const request = buildMonthRequest({
|
||||
org_code: 'ORG-1',
|
||||
period_payload: { year: 2026, month: 3 }
|
||||
});
|
||||
|
||||
assert.equal(request.orgCode, 'ORG-1');
|
||||
assert.equal(request.year, 2026);
|
||||
assert.equal(request.month, 3);
|
||||
});
|
||||
|
||||
test('buildArtifact locks field names and partial semantics', () => {
|
||||
const artifact = buildArtifact({
|
||||
org_label: '国网兰州供电公司',
|
||||
org_code: 'ORG-1',
|
||||
period_mode: 'month',
|
||||
period_mode_code: 'month',
|
||||
period_value: '2026-03',
|
||||
period_payload: { year: 2026, month: 3 },
|
||||
rows: [{ id: 1 }],
|
||||
status: 'partial',
|
||||
reasons: ['export_failed']
|
||||
});
|
||||
|
||||
assert.equal(artifact.report_name, 'tq-lineloss-report');
|
||||
assert.equal(artifact.org.code, 'ORG-1');
|
||||
assert.equal(artifact.period.value, '2026-03');
|
||||
assert.deepEqual(artifact.reasons, ['export_failed']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the JS test file to confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||
```
|
||||
|
||||
Expected: FAIL because the script/helpers do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write the minimal browser-side implementation**
|
||||
|
||||
Required structure:
|
||||
|
||||
```javascript
|
||||
function validateArgs(args) { /* require normalized canonical args */ }
|
||||
function buildMonthRequest(args) { /* build month request from canonical values */ }
|
||||
function buildWeekRequest(args) { /* build week request from canonical values */ }
|
||||
function normalizeRows(rawRows) { /* canonical columns only */ }
|
||||
function buildArtifact(input) { /* locked artifact shape */ }
|
||||
|
||||
return (async () => {
|
||||
const args = __SKILL_ARGS__;
|
||||
validateArgs(args);
|
||||
// validate page context
|
||||
// collect from page/API
|
||||
// normalize rows
|
||||
// optionally attempt export/report-log if the real business flow requires it
|
||||
return buildArtifact(result);
|
||||
})();
|
||||
```
|
||||
|
||||
Keep test exports behind an environment-safe guard so the file still works as browser-eval code.
|
||||
|
||||
- [ ] **Step 4: Re-run the JS tests until they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.js" "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||
git commit -m "feat: add tq lineloss browser collection script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add deterministic suffix detection and explicit scene routing
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/deterministic_submit.rs`
|
||||
- Modify: `src/compat/mod.rs`
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Create: `tests/deterministic_submit_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing routing tests**
|
||||
|
||||
Add Rust tests for:
|
||||
- exact raw `。。。` suffix enables deterministic mode
|
||||
- no suffix leaves current routing untouched
|
||||
- suffix + unsupported deterministic request returns supported-scene prompt
|
||||
- when page URL/title context is available and does not match the lineloss scene, deterministic routing returns mismatch/block prompt instead of proceeding
|
||||
- Zhihu hotlist request without suffix keeps the current route
|
||||
- ASCII `...` does not trigger deterministic mode
|
||||
- `。。。。` does not trigger deterministic mode
|
||||
- `。。。` in the middle of the instruction does not trigger deterministic mode
|
||||
- trailing whitespace after `。。。` does not trigger deterministic mode in this slice
|
||||
|
||||
Suggested tests:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn deterministic_submit_requires_exact_suffix() {}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_nonmatch_returns_supported_scene_message() {}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_rejects_page_context_mismatch() {}
|
||||
|
||||
#[test]
|
||||
fn zhihu_hotlist_request_without_suffix_keeps_existing_route() {}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_rejects_non_exact_suffix_variants() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the targeted routing tests and confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_submit_requires_exact_suffix -- --exact
|
||||
cargo test deterministic_submit_nonmatch_returns_supported_scene_message -- --exact
|
||||
cargo test zhihu_hotlist_request_without_suffix_keeps_existing_route -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the deterministic routing seam does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the narrow deterministic routing module**
|
||||
|
||||
Recommended public shape:
|
||||
|
||||
```rust
|
||||
pub enum DeterministicSubmitDecision {
|
||||
NotDeterministic,
|
||||
Prompt { summary: String },
|
||||
Execute(DeterministicExecutionPlan),
|
||||
}
|
||||
```
|
||||
|
||||
`src/agent/mod.rs` should:
|
||||
1. detect deterministic suffix
|
||||
2. if not deterministic, continue current flow untouched
|
||||
3. if prompt, return `TaskComplete`
|
||||
4. if execute, pass the plan into the browser-script execution seam
|
||||
|
||||
- [ ] **Step 4: Re-run the routing tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_submit_requires_exact_suffix -- --exact
|
||||
cargo test deterministic_submit_nonmatch_returns_supported_scene_message -- --exact
|
||||
cargo test zhihu_hotlist_request_without_suffix_keeps_existing_route -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/deterministic_submit.rs src/compat/mod.rs src/agent/mod.rs tests/deterministic_submit_test.rs
|
||||
git commit -m "feat: add deterministic submit routing seam"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement company/unit normalization from real source data
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/tq_lineloss/mod.rs`
|
||||
- Create: `src/compat/tq_lineloss/contracts.rs`
|
||||
- Create: `src/compat/tq_lineloss/org_resolver.rs`
|
||||
- Create: `src/compat/tq_lineloss/org_units.rs`
|
||||
- Modify: `tests/deterministic_submit_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing org resolver tests**
|
||||
|
||||
Cover:
|
||||
- `兰州公司` -> canonical `国网兰州供电公司` + correct code
|
||||
- `天水公司` -> canonical `国网天水供电公司` + correct code
|
||||
- `城关供电分公司` -> lower-level direct match
|
||||
- `榆中县公司` -> normalized county alias match
|
||||
- ambiguous alias prompts instead of guessing
|
||||
- missing company prompts instead of executing
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn lineloss_org_resolver_matches_city_alias() {}
|
||||
|
||||
#[test]
|
||||
fn lineloss_org_resolver_matches_county_alias() {}
|
||||
|
||||
#[test]
|
||||
fn lineloss_org_resolver_prompts_on_ambiguity() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the org tests and confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test lineloss_org_resolver_matches_city_alias -- --exact
|
||||
cargo test lineloss_org_resolver_matches_county_alias -- --exact
|
||||
cargo test lineloss_org_resolver_prompts_on_ambiguity -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the resolver and checked-in unit dictionary do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Check in the canonical unit dictionary and implement alias resolution**
|
||||
|
||||
Rules:
|
||||
- derive data from the real source materials, not guessed literals
|
||||
- keep canonical `label` and `code`
|
||||
- generate normalized aliases from formal names
|
||||
- support both city-company and district/county/sub-company levels
|
||||
- require uniqueness before execution
|
||||
|
||||
- [ ] **Step 4: Implement explicit prompt messages**
|
||||
|
||||
Examples:
|
||||
- `已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。`
|
||||
- `已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。`
|
||||
|
||||
- [ ] **Step 5: Re-run the org tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test lineloss_org_resolver_matches_city_alias -- --exact
|
||||
cargo test lineloss_org_resolver_matches_county_alias -- --exact
|
||||
cargo test lineloss_org_resolver_prompts_on_ambiguity -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/tq_lineloss/mod.rs src/compat/tq_lineloss/contracts.rs src/compat/tq_lineloss/org_resolver.rs src/compat/tq_lineloss/org_units.rs tests/deterministic_submit_test.rs
|
||||
git commit -m "feat: add tq lineloss org normalization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement period extraction and canonical payload building
|
||||
|
||||
**Files:**
|
||||
- Create: `src/compat/tq_lineloss/period_resolver.rs`
|
||||
- Modify: `src/compat/tq_lineloss/mod.rs`
|
||||
- Modify: `tests/deterministic_submit_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing period resolver tests**
|
||||
|
||||
Cover:
|
||||
- `月累计 2026-03`
|
||||
- `月累计 2026年3月`
|
||||
- `周累计 2026年第12周`
|
||||
- contradictory month/week expressions prompt
|
||||
- missing mode prompts
|
||||
- missing period prompts
|
||||
- bare `第12周` prompts for year in this slice
|
||||
- real backend month/week mode codes and request payload field names are derived from source materials instead of placeholder values
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn lineloss_period_resolver_parses_month_text() {}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_parses_week_text() {}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_prompts_for_missing_year_on_week() {}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_rejects_contradictory_mode() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the period tests and confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test lineloss_period_resolver_parses_month_text -- --exact
|
||||
cargo test lineloss_period_resolver_parses_week_text -- --exact
|
||||
cargo test lineloss_period_resolver_prompts_for_missing_year_on_week -- --exact
|
||||
cargo test lineloss_period_resolver_rejects_contradictory_mode -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the period resolver does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal resolver**
|
||||
|
||||
Output contract:
|
||||
|
||||
```rust
|
||||
pub struct ResolvedPeriod {
|
||||
pub mode: PeriodMode,
|
||||
pub mode_code: String,
|
||||
pub value: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- no page-default fallback
|
||||
- no implicit current-year assumptions
|
||||
- no mixed month/week execution
|
||||
|
||||
- [ ] **Step 4: Re-run the period tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test lineloss_period_resolver_parses_month_text -- --exact
|
||||
cargo test lineloss_period_resolver_parses_week_text -- --exact
|
||||
cargo test lineloss_period_resolver_prompts_for_missing_year_on_week -- --exact
|
||||
cargo test lineloss_period_resolver_rejects_contradictory_mode -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/tq_lineloss/period_resolver.rs src/compat/tq_lineloss/mod.rs tests/deterministic_submit_test.rs
|
||||
git commit -m "feat: add tq lineloss period normalization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire deterministic execution through the existing browser-script seam
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/compat/deterministic_submit.rs`
|
||||
- Modify: `src/agent/mod.rs`
|
||||
- Modify if needed: `src/compat/direct_skill_runtime.rs`
|
||||
- Modify: `tests/deterministic_submit_test.rs`
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing execution tests**
|
||||
|
||||
Cover:
|
||||
- successful deterministic lineloss request builds canonical tool args
|
||||
- missing company/mode/period returns prompt without browser execution
|
||||
- `partial` artifact maps to successful partial summary
|
||||
- `blocked` and `error` artifacts map to failed completion
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn deterministic_lineloss_execution_passes_canonical_args() {}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_missing_company_does_not_invoke_browser() {}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_partial_artifact_maps_to_partial_summary() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the execution tests and confirm failure**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_lineloss_execution_passes_canonical_args -- --exact
|
||||
cargo test deterministic_lineloss_missing_company_does_not_invoke_browser -- --exact
|
||||
cargo test deterministic_lineloss_partial_artifact_maps_to_partial_summary -- --exact
|
||||
```
|
||||
|
||||
Expected: FAIL because the deterministic execution plan is not wired yet.
|
||||
|
||||
- [ ] **Step 3: Implement execution via the existing `browser_script` seam**
|
||||
|
||||
Build tool args only from normalized values:
|
||||
- `expected_domain`
|
||||
- `org_label`
|
||||
- `org_code`
|
||||
- `period_mode`
|
||||
- `period_mode_code`
|
||||
- `period_value`
|
||||
- `period_payload`
|
||||
|
||||
Resolve the tool explicitly to:
|
||||
- `tq-lineloss-report.collect_lineloss`
|
||||
|
||||
Do not introduce a new browser opcode family or second browser protocol.
|
||||
|
||||
- [ ] **Step 4: Implement central artifact interpretation**
|
||||
|
||||
Recommended helper:
|
||||
|
||||
```rust
|
||||
fn summarize_lineloss_artifact(artifact: &serde_json::Value) -> (bool, String)
|
||||
```
|
||||
|
||||
Summary must include canonical org/period and row counts, and surface blocked/partial/error reasons.
|
||||
|
||||
- [ ] **Step 5: Re-run the execution tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test deterministic_lineloss_execution_passes_canonical_args -- --exact
|
||||
cargo test deterministic_lineloss_missing_company_does_not_invoke_browser -- --exact
|
||||
cargo test deterministic_lineloss_partial_artifact_maps_to_partial_summary -- --exact
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/compat/deterministic_submit.rs src/agent/mod.rs src/compat/direct_skill_runtime.rs tests/deterministic_submit_test.rs tests/compat_runtime_test.rs
|
||||
git commit -m "feat: execute deterministic tq lineloss skill through browser script seam"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add Zhihu regression coverage and run the full verification set
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/compat_runtime_test.rs`
|
||||
- Modify only if required: `tests/runtime_task_flow_test.rs`
|
||||
- Reuse: `tests/deterministic_submit_test.rs`
|
||||
|
||||
- [ ] **Step 1: Add focused Zhihu regression tests**
|
||||
|
||||
Required assertions:
|
||||
- ordinary Zhihu hotlist requests without `。。。` still use the current path
|
||||
- existing export/presentation requests still preserve their current behavior
|
||||
- deterministic suffix does not silently route unmatched requests into Zhihu logic
|
||||
- an existing non-lineloss direct `browser_script` path outside the new scene still behaves unchanged
|
||||
|
||||
- [ ] **Step 2: Add end-to-end deterministic submit coverage**
|
||||
|
||||
Required assertions:
|
||||
- suffix detection
|
||||
- scene match
|
||||
- page-context mismatch prompt/block behavior when URL/title contradict the lineloss scene
|
||||
- missing/ambiguous prompts
|
||||
- canonical args passed to the browser-script tool
|
||||
- returned summary shows canonical org and period
|
||||
- execution stays on the existing pipe-backed browser-script seam with no ws-only dependency introduced on `main`
|
||||
|
||||
- [ ] **Step 3: Run the focused Rust tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test deterministic_submit_test
|
||||
cargo test --test compat_runtime_test
|
||||
cargo test --test runtime_task_flow_test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the whole Rust suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Re-run the staged skill JS tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node --test "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/tq-lineloss-report/scripts/collect_lineloss.test.js"
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/deterministic_submit_test.rs tests/compat_runtime_test.rs tests/runtime_task_flow_test.rs
|
||||
git commit -m "test: cover deterministic tq lineloss routing and zhihu regression"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification checklist
|
||||
|
||||
- [ ] `。。。` is the only deterministic trigger.
|
||||
- [ ] Non-`。。。` requests preserve current routing.
|
||||
- [ ] Deterministic page-context mismatch blocks or mismatches before execution when URL/title contradict the lineloss scene.
|
||||
- [ ] Zhihu hotlist behavior is unchanged.
|
||||
- [ ] Existing non-lineloss direct `browser_script` behavior is unchanged.
|
||||
- [ ] Deterministic non-match returns an explicit supported-scene message.
|
||||
- [ ] Missing company prompts.
|
||||
- [ ] Ambiguous company prompts.
|
||||
- [ ] Missing mode prompts.
|
||||
- [ ] Missing period prompts.
|
||||
- [ ] Bare `第12周` prompts for year.
|
||||
- [ ] Canonical org code is passed to the staged skill.
|
||||
- [ ] Canonical period mode code and payload are passed to the staged skill.
|
||||
- [ ] The staged skill returns the locked artifact shape.
|
||||
- [ ] Execution uses the existing `browser_script` seam only.
|
||||
- [ ] No ws-specific runtime dependency is added on `main`.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Prefer extracting a tiny shared execution helper from `src/compat/direct_skill_runtime.rs` if needed instead of duplicating tool lookup or browser-script invocation code.
|
||||
- Keep deterministic whitelist configuration in one place, but do not expand this slice into a full general scene-registry redesign.
|
||||
- If a failing test suggests changing Zhihu behavior, fix the deterministic branch or test harness instead of weakening the existing Zhihu path.
|
||||
- The checked-in unit dictionary is part of the deterministic contract; treat updates to that data as explicit behavior changes and cover them with tests.
|
||||
@@ -1,506 +0,0 @@
|
||||
# WS 浏览器后端认证替换设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `sg_claw` 的 websocket service 路径已经能接收 `sg_claw_client` 请求、复用共享 task runner、连接真实浏览器 websocket 地址 `browser_ws_url`,并进入真实 skill 执行链路。但真实联调时,所有浏览器相关调用都会失败并返回:
|
||||
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
|
||||
根因已经定位:
|
||||
|
||||
- pipe 模式在 [src/lib.rs](src/lib.rs) 中通过 handshake 拿到 `session_key`,并用它构造 `BrowserPipeTool`
|
||||
- ws service 模式在 [src/service/server.rs](src/service/server.rs) 中仍然构造 `BrowserPipeTool::new(..., vec![])`
|
||||
- `BrowserPipeTool` 的认证模型要求非空 session key,因此 ws service 路径虽然使用的是浏览器 websocket 协议,仍错误地依赖了 pipe 特有的 HMAC/session-key 语义
|
||||
|
||||
这会导致:
|
||||
|
||||
1. `sg_claw_client -> sg_claw` 连接正常
|
||||
2. skill 加载与模型调用正常
|
||||
3. 真实浏览器动作开始执行
|
||||
4. 但所有 browser tool 调用在认证层统一失败
|
||||
|
||||
## 目标
|
||||
|
||||
在 **仅限 ws 模式改动** 的前提下,让 `sg_claw` service 路径改为使用 **ws-native browser backend**,不再依赖 `BrowserPipeTool` 的 pipe session-key 认证模型,从而让真实浏览器联调可用。
|
||||
|
||||
## 约束
|
||||
|
||||
必须满足:
|
||||
|
||||
- 只改 ws 模式相关实现
|
||||
- 不破坏 legacy pipe 模式
|
||||
- 不修改 pipe handshake 语义
|
||||
- 不修改 `src/lib.rs` 的 pipe 主入口行为
|
||||
- 不引入临时绕过认证或 fake seed
|
||||
- 不扩大到多客户端、多任务、队列、守护进程管理
|
||||
|
||||
## 非目标
|
||||
|
||||
本次不做:
|
||||
|
||||
- 自动拉起 sgBrowser
|
||||
- 浏览器进程管理
|
||||
- 多浏览器实例支持
|
||||
- service/client UX 优化
|
||||
- browser ws 协议扩展
|
||||
- pipe 模式重构
|
||||
- 统一重构所有 runtime 层去完全依赖 `BrowserBackend`
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 正常 pipe 路径
|
||||
|
||||
pipe 模式当前在 [src/lib.rs](src/lib.rs) 中:
|
||||
|
||||
1. 通过 `perform_handshake(...)` 读取浏览器侧初始化消息
|
||||
2. 从 handshake 中拿到 `session_key`
|
||||
3. 用 `BrowserPipeTool::new(transport.clone(), mac_policy, handshake.session_key)` 构造浏览器工具
|
||||
4. 后续 browser action 使用 pipe/HMAC 语义
|
||||
|
||||
该路径已经可用,本次不能动。
|
||||
|
||||
### 当前 ws service 路径
|
||||
|
||||
当前 ws 模式在 [src/service/server.rs](src/service/server.rs) 中:
|
||||
|
||||
1. `sg_claw_client` 将任务发给 `sg_claw` service
|
||||
2. service 构造 `ServiceBrowserTransport`
|
||||
3. service 用 `BrowserPipeTool::new(transport.clone(), mac_policy.clone(), vec![])`
|
||||
4. browser action 经 `ServiceBrowserTransport` 编码为 browser websocket 请求并发给 `browser_ws_url`
|
||||
|
||||
问题在于第 3 步:
|
||||
|
||||
- service 走的是 browser websocket 协议
|
||||
- 但却仍使用 `BrowserPipeTool`
|
||||
- `BrowserPipeTool` 内部仍坚持要求 pipe session key
|
||||
- 因此真实 ws 联调时直接失败
|
||||
|
||||
### 现有 ws-native 能力
|
||||
|
||||
代码中已经存在:
|
||||
|
||||
- [src/browser/ws_protocol.rs](src/browser/ws_protocol.rs):固定 browser websocket 协议 codec
|
||||
- [src/browser/ws_backend.rs](src/browser/ws_backend.rs):`WsBrowserBackend`
|
||||
- [src/browser/mod.rs](src/browser/mod.rs):已导出 `WsBrowserBackend`
|
||||
|
||||
`WsBrowserBackend` 本身不依赖 pipe session key,而是:
|
||||
|
||||
- 使用 `WsClient` 发送/接收文本帧
|
||||
- 使用 `MacPolicy` 做动作级校验
|
||||
- 通过 `encode_v1_action(...)` 与 `decode_callback_frame(...)` 处理 ws 协议
|
||||
|
||||
这正是 ws service 模式应该使用的模型。
|
||||
|
||||
## 关键集成缝隙
|
||||
|
||||
当前共享 runner 的真实缝隙已经确认:
|
||||
|
||||
- [src/agent/task_runner.rs](src/agent/task_runner.rs) 的 `run_submit_task(...)` 仍直接要求 `&BrowserPipeTool<T>`
|
||||
- [src/compat/runtime.rs](src/compat/runtime.rs) 与 [src/compat/orchestration.rs](src/compat/orchestration.rs) 也继续以 `BrowserPipeTool<T>` 作为主浏览器调用对象
|
||||
- 同时 compat runtime 内部已经存在 `Arc<dyn BrowserBackend>` 的工具适配层,只是它目前是从 `PipeBrowserBackend::from_inner(browser_tool)` 包出来的
|
||||
|
||||
这意味着本次实现不能只在 `src/service/server.rs` 里替换构造逻辑,而必须在 **ws 专用调用面** 增加一个最小适配缝隙,让 service 模式能把 `WsBrowserBackend` 传入 compat/runtime/orchestration,而 pipe 继续保持 `BrowserPipeTool` 原样。
|
||||
|
||||
允许的最小缝隙定义如下:
|
||||
|
||||
1. `run_submit_task(...)` 的 pipe 版本保持不动,供 pipe 入口继续使用
|
||||
2. 新增一个 **仅供 ws service 使用** 的并行入口,例如:
|
||||
- `run_submit_task_with_browser_backend(...)`
|
||||
- 或 service 侧调用的等价 ws-only adapter
|
||||
3. ws-only 入口内部允许把浏览器依赖类型降到 `Arc<dyn BrowserBackend>`
|
||||
4. `src/lib.rs`、pipe handshake、pipe `BrowserPipeTool` 构造逻辑不允许改行为
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 决策 1:ws service 路径弃用 `BrowserPipeTool`
|
||||
|
||||
在 ws service 路径中,不再构造 `BrowserPipeTool`。
|
||||
|
||||
替代方案:
|
||||
|
||||
- service 侧提供一个 `WsClient` 实现
|
||||
- 直接构造 `WsBrowserBackend`
|
||||
- 让 ws service 的 browser action 通过 `WsBrowserBackend` 执行
|
||||
|
||||
### 决策 2:pipe 路径保持原样
|
||||
|
||||
pipe 模式继续:
|
||||
|
||||
- handshake
|
||||
- `session_key`
|
||||
- `BrowserPipeTool`
|
||||
|
||||
不做语义调整,不引入兼容层,不改动已存在的验证路径。
|
||||
|
||||
### 决策 3:runner 只在 ws 调用面做最小接线
|
||||
|
||||
当前共享 task runner 复用已经存在,本次不做大重构。
|
||||
|
||||
策略是:
|
||||
|
||||
- 只在 ws service 用到的调用面,改成可使用 `WsBrowserBackend`
|
||||
- 如果必须扩共享调用接口,则仅做**最小、兼容、对 pipe 零影响**的改动
|
||||
- 任何涉及 pipe 行为变更的改动都不允许
|
||||
|
||||
### 决策 4:保留现有 browser websocket 连接生命周期
|
||||
|
||||
本次不重做连接管理架构。
|
||||
|
||||
继续维持:
|
||||
|
||||
- 单客户端
|
||||
- 单任务串行
|
||||
- 按现有 service 生命周期维护 browser websocket 连接
|
||||
|
||||
只替换认证错误的执行路径,不顺手做生命周期优化。
|
||||
|
||||
## 目标架构
|
||||
|
||||
### 目标调用链
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service
|
||||
-> ws-native browser backend
|
||||
-> browser_ws_url
|
||||
-> sgBrowser
|
||||
```
|
||||
|
||||
### 与 pipe 的并行关系
|
||||
|
||||
```text
|
||||
pipe mode:
|
||||
browser process <-> stdio/pipe <-> sgclaw::run() <-> BrowserPipeTool
|
||||
|
||||
ws mode:
|
||||
sg_claw_client <-> sg_claw service <-> WsBrowserBackend <-> sgBrowser websocket
|
||||
```
|
||||
|
||||
两条路径并行存在,互不混用认证模型。
|
||||
|
||||
## 模块设计
|
||||
|
||||
### 1. `src/service/server.rs`
|
||||
|
||||
这是本次核心改动文件。
|
||||
|
||||
#### 当前职责
|
||||
|
||||
- 管理 service client websocket 收发
|
||||
- 将 service 请求转入共享 runner
|
||||
- 维护 service->browser 的 websocket 传输桥
|
||||
|
||||
#### 本次改动
|
||||
|
||||
- 将“service->browser 的桥”从 `Transport + BrowserPipeTool` 组合改为 `WsClient + WsBrowserBackend`
|
||||
- 删除 ws service 路径中对空 `session_key` 的依赖
|
||||
- 继续保留 service socket 生命周期与 session 状态机
|
||||
|
||||
#### 目标结构
|
||||
|
||||
可接受的目标形态:
|
||||
|
||||
- `ServiceBrowserWsClient`:实现 `WsClient`
|
||||
- 内部继续维护真实 browser websocket 连接
|
||||
- `serve_client(...)` 在处理任务时构造 `WsBrowserBackend`
|
||||
- 共享 runner 或其 ws 调用包装层通过该 backend 执行 browser action
|
||||
|
||||
### 2. 共享 runner / ws 调用包装层
|
||||
|
||||
本次不要求把全项目统一改成 `BrowserBackend`。
|
||||
|
||||
但 ws service 模式必须能把 browser action 接到 `WsBrowserBackend`。
|
||||
|
||||
可接受的最小方案:
|
||||
|
||||
- 在 ws service 使用的一层引入一个只服务 ws 模式的 adapter
|
||||
- 该 adapter 把 runner 所需的 browser 调用能力委托给 `WsBrowserBackend`
|
||||
|
||||
要求:
|
||||
|
||||
- pipe 现有调用签名不变,或即使扩展也必须保证 pipe 行为完全一致
|
||||
- 不允许为了 ws 把 pipe 入口重写
|
||||
|
||||
### 3. `src/browser/ws_backend.rs`
|
||||
|
||||
原则上复用现有实现。
|
||||
|
||||
只有在以下情况下才允许最小补改:
|
||||
|
||||
- service 真实联调发现它缺一个 ws service 必需但当前未暴露的能力
|
||||
- 该补改只服务 ws-native 路径
|
||||
- 不影响现有测试语义
|
||||
|
||||
## 连接职责与边界
|
||||
|
||||
为避免 service 侧与 `WsBrowserBackend` 重复实现责任,本次显式约束如下:
|
||||
|
||||
### `WsBrowserBackend` 负责
|
||||
|
||||
- 单次 `invoke(...)` 的请求串行化
|
||||
- 调用 `encode_v1_action(...)`
|
||||
- 发送 websocket 文本帧
|
||||
- 等待即时状态帧
|
||||
- 如有 callback,等待 callback 帧并做名称匹配
|
||||
- 将结果统一为 `CommandOutput`
|
||||
- 按现有 `WsBrowserBackend` 语义产出 timeout / protocol 错误
|
||||
|
||||
### service 侧 `WsClient` 适配器负责
|
||||
|
||||
- 持有真实 browser websocket 连接
|
||||
- 在第一次请求时建立到 `browser_ws_url` 的连接
|
||||
- 把 `send_text(...)` / `recv_text_timeout(...)` 委托到真实 websocket
|
||||
- 将底层关闭、reset、timeout 统一映射为既有 `PipeError` 语义
|
||||
- 不实现 request/response correlation,不解析 browser ws 协议 payload
|
||||
|
||||
### 明确不允许
|
||||
|
||||
- service 侧继续手写 callback 轮询逻辑
|
||||
- service 侧继续直接调用 `encode_v1_action(...)` 组包作为主路径
|
||||
- 在 service 侧复制 `WsBrowserBackend` 的协议处理逻辑
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- `src/service/server.rs` 只负责“连线”
|
||||
- `src/browser/ws_backend.rs` 继续负责“ws 浏览器调用语义”
|
||||
|
||||
## 数据流设计
|
||||
|
||||
### 成功路径
|
||||
|
||||
1. `sg_claw_client` 向 `sg_claw` 发 `SubmitTask`
|
||||
2. service 收到任务并进入共享 runner
|
||||
3. 当 runner 需要浏览器动作时:
|
||||
- ws service 调用 `WsBrowserBackend.invoke(...)`
|
||||
4. `WsBrowserBackend`:
|
||||
- 用 `MacPolicy` 校验动作
|
||||
- 用 `encode_v1_action(...)` 编码请求
|
||||
- 发往 `browser_ws_url`
|
||||
- 等待状态帧
|
||||
- 如有 callback,继续等 callback 帧
|
||||
5. 结果返回到 runner
|
||||
6. runner 继续执行并向 client 流式输出日志和 completion
|
||||
|
||||
### 失败路径
|
||||
|
||||
#### browser websocket 不可连
|
||||
|
||||
- 返回明确的 browser websocket connect 错误
|
||||
- 不冒充认证错误
|
||||
|
||||
#### 浏览器返回非 0 状态
|
||||
|
||||
- 返回明确协议错误:`browser returned non-zero status`
|
||||
|
||||
#### callback 超时
|
||||
|
||||
- 返回 timeout
|
||||
|
||||
#### websocket 断开
|
||||
|
||||
- 返回 `PipeError::PipeClosed`
|
||||
- 由 service 生命周期逻辑处理
|
||||
|
||||
#### 不再允许的错误
|
||||
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
|
||||
该错误在 ws 模式下应彻底消失。
|
||||
|
||||
## 失败语义
|
||||
|
||||
为便于测试与实现,ws-only 路径的 outward error 语义固定如下:
|
||||
|
||||
### browser websocket connect 失败
|
||||
|
||||
- outward: `PipeError::Protocol("browser websocket connect failed: ...")`
|
||||
|
||||
### 浏览器返回非 0 状态码
|
||||
|
||||
- outward: `PipeError::Protocol("browser returned non-zero status: ...")`
|
||||
|
||||
### callback 超时
|
||||
|
||||
- outward: `PipeError::Timeout`
|
||||
- timeout 来源:沿用 `WsBrowserBackend` / ws service 当前 response timeout 配置,默认 30 秒
|
||||
|
||||
### websocket 被对端正常关闭或 reset
|
||||
|
||||
- outward: `PipeError::PipeClosed`
|
||||
- 不允许使用“等价错误”这类不精确表述
|
||||
|
||||
### 本次必须消除的错误
|
||||
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
|
||||
任何 ws service 联调路径再出现该错误,都视为实现未完成。
|
||||
|
||||
## 测试设计
|
||||
|
||||
### 分层测试策略
|
||||
|
||||
为避免依赖 LLM/planner 的非确定性行为,本次测试必须分成两层,且各自断言不同目标:
|
||||
|
||||
#### A. backend / adapter 层测试(确定性)
|
||||
|
||||
这一层不经过 `sg_claw_client`、不经过真实模型规划,直接验证 ws-only 技术行为。
|
||||
|
||||
目标:
|
||||
|
||||
1. `ServiceBrowserWsClient` 与 `WsBrowserBackend` 的组合可以:
|
||||
- 发送 `Navigate`
|
||||
- 接收 `0` 状态
|
||||
- 在 callback 场景下读取 callback 文本
|
||||
2. 当 fake browser server 主动关闭/reset 时:
|
||||
- 在 `WsClient` / `WsBrowserBackend.invoke(...)` 观察层断言 outward error 必须是 `PipeError::PipeClosed`
|
||||
3. 当 fake browser server 不返回 callback 时:
|
||||
- 在 `WsBrowserBackend.invoke(...)` 观察层断言 outward error 必须是 `PipeError::Timeout`
|
||||
4. 该层测试完全不依赖 LLM、planner、skills 路由
|
||||
|
||||
建议:
|
||||
|
||||
- 新增 focused ws service/backend test
|
||||
- 输入动作固定为代码直接调用 `invoke(Action::Navigate, ...)` 等,而不是自然语言任务
|
||||
|
||||
#### B. client -> service 集成测试(链路验证)
|
||||
|
||||
这一层验证 ws-only 接线已经替换掉空 session key 路径,但不承担细粒度协议语义断言。
|
||||
|
||||
目标:
|
||||
|
||||
1. 通过真实 `sg_claw_client -> sg_claw service` 发起一个最小自然语言任务
|
||||
2. fake browser websocket server 至少收到一个来自 ws-only 路径的文本帧
|
||||
3. client/service 输出中不再出现:
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
4. 该层只证明:
|
||||
- ws service 已不再走空 session key 的 pipe 认证路径
|
||||
- 真实端到端链路已能到达 browser websocket
|
||||
|
||||
该层不用于断言精确 enum 身份,也不用于覆盖 callback timeout / reset 细节。
|
||||
|
||||
### 新增红测 1:ws-only backend/adapter 基本调用可用
|
||||
|
||||
目标:
|
||||
|
||||
- 不走自然语言任务
|
||||
- 直接构造 ws service 使用的 `WsClient` + `WsBrowserBackend`
|
||||
- 调用固定动作:`Action::Navigate`,目标 url 固定为 `https://www.zhihu.com/hot`
|
||||
- fake browser websocket server 返回 `0`
|
||||
- 断言:
|
||||
- `invoke(...)` 成功
|
||||
- fake server 收到的首个文本帧可按 `ws_protocol` 语义解释为 `Navigate`
|
||||
|
||||
### 新增红测 2:ws-only backend/adapter 断链语义固定
|
||||
|
||||
目标:
|
||||
|
||||
- 不走自然语言任务
|
||||
- fake browser websocket server 在接受请求后主动关闭或 reset
|
||||
- 在 `invoke(...)` 观察层断言:
|
||||
- outward error 固定为 `PipeError::PipeClosed`
|
||||
|
||||
### 新增红测 3:ws-only backend/adapter callback timeout 语义固定
|
||||
|
||||
目标:
|
||||
|
||||
- 不走自然语言任务
|
||||
- fake browser websocket server 返回 `0` 但不返回 callback 帧
|
||||
- 在 `invoke(...)` 观察层断言:
|
||||
- outward error 固定为 `PipeError::Timeout`
|
||||
|
||||
### 新增红测 4:client->service 链路不再触发空 session key 错误
|
||||
|
||||
目标:
|
||||
|
||||
- 通过真实 `sg_claw_client -> sg_claw service` 链路触发浏览器动作
|
||||
- 用 fake browser websocket 服务端接住请求
|
||||
- 任务输入固定为:`打开知乎热榜并读取页面主区域文本`
|
||||
- 断言 client/service 输出中不再出现:
|
||||
- `invalid hmac seed: session key must not be empty`
|
||||
- 断言 fake browser server 至少收到了一个文本帧
|
||||
|
||||
### 回归测试
|
||||
|
||||
必须重新运行并保持通过:
|
||||
|
||||
#### pipe 回归
|
||||
|
||||
```bash
|
||||
cargo test --test pipe_handshake_test -- --nocapture
|
||||
```
|
||||
|
||||
如实现涉及 browser tool 上层接线,还需补跑:
|
||||
|
||||
```bash
|
||||
cargo test --test browser_tool_test --test compat_browser_tool_test --test runtime_task_flow_test -- --nocapture
|
||||
```
|
||||
|
||||
#### ws 回归
|
||||
|
||||
```bash
|
||||
cargo test --test service_ws_session_test --test service_task_flow_test --test browser_ws_protocol_test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
## 手工验收
|
||||
|
||||
使用真实配置和真实已启动 sgBrowser:
|
||||
|
||||
1. 启动 sgBrowser,并确保 `browserWsUrl` 可用
|
||||
2. 启动 `sg_claw`
|
||||
3. 运行:
|
||||
- `sg_claw_client`
|
||||
4. 发送知乎最小任务:
|
||||
- 打开知乎热榜并读取页面主区域文本
|
||||
5. 观察:
|
||||
- 不再出现 `invalid hmac seed`
|
||||
- 出现真实 browser action 日志
|
||||
- 能返回单次 completion
|
||||
6. 再运行旧知乎 skill:
|
||||
- `读取知乎热榜数据,并导出 excel 文件`
|
||||
7. 验证旧知乎 skill 进入真实 browser 执行路径
|
||||
8. 最后确认 legacy pipe 入口仍可启动(仅验证,不允许为此修改 pipe 实现)
|
||||
|
||||
## 风险
|
||||
|
||||
### 风险 1:ws service 与共享 runner 接口耦合过深
|
||||
|
||||
控制:
|
||||
|
||||
- 只在 ws 使用面做 adapter
|
||||
- 不对 pipe 主入口做结构性改造
|
||||
|
||||
### 风险 2:为适配 ws-native backend 误改 pipe 调用链
|
||||
|
||||
控制:
|
||||
|
||||
- 所有 pipe 回归必须在每轮修改后重跑
|
||||
- `src/lib.rs` 不允许改行为
|
||||
|
||||
### 风险 3:ws service 内联连接逻辑与 `WsBrowserBackend` 责任重复
|
||||
|
||||
控制:
|
||||
|
||||
- 本次先以最小变更消除认证阻塞
|
||||
- 不顺手做大规模整理
|
||||
|
||||
## 通过标准
|
||||
|
||||
满足以下全部条件才算完成:
|
||||
|
||||
1. ws service 路径不再依赖空 session key
|
||||
2. 不再出现 `invalid hmac seed: session key must not be empty`
|
||||
3. 真实 browser websocket 请求能发到 sgBrowser/fake browser server
|
||||
4. 旧知乎 skill 至少能进入真实 browser action 执行链路
|
||||
5. pipe 模式零回归
|
||||
6. 所有新增/相关测试通过
|
||||
|
||||
## 实施建议
|
||||
|
||||
按以下顺序实施:
|
||||
|
||||
1. 先补红测,锁定“ws 不再触发 invalid hmac seed”
|
||||
2. 再把 ws service 路径切到 `WsBrowserBackend`
|
||||
3. 跑 ws 测试
|
||||
4. 跑 pipe 回归
|
||||
5. 做真实知乎最小任务 smoke
|
||||
6. 再做旧知乎 skill smoke
|
||||
@@ -1,276 +0,0 @@
|
||||
# WS Browser Bridge Path Design
|
||||
|
||||
## Background
|
||||
|
||||
The repository now has explicit live evidence that the real sgBrowser websocket endpoint at `ws://127.0.0.1:12345` is **reachable** but is **not validated as an external-control surface**.
|
||||
|
||||
The probe transcript in `docs/_tmp_sgbrowser_ws_probe_transcript.md` shows a stable outcome across the full bootstrap matrix:
|
||||
|
||||
- direct open-page frame
|
||||
- `sgOpenAgent`
|
||||
- `sgSetAuthInfo`
|
||||
- `sgBrowserLogin`
|
||||
- `sgBrowerserActiveTab`
|
||||
- combined bootstrap attempts
|
||||
- alternate `requesturl` values
|
||||
|
||||
Across all of those sequences, the endpoint behaved like this:
|
||||
|
||||
1. websocket connection succeeds
|
||||
2. first inbound text frame is always the banner `Welcome! You are client #1`
|
||||
3. no sequence produced a reproducible numeric status frame for a real business action
|
||||
4. no sequence produced a reproducible callback frame for a real business action
|
||||
5. follow-on business frames timed out or produced no further usable protocol traffic
|
||||
|
||||
That means the current project can no longer treat raw external websocket business frames as the default production integration surface.
|
||||
|
||||
## Why the raw websocket path is now considered non-validated
|
||||
|
||||
The decision is not based on a guess. It is based on both live evidence and repository evidence.
|
||||
|
||||
### Live evidence
|
||||
|
||||
`docs/_tmp_sgbrowser_ws_probe_transcript.md` proves that the real endpoint did **not** yield the one thing raw external control needs:
|
||||
|
||||
- a reproducible status/callback response for a real browser action
|
||||
|
||||
Because that never happened, the bootstrap hypothesis did not clear the acceptance bar.
|
||||
|
||||
### Repository evidence
|
||||
|
||||
The rest of the repository already points to a different product integration model.
|
||||
|
||||
#### 1. Historical frontend code uses browser-host bridge surfaces
|
||||
|
||||
In `frontend/archive/sgClaw验证-已归档/testRunner.js:15-26`:
|
||||
|
||||
- the runtime checks for `window.sgFunctionsUI`
|
||||
- the runtime checks for `window.BrowserAction`
|
||||
- the working path uses `window.sgFunctionsUI(action, params, callback)`
|
||||
|
||||
That is a host/browser bridge contract, not an external raw websocket RPC contract.
|
||||
|
||||
#### 2. Prior architecture docs make `CommandRouter` the execution entry
|
||||
|
||||
In `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md:16-18` and `:36-50`:
|
||||
|
||||
- reuse SuperRPA `CommandRouter` as the browser execution entry
|
||||
- keep browser-side hosting, security re-check, and dispatch in SuperRPA
|
||||
- avoid building parallel browser automation APIs
|
||||
|
||||
That is directly incompatible with treating raw external websocket business frames as the primary control plane.
|
||||
|
||||
#### 3. Project planning docs describe FunctionsUI IPC as the supported frontend seam
|
||||
|
||||
In `docs/archive/项目管理与排期/协作时间表.md:419-430`:
|
||||
|
||||
- Vue/FunctionsUI calls browser-host methods such as `window.superrpa.sgclaw.start()` and `sendCommand(...)`
|
||||
- browser host pushes callbacks such as `onStatusChange(...)` and `onLog(...)`
|
||||
|
||||
Again, this is a bridge and host IPC model.
|
||||
|
||||
#### 4. Floating-chat planning already preserves named bridge calls
|
||||
|
||||
In `docs/plans/2026-03-27-sgclaw-floating-chat-frontend-design.md:289-293`:
|
||||
|
||||
- `connect()` issues `sgclawConnect`
|
||||
- `start()` issues `sgclawStart`
|
||||
- `stop()` issues `sgclawStop`
|
||||
- `submitTask()` issues `sgclawSubmitTask`
|
||||
|
||||
That design work assumes a named browser bridge, not direct raw websocket frames.
|
||||
|
||||
## Decision
|
||||
|
||||
**Authoritative browser integration surface: the browser-host bridge path, not the raw external sgBrowser websocket business-frame path.**
|
||||
|
||||
More concretely, sgClaw should target this chain:
|
||||
|
||||
```text
|
||||
sgClaw runtime
|
||||
-> existing browser-facing bridge contract
|
||||
-> FunctionsUI / host IPC
|
||||
-> BrowserAction / sgclaw host callbacks
|
||||
-> existing SuperRPA CommandRouter dispatch
|
||||
```
|
||||
|
||||
## Authoritative seams for future implementation
|
||||
|
||||
Because this repository does not contain the full SuperRPA browser host source tree, the bridge-first implementation must integrate at the **nearest validated seam available in this repo**, while staying aligned with the external browser-host contract already documented.
|
||||
|
||||
The future implementation must model **two different bridge layers** explicitly instead of mixing them together.
|
||||
|
||||
### Layer 1: session/lifecycle bridge contract
|
||||
|
||||
This layer is evidenced by the named calls already present in repo documentation:
|
||||
|
||||
- `sgclawConnect`
|
||||
- `sgclawStart`
|
||||
- `sgclawStop`
|
||||
- `sgclawSubmitTask`
|
||||
|
||||
This layer manages session setup, task submission, and host/UI lifecycle behavior.
|
||||
|
||||
It is important evidence that a browser-host bridge exists, but it is **not** the per-browser-action contract that a new `BrowserBackend` implementation should target.
|
||||
|
||||
### Layer 2: browser-action execution contract
|
||||
|
||||
This is the authoritative target for the new browser backend.
|
||||
|
||||
It is evidenced by:
|
||||
|
||||
- `window.BrowserAction(...)` in archived frontend code
|
||||
- `FunctionsUI` / host IPC integration in archived planning docs
|
||||
- browser-side dispatch through `CommandRouter` in `docs/superpowers/specs/2026-03-25-superrpa-sgclaw-browser-control-design.md`
|
||||
|
||||
In this repository, the concrete boundary must be a **repo-local semantic transport seam** that can be implemented and tested without access to the external SuperRPA host code.
|
||||
|
||||
That seam should be a narrow Rust-side contract such as `BridgeActionTransport`:
|
||||
|
||||
- input: semantic browser action request (`navigate`, `click`, `getText`, etc.) plus params and expected domain
|
||||
- output: semantic success/error reply that can be normalized back into `BrowserBackend` results
|
||||
|
||||
`BridgeBrowserBackend` should target **Layer 2 only**.
|
||||
|
||||
### Explicit out-of-scope boundary
|
||||
|
||||
The following are outside this repository and therefore outside the immediate Rust implementation slice:
|
||||
|
||||
- actual SuperRPA C++ host/browser code
|
||||
- actual `FunctionsUI` TypeScript host plumbing in the external browser repository
|
||||
- actual `CommandRouter` implementation in the external browser repository
|
||||
|
||||
This repository should implement only:
|
||||
|
||||
- the Rust-side bridge contract types
|
||||
- the Rust-side bridge transport/provider seam
|
||||
- the Rust-side bridge-backed browser adapter
|
||||
- deterministic tests against those seams
|
||||
|
||||
### What this means practically
|
||||
|
||||
The next implementation slice should **not** continue trying to make `WsBrowserBackend` drive the real browser endpoint directly.
|
||||
|
||||
Instead, the next implementation slice should introduce a **bridge-backed browser adapter** that:
|
||||
|
||||
- preserves the Rust-side `BrowserBackend` contract where practical
|
||||
- translates browser actions onto the Layer-2 semantic bridge surface
|
||||
- keeps lifecycle/session bridge calls separate from per-action browser execution
|
||||
- leaves the raw websocket probe code as diagnostic infrastructure only
|
||||
|
||||
## Chosen architecture
|
||||
|
||||
Use a bridge-backed adapter design.
|
||||
|
||||
### Target shape
|
||||
|
||||
```text
|
||||
compat/runtime/orchestration
|
||||
-> Arc<dyn BrowserBackend>
|
||||
-> BridgeBrowserBackend (new)
|
||||
-> BridgeActionTransport (new repo-local seam)
|
||||
-> external browser-host bridge / FunctionsUI IPC
|
||||
-> BrowserAction / CommandRouter path
|
||||
```
|
||||
|
||||
### Why this shape
|
||||
|
||||
- It preserves the already-useful Rust-side browser abstraction (`BrowserBackend`) instead of re-plumbing the entire runtime.
|
||||
- It keeps raw websocket probing available for diagnostics without letting it dictate production architecture.
|
||||
- It matches the architecture already documented for SuperRPA integration.
|
||||
- It keeps future work narrow: one new adapter layer instead of rewriting all runtime behavior.
|
||||
|
||||
## What stays the same
|
||||
|
||||
### Pipe path remains unchanged
|
||||
|
||||
The existing pipe path must remain behaviorally unchanged:
|
||||
|
||||
- `src/lib.rs`
|
||||
- pipe handshake behavior
|
||||
- `BrowserPipeTool`
|
||||
- existing HMAC/domain validation semantics
|
||||
|
||||
The bridge-first work is about the **ws service / real browser integration path**, not about replacing or weakening the pipe path.
|
||||
|
||||
### Existing compat/runtime abstractions should be preserved where practical
|
||||
|
||||
The next slice should reuse:
|
||||
|
||||
- `BrowserBackend`
|
||||
- existing browser tool adapters in compat/runtime
|
||||
- existing task runner/orchestration flow
|
||||
|
||||
The new work should be concentrated in a bridge adapter and its wiring, not spread through unrelated layers.
|
||||
|
||||
## What does not stay the same
|
||||
|
||||
### Raw websocket is no longer the mainline production assumption
|
||||
|
||||
The repository may keep:
|
||||
|
||||
- `src/browser/ws_backend.rs`
|
||||
- `src/browser/ws_protocol.rs`
|
||||
- `src/browser/ws_probe.rs`
|
||||
- `src/bin/sgbrowser_ws_probe.rs`
|
||||
|
||||
But those should now be treated as:
|
||||
|
||||
- protocol tooling
|
||||
- fake-server test tooling
|
||||
- live diagnostic/probe tooling
|
||||
- possibly constrained compatibility code
|
||||
|
||||
They should remain diagnostic-only in this repository and must not be treated as the production path for reaching the real browser.
|
||||
|
||||
## Design constraints for the bridge slice
|
||||
|
||||
The bridge-path implementation must follow these constraints:
|
||||
|
||||
1. **No parallel browser API invention.** Reuse the real bridge/browser action surface already evidenced in docs and archived frontend code.
|
||||
2. **No pipe regression.** Do not alter the working pipe entry path.
|
||||
3. **Adapter-first design.** Prefer one bridge-backed backend implementation over broad runtime rewrites.
|
||||
4. **TDD first.** Add focused bridge adapter tests before production wiring.
|
||||
5. **Repository-local seam only.** Where external SuperRPA browser-host code is unavailable here, encode the contract in narrow adapters and tests instead of guessing internals.
|
||||
|
||||
## Testing implications
|
||||
|
||||
The bridge path changes what “proof” looks like.
|
||||
|
||||
### Required proof for the next slice
|
||||
|
||||
The next implementation slice must prove:
|
||||
|
||||
- a browser action can be emitted onto the bridge contract deterministically
|
||||
- the bridge adapter maps replies/errors back into `BrowserBackend` semantics
|
||||
- compat/runtime can use the bridge-backed backend without pipe regression
|
||||
|
||||
### No longer required for acceptance
|
||||
|
||||
The next slice does **not** need to prove that raw websocket business frames work directly against `ws://127.0.0.1:12345`, because the current evidence rejected that path as the mainline assumption.
|
||||
|
||||
## Acceptance criteria for this design decision
|
||||
|
||||
This design is correct only if future implementation follows all of these:
|
||||
|
||||
1. The next production slice targets the browser-host bridge path rather than raw external websocket business frames.
|
||||
2. The raw websocket probe tooling remains diagnostic only.
|
||||
3. Existing pipe behavior stays unchanged.
|
||||
4. The next implementation plan identifies a narrow bridge-backed adapter, not a broad architecture rewrite.
|
||||
5. Future success claims are based on bridge-path execution evidence, not on reinterpreting the existing raw-websocket transcript.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Aligns implementation with the strongest evidence already in the repo
|
||||
- Stops further speculative coding on the wrong control surface
|
||||
- Preserves existing ws probe work as useful diagnostics
|
||||
- Keeps the next slice narrow and testable
|
||||
|
||||
### Trade-off
|
||||
|
||||
- Requires an additional adapter design step before more production code can land
|
||||
- Defers any hope that a small websocket tweak alone will unlock the real browser path
|
||||
|
||||
That trade-off is correct, because the current blocker is no longer a small protocol bug. It is an integration-surface mismatch.
|
||||
@@ -1,288 +0,0 @@
|
||||
# WS Browser Integration Surface Correction Design
|
||||
|
||||
## Background
|
||||
|
||||
The current websocket service path already proved two things:
|
||||
|
||||
1. `sg_claw_client -> sg_claw` request handling works.
|
||||
2. The ws-native backend/auth replacement removed the old pipe/HMAC mismatch that produced `invalid hmac seed: session key must not be empty`.
|
||||
|
||||
However, real sgBrowser smoke still does not work.
|
||||
|
||||
Manual probing against the configured real browser websocket endpoint (`ws://127.0.0.1:12345`) produced a stable pattern:
|
||||
|
||||
- the connection succeeds
|
||||
- the server sends one banner text frame such as `Welcome! You are client #1`
|
||||
- after that, business frames receive no status frame and no callback frame
|
||||
- this remains true for:
|
||||
- valid-looking `sgBrowerserOpenPage` frames
|
||||
- callback-based APIs
|
||||
- no-arg/context-light APIs
|
||||
- malformed or obviously wrong frames
|
||||
|
||||
At the same time, local documentation and archived frontend code point to a different integration model:
|
||||
|
||||
- the websocket API doc describes the websocket service as a transport replacement for page-context JavaScript calls, and requires the current page URL (`requesturl`) in each message
|
||||
- archived frontend/product code uses `window.sgFunctionsUI(...)` and `window.BrowserAction(...)`
|
||||
- archived architecture docs describe the supported product path as `FunctionsUI -> browser host bridge -> BrowserAction/CommandRouter`, not an arbitrary external process speaking raw browser websocket frames
|
||||
|
||||
This means the current assumption is no longer acceptable as the default architecture hypothesis:
|
||||
|
||||
- **Rejected default assumption:** `sg_claw` can directly control the real browser by speaking raw business frames to `browserWsUrl` as an external client, with no additional browser-host bridge, page context, or bootstrap/session contract.
|
||||
|
||||
That assumption may still turn out to be partially true, but it is no longer justified enough to continue coding against as the mainline design.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The project currently has a functioning ws-native transport implementation, but it does **not** have a validated real integration surface for sgBrowser.
|
||||
|
||||
The unresolved question is now architectural rather than syntactic:
|
||||
|
||||
### Possibility A: raw websocket is valid, but requires hidden bootstrap/preconditions
|
||||
|
||||
Examples suggested by the local API document:
|
||||
|
||||
- a real browser page must already exist and `requesturl` must refer to that page
|
||||
- one or more setup calls such as `sgSetAuthInfo`, `sgBrowserLogin`, `sgOpenAgent`, or `sgBrowerserActiveTab` must happen first
|
||||
- callbacks may require a browser-side JS/page context that an external process does not automatically have
|
||||
- some APIs may only work against agent/show/hide areas after browser-side initialization
|
||||
|
||||
### Possibility B: raw websocket is not the supported external control surface
|
||||
|
||||
Instead, the real product path may require:
|
||||
|
||||
- `FunctionsUI` / browser-host IPC
|
||||
- host-side security and routing
|
||||
- `BrowserAction` / `CommandRouter` dispatch
|
||||
- page-injected or browser-embedded execution context
|
||||
|
||||
If this is true, continuing to invest in raw external websocket business-frame handling as the main integration surface would be architectural drift.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current unvalidated ws-native-direct assumption with a decision-backed integration strategy.
|
||||
|
||||
The next implementation slice must do exactly one of these two things based on evidence:
|
||||
|
||||
1. **Bootstrap path:** prove that raw websocket control is real and supported once the missing bootstrap/precondition sequence is performed, then codify that bootstrap sequence and keep `WsBrowserBackend` as the execution surface.
|
||||
2. **Bridge path:** prove that raw websocket is not the real supported surface for external control, then pivot the runtime design so sgClaw targets the actual browser-host bridge / `BrowserAction` surface instead of pretending the raw websocket is enough.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This correction slice does **not** include:
|
||||
|
||||
- broad feature work on the floating chat UI
|
||||
- multi-client service redesign
|
||||
- browser process lifecycle management
|
||||
- speculative protocol expansion
|
||||
- generic reconnection/backoff work
|
||||
- rewriting the entire compat/runtime stack without evidence
|
||||
- landing both bootstrap and bridge implementations in one branch
|
||||
|
||||
The purpose of this slice is to choose the correct integration surface first.
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
### Evidence that the current raw-ws-direct assumption is weak
|
||||
|
||||
1. Real endpoint accepts connections but stays silent after the welcome/banner frame.
|
||||
2. Silence occurs even for malformed frames, which suggests the endpoint is not acting like an openly documented RPC surface for arbitrary external clients.
|
||||
3. The API documentation frames websocket use as a replacement for page-side JS invocation, not as a standalone public automation API.
|
||||
4. The documentation repeatedly depends on `requesturl`, callback function names, target pages, and browser areas (`show`, `hide`, `agent`).
|
||||
5. Historical frontend/product code uses `window.sgFunctionsUI(...)` and `window.BrowserAction(...)`, not raw external websocket business calls.
|
||||
6. Historical architecture docs emphasize `FunctionsUI`, `CommandRouter`, and browser-host bridge seams.
|
||||
|
||||
### Evidence that the current ws-native work is still useful
|
||||
|
||||
1. The ws-native auth replacement removed a real bug.
|
||||
2. The ws backend now correctly carries forward the last navigated request URL.
|
||||
3. `WsBrowserBackend` and `ws_protocol` remain valuable as deterministic protocol tooling for fake-server tests and any future bootstrap validation.
|
||||
|
||||
So the conclusion is **not** “delete ws-native work.”
|
||||
|
||||
The conclusion is:
|
||||
|
||||
- do not treat raw external websocket control as validated product architecture yet
|
||||
- use the ws-native code only behind a decision gate
|
||||
|
||||
## Design Decision
|
||||
|
||||
Adopt a **decision-gated integration strategy**.
|
||||
|
||||
### Decision Gate 1: Validate bootstrap viability first
|
||||
|
||||
Before any more production architecture changes, add a focused, deterministic validation harness that can exercise a candidate raw-websocket bootstrap sequence against a live endpoint.
|
||||
|
||||
The harness must support:
|
||||
|
||||
- ordered frame scripts
|
||||
- exact frame logging
|
||||
- exact timeout/silence observation
|
||||
- trying candidate setup sequences such as:
|
||||
- `sgSetAuthInfo`
|
||||
- `sgBrowserLogin`
|
||||
- `sgOpenAgent`
|
||||
- `sgBrowerserActiveTab`
|
||||
- then a minimal action such as `sgBrowerserOpenPage` or `sgBrowserExcuteJsCodeByArea`
|
||||
- trying the same action with different `requesturl` assumptions
|
||||
- distinguishing these outcomes:
|
||||
- numeric status returned
|
||||
- callback returned
|
||||
- welcome only, then silence
|
||||
- close/reset
|
||||
- protocol error
|
||||
|
||||
This harness is not product code. It is an evidence tool that prevents blind implementation.
|
||||
|
||||
### Decision Gate 2: Make bridge pivot the default fallback
|
||||
|
||||
If the validation harness cannot demonstrate a reproducible bootstrap sequence that yields real status/callback frames from the live browser endpoint, then raw websocket must be considered **non-validated for external control**.
|
||||
|
||||
At that point, the design must pivot to the bridge path:
|
||||
|
||||
- sgClaw browser control targets the real browser-host integration surface
|
||||
- use the bridge already evidenced in docs/code (`FunctionsUI`, browser host IPC, `BrowserAction`, `CommandRouter`)
|
||||
- keep raw websocket support, if retained at all, as a diagnostic or highly constrained adapter rather than the primary product path
|
||||
|
||||
## Architecture Options
|
||||
|
||||
## Option A: Bootstrap-validated raw websocket path
|
||||
|
||||
Choose this only if the live validation harness produces repeatable evidence.
|
||||
|
||||
### Resulting architecture
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service
|
||||
-> bootstrap sequence executor
|
||||
-> WsBrowserBackend
|
||||
-> browserWsUrl
|
||||
-> sgBrowser
|
||||
```
|
||||
|
||||
### Required conditions
|
||||
|
||||
- a reproducible bootstrap sequence exists
|
||||
- the sequence yields status/callback traffic for real business actions
|
||||
- the sequence can be encoded as a narrow service-side precondition layer
|
||||
- the sequence does not require unowned browser UI/manual setup outside a documented contract
|
||||
|
||||
### Allowed production changes if Option A wins
|
||||
|
||||
- add explicit bootstrap calls before first browser action
|
||||
- persist validated session/context state needed by the real endpoint
|
||||
- tighten `request_url` / target-page handling around the proven contract
|
||||
|
||||
### Not allowed even if Option A wins
|
||||
|
||||
- guessing bootstrap steps without evidence
|
||||
- silently sprinkling many setup calls into random locations
|
||||
- broadening the compat/runtime API before the bootstrap contract is known
|
||||
|
||||
## Option B: Bridge-first integration path
|
||||
|
||||
Choose this if live validation does not prove a workable raw websocket bootstrap.
|
||||
|
||||
### Resulting architecture
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service
|
||||
-> bridge adapter
|
||||
-> browser host / FunctionsUI / BrowserAction / CommandRouter
|
||||
-> sgBrowser page actions
|
||||
```
|
||||
|
||||
### Required conditions
|
||||
|
||||
- local docs/code show a stable supported bridge path
|
||||
- raw websocket remains non-validated or only page-context-scoped
|
||||
- the bridge surface can be wrapped behind the existing `BrowserBackend` abstraction or a sibling adapter without weakening pipe behavior
|
||||
|
||||
### Allowed production changes if Option B wins
|
||||
|
||||
- add a new browser backend implementation that targets the real bridge surface
|
||||
- redirect ws service/browser execution away from raw business frames
|
||||
- preserve ws-native code only for tests, probes, or intentionally constrained cases
|
||||
|
||||
### Not allowed even if Option B wins
|
||||
|
||||
- pretending the old raw-ws mainline still works “well enough”
|
||||
- leaving the service path ambiguously split between two competing primary surfaces
|
||||
|
||||
## Scope Guardrails for the Next Implementation Plan
|
||||
|
||||
The next implementation plan must obey these guardrails:
|
||||
|
||||
1. **One branch, one decision.** Do not implement both architecture options at once.
|
||||
2. **Evidence before code.** If bootstrap is unproven, the next coding task is probe/validation tooling, not another speculative service/runtime refactor.
|
||||
3. **Keep pipe untouched.** `src/lib.rs`, pipe handshake, and the pipe `BrowserPipeTool` path remain behaviorally unchanged.
|
||||
4. **Do not delete ws-native code prematurely.** It still has value for protocol tests and validation tooling.
|
||||
5. **Do not broaden success claims.** Removing `invalid hmac seed` did not make real browser control work.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Stage 1: Evidence tooling tests
|
||||
|
||||
Add deterministic tests for the live-probe/validation harness so it can:
|
||||
|
||||
- send an ordered frame script
|
||||
- record exact received frames
|
||||
- report silence/timeout precisely
|
||||
- expose transcript output suitable for comparing candidate bootstrap sequences
|
||||
|
||||
These tests use a fake websocket server, not sgBrowser.
|
||||
|
||||
### Stage 2: Live validation runs
|
||||
|
||||
Use the harness against the real endpoint with a fixed matrix of candidate sequences.
|
||||
|
||||
At minimum, compare:
|
||||
|
||||
1. no bootstrap -> minimal action
|
||||
2. `sgOpenAgent` -> minimal action
|
||||
3. `sgSetAuthInfo` -> minimal action
|
||||
4. `sgBrowserLogin` -> minimal action
|
||||
5. `sgBrowerserActiveTab` -> minimal action
|
||||
6. combined documented bootstrap candidates -> minimal action
|
||||
7. alternate `requesturl` values representing:
|
||||
- `about:blank`
|
||||
- target page URL
|
||||
- a currently open page URL if known
|
||||
|
||||
### Stage 3: Architecture-branch acceptance
|
||||
|
||||
If Option A wins:
|
||||
|
||||
- add one automated regression that proves the validated bootstrap sequence produces the first real status frame in a controlled integration test
|
||||
- then continue with the narrowest production implementation plan
|
||||
|
||||
If Option B wins:
|
||||
|
||||
- write a new bridge-integration implementation plan before changing production code
|
||||
- base all production tasks on the documented bridge surface
|
||||
|
||||
## Acceptance Criteria for This Design Correction
|
||||
|
||||
This design correction is successful only if future work follows these rules:
|
||||
|
||||
1. The repository has an explicit design document recording that raw ws-native direct control is **not currently validated**.
|
||||
2. The next engineering slice starts with validation or bridge selection, not another speculative runtime refactor.
|
||||
3. Any future claim that raw websocket is the supported production path must be backed by a reproducible live bootstrap transcript.
|
||||
4. If that evidence does not appear, the project pivots to the bridge path rather than continuing to guess.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- stops further speculative coding against an unproven surface
|
||||
- preserves useful ws-native work without over-committing to it
|
||||
- creates a clean decision point for the next implementation branch
|
||||
|
||||
### Trade-off
|
||||
|
||||
- this does not immediately unblock real browser control
|
||||
- it intentionally inserts an evidence phase before more production changes
|
||||
|
||||
That trade-off is acceptable because the current failure mode is architectural uncertainty, not a missing two-line fix.
|
||||
@@ -1,105 +0,0 @@
|
||||
# WS Browser Welcome Frame Compatibility Design
|
||||
|
||||
## Background
|
||||
|
||||
Manual smoke verification after the ws-native browser backend auth replacement showed that real `sgBrowser` sends a banner text frame immediately after the websocket connection is established:
|
||||
|
||||
- `Welcome! You are client #1`
|
||||
|
||||
The current ws-native path treats the first received text frame as a protocol status frame. In `src/browser/ws_backend.rs`, `WsBrowserBackend::invoke(...)` reads one text frame and immediately parses it as an integer status code. That works for the existing deterministic tests, but it fails against the real browser because the first frame is a human-readable welcome banner rather than `0` or another numeric status.
|
||||
|
||||
This means the auth replacement is working — the old `invalid hmac seed: session key must not be empty` error no longer appears — but real smoke still fails on protocol parsing.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the ws service path tolerate exactly one initial welcome/banner text frame from the real browser websocket, without weakening the general ws protocol semantics.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This change must not:
|
||||
|
||||
- Relax parsing of arbitrary non-protocol text frames
|
||||
- Change `WsBrowserBackend` into a browser-specific parser for banners
|
||||
- Affect the legacy pipe path
|
||||
- Add retry loops or broader reconnection logic
|
||||
- Change callback handling semantics
|
||||
|
||||
## Chosen approach
|
||||
|
||||
Handle the welcome banner only in `ServiceBrowserWsClient`.
|
||||
|
||||
### Why this layer
|
||||
|
||||
`ServiceBrowserWsClient` is already the real-browser adapter used only by the ws service path in `src/service/server.rs`. The welcome frame is a quirk of the real browser endpoint rather than a property of the shared ws protocol abstraction. Keeping the compatibility behavior in the service-side client preserves the stricter semantics of `WsBrowserBackend` for all other callers and test doubles.
|
||||
|
||||
## Behavioral rules
|
||||
|
||||
1. Only the first received text frame after establishing a browser websocket connection may be treated as a welcome/banner candidate.
|
||||
2. If that first text frame matches the real banner shape (currently observed as `Welcome! You are client #1`), the client discards it and continues waiting for the actual protocol frame.
|
||||
3. The welcome skip is one-time only per connection, not per request. Because `ServiceBrowserWsClient` holds a persistent socket, this state must survive multiple `invoke(...)` calls on the same underlying websocket.
|
||||
4. After the welcome skip:
|
||||
- status frames must still be numeric strings
|
||||
- callback frames must still match the existing JSON-array callback protocol
|
||||
- any other malformed frame remains a protocol error
|
||||
5. Timeout, close/reset, and connect-failure semantics remain unchanged.
|
||||
|
||||
## Matching strategy
|
||||
|
||||
Use a narrow string check in `ServiceBrowserWsClient` for a welcome/banner frame:
|
||||
|
||||
- starts with `Welcome! You are client #`
|
||||
|
||||
This is intentionally strict. We are adapting one known real-browser behavior, not introducing a generic “ignore garbage text” mode.
|
||||
|
||||
## Tests
|
||||
|
||||
### New red tests
|
||||
|
||||
Add focused unit tests under `src/service/server.rs` tests:
|
||||
|
||||
1. Positive case:
|
||||
- fake websocket server sends:
|
||||
1. `Welcome! You are client #1`
|
||||
2. `0`
|
||||
- then `WsBrowserBackend.invoke(Action::Navigate, ...)` succeeds
|
||||
|
||||
2. Negative case:
|
||||
- fake websocket server sends a different first text frame that does **not** match the known welcome prefix
|
||||
- assert the call still fails as a protocol error rather than silently skipping the frame
|
||||
|
||||
The positive test must fail before the implementation change and pass after it. The negative test guards the non-goal that we are not introducing a generic “ignore arbitrary text” mode.
|
||||
|
||||
### Regression coverage
|
||||
|
||||
Re-run:
|
||||
|
||||
- `cargo test service::server::tests -- --nocapture`
|
||||
- `cargo test --test browser_ws_backend_test -- --nocapture`
|
||||
- `cargo test --test service_task_flow_test -- --nocapture`
|
||||
|
||||
If those pass, re-run the earlier mixed ws+pipe sweep to confirm no unexpected regression escaped the targeted checks.
|
||||
|
||||
## Risks and controls
|
||||
|
||||
### Risk: swallowing a legitimate protocol error
|
||||
|
||||
Control:
|
||||
- only allow the one-time skip on the first received text frame
|
||||
- only skip frames matching the known welcome prefix
|
||||
|
||||
### Risk: broadening behavior beyond service ws path
|
||||
|
||||
Control:
|
||||
- keep the change entirely inside `ServiceBrowserWsClient`
|
||||
- do not modify `WsBrowserBackend` parsing rules
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The fix is complete only if all of the following are true:
|
||||
|
||||
1. The positive welcome-banner test fails before the change and passes after it.
|
||||
2. The negative malformed-first-frame test proves that non-matching first text frames still fail as protocol errors.
|
||||
3. Real ws service smoke no longer fails with `invalid browser status frame: Welcome! You are client #1` when using the configured real sgBrowser endpoint.
|
||||
4. Existing ws backend tests remain green.
|
||||
5. Existing service task-flow regression remains green.
|
||||
6. Pipe behavior remains unchanged, verified by the mixed ws+pipe regression suite.
|
||||
@@ -1,182 +0,0 @@
|
||||
# Zhihu WS Submit Realignment Design
|
||||
|
||||
## Background
|
||||
|
||||
The current Zhihu submit path drifted away from the documented browser websocket contract.
|
||||
|
||||
The authoritative contract for this repository is `docs/_tmp_sgbrowser_ws_api_doc.txt`.
|
||||
|
||||
For this slice, the spec anchors to these documented invariants only:
|
||||
|
||||
- connect to `ws://127.0.0.1:12345`
|
||||
- send `{"type":"register","role":"web"}`
|
||||
- send browser actions as JSON arrays `[requesturl, action, ...args]`
|
||||
- let browser results come back through documented callback semantics such as `callBackJsToCpp(...)`
|
||||
- keep the current page URL as the request owner instead of inventing an external helper page
|
||||
|
||||
The current production path does not follow that shape for Zhihu routes.
|
||||
|
||||
Instead, the submit path selects `BrowserCallbackBackend`, which starts `LiveBrowserCallbackHost` and attempts to bootstrap a local helper page at `/sgclaw/browser-helper.html`. That helper-page bootstrap is not part of the user's confirmed production model, and live evidence already shows it is the wrong assumption for the Release browser.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Zhihu submit currently fails before real work begins because the service path depends on a helper-page callback host bootstrap that the Release browser does not use.
|
||||
|
||||
That drift shows up in three ways:
|
||||
|
||||
1. Zhihu submit routes select the callback-host backend instead of the direct websocket backend.
|
||||
2. The mainline request URL becomes the local helper page URL instead of the real browser page URL.
|
||||
3. The submit path waits for helper-page readiness rather than proceeding through the documented websocket callback model.
|
||||
|
||||
This causes the observable failure:
|
||||
|
||||
- `timeout while waiting for browser message`
|
||||
- no real Zhihu page open/action in the browser
|
||||
|
||||
## Goal
|
||||
|
||||
Realign the Zhihu submit path to the documented websocket callback model without changing the existing pipe/service contract.
|
||||
|
||||
Concretely, the target behavior is:
|
||||
|
||||
- Zhihu submit routes use the websocket browser backend directly
|
||||
- browser messages keep the real page URL as `requesturl`
|
||||
- browser actions continue to use documented websocket opcodes
|
||||
- callback-bearing results continue to use the documented callback payload model
|
||||
- the browser no longer depends on opening a local helper page before Zhihu work starts
|
||||
|
||||
## Non-goals
|
||||
|
||||
This slice does not include:
|
||||
|
||||
- changing `ClientMessage` or `ServiceMessage`
|
||||
- changing `run_submit_task_with_browser_backend(...)`
|
||||
- rewriting the Zhihu workflow itself
|
||||
- adding a new browser bridge abstraction
|
||||
- redesigning the pipe path
|
||||
- deleting callback-host code that is outside the Zhihu submit mainline
|
||||
- speculative protocol expansion beyond the documented websocket contract
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Choose **Option A**: withdraw Zhihu submit from the helper-page callback-host path and return it to the documented websocket callback model.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- Keep callback host but remove helper bootstrap: still preserves the wrong abstraction in the mainline.
|
||||
- Build a new orchestration layer: exceeds the requested scope.
|
||||
|
||||
## Mainline Architecture After Realignment
|
||||
|
||||
```text
|
||||
sg_claw_client
|
||||
-> sg_claw service / runtime submit path
|
||||
-> existing BrowserBackend seam
|
||||
-> WsBrowserBackend
|
||||
-> ws://127.0.0.1:12345
|
||||
-> documented browser opcodes and callback semantics
|
||||
```
|
||||
|
||||
For Zhihu submit routes, the callback-host helper page is no longer part of the mainline execution chain.
|
||||
|
||||
## Required Production Changes
|
||||
|
||||
### 1. Route selection
|
||||
|
||||
Update submit-route backend selection so these routes no longer instantiate `BrowserCallbackBackend`:
|
||||
|
||||
- `WorkflowRoute::ZhihuHotlistExportXlsx`
|
||||
- `WorkflowRoute::ZhihuHotlistScreen`
|
||||
- `WorkflowRoute::ZhihuArticleEntry`
|
||||
- `WorkflowRoute::ZhihuArticleDraft`
|
||||
- `WorkflowRoute::ZhihuArticlePublish`
|
||||
|
||||
The change applies in both:
|
||||
|
||||
- service submit path in `src/service/server.rs`
|
||||
- direct runtime submit path in `src/agent/mod.rs`
|
||||
|
||||
Direct runtime fallback behavior stays unchanged when no browser websocket URL is configured:
|
||||
|
||||
- if a real browser websocket URL is configured, use `WsBrowserBackend` for the listed Zhihu routes
|
||||
- if no browser websocket URL is configured, keep the existing pipe fallback instead of failing fast
|
||||
|
||||
### 2. Request URL ownership
|
||||
|
||||
Keep `requesturl` aligned with the real browser page instead of the helper page.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- initial request URL comes from the existing submit-path request context
|
||||
- after a successful navigate call, the websocket backend continues to update its request URL to the navigated target page
|
||||
- later `getText` and `eval` calls run against the real Zhihu page URL
|
||||
|
||||
This preserves the documented page-owned websocket model.
|
||||
|
||||
### 3. Callback semantics
|
||||
|
||||
Keep callback-bearing actions on the existing websocket protocol path, using the documented callback payload shape.
|
||||
|
||||
Required invariants:
|
||||
|
||||
- action frames remain `[requesturl, action, ...args]`
|
||||
- navigate uses the documented opcode `sgHideBrowserCallAfterLoaded`
|
||||
- `getText` and `eval` continue to emit `callBackJsToCpp(...)` payloads in the documented `sourceUrl@_@targetUrl@_@callback@_@actionUrl@_@responseTxt` form
|
||||
- callback decoding remains on the websocket path instead of moving through localhost helper-page HTTP endpoints
|
||||
|
||||
### 4. Callback-host removal from the Zhihu mainline
|
||||
|
||||
For this slice, callback-host code is removed from the Zhihu submit mainline, not redesigned.
|
||||
|
||||
Practical meaning:
|
||||
|
||||
- Zhihu submit must not start `LiveBrowserCallbackHost`
|
||||
- Zhihu submit must not emit `sgBrowerserOpenPage` for `/sgclaw/browser-helper.html`
|
||||
- Zhihu submit must not block on `/sgclaw/callback/ready`
|
||||
|
||||
Code outside the Zhihu submit mainline can remain unchanged unless tests require cleanup.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
This slice follows TDD and replaces the stale helper-page assumptions with direct websocket submit-path assertions.
|
||||
|
||||
### Red tests to add or rewrite
|
||||
|
||||
1. Rewrite the current submit regression that asserts helper-page bootstrap.
|
||||
- old behavior under test: Zhihu submit bootstraps callback host
|
||||
- new behavior under test: Zhihu submit does **not** bootstrap callback host and does **not** emit helper-page frames
|
||||
|
||||
2. Add or update a focused submit-path regression proving request ownership stays on the real page.
|
||||
- after navigate, subsequent Zhihu browser actions must use the real target page URL rather than `/sgclaw/browser-helper.html`
|
||||
|
||||
3. Remove or rewrite any newly added red test whose only purpose was to preserve callback-host-without-helper behavior.
|
||||
- that test belongs to the rejected Option B path, not the chosen Option A path
|
||||
|
||||
### Green verification
|
||||
|
||||
After the minimal code change, run focused verification in this order:
|
||||
|
||||
1. `agent_runtime_test` coverage for the submit path
|
||||
2. relevant Zhihu `compat_runtime_test` coverage
|
||||
3. submit/service websocket regressions impacted by route selection
|
||||
4. stronger real-browser validation after focused tests pass
|
||||
|
||||
## Scope Guardrails
|
||||
|
||||
The implementation plan for this spec must obey all of the following:
|
||||
|
||||
1. Do not modify the pipe contract.
|
||||
2. Do not add a new browser abstraction.
|
||||
3. Do not broaden the change beyond the Zhihu submit path and its directly affected websocket protocol tests.
|
||||
4. Do not keep the helper-page path as a second competing Zhihu mainline.
|
||||
5. If live validation still reveals a callback-payload mismatch, only adjust the websocket protocol encoding/decoding at the exact mismatch point.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The slice is complete when all of the following are true:
|
||||
|
||||
1. Zhihu submit routes no longer select the helper-page callback-host backend.
|
||||
2. No Zhihu submit regression expects or observes `/sgclaw/browser-helper.html` bootstrap.
|
||||
3. The websocket backend sends Zhihu follow-up actions with the real page URL as `requesturl`.
|
||||
4. Focused automated tests covering the changed submit path pass.
|
||||
5. Real-browser validation no longer fails at callback-host readiness timeout, emits no helper-page bootstrap frames, and emits at least one real-page follow-up browser action after navigate.
|
||||
@@ -1,291 +0,0 @@
|
||||
# Scene Skill Runtime Routing Design
|
||||
|
||||
**Goal:** Add a minimal, extensible scene-routing layer so staged business scenes can be triggered from natural language while still executing through the existing browser-backed skill path.
|
||||
|
||||
**Architecture:** Introduce a registry-driven scene contract loader that reads staged `scene.json` metadata, matches user instructions to a scene, and chooses one of two dispatch modes: direct browser execution or agent-mediated browser execution. Both modes must reuse the same browser-backed skill tool path so scene skills continue to execute through browser-internal methods rather than text-only responses or local fake execution.
|
||||
|
||||
**Tech Stack:** Rust, serde/JSON scene metadata loading, existing `BrowserScriptSkillTool`, existing compat runtime / runtime engine / workflow executor layers, focused Rust unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The codebase already supports two useful but separate ideas:
|
||||
|
||||
1. **Zhihu special-case runtime routing**
|
||||
- `src/compat/workflow_executor.rs` detects a narrow set of Zhihu tasks and can execute them directly without relying on the model to choose tools.
|
||||
- This is stable, but not extensible for a growing set of business scenes.
|
||||
|
||||
2. **Browser-backed skills**
|
||||
- `src/compat/runtime.rs` loads skills and exposes `browser_script` tools through `BrowserScriptSkillTool`.
|
||||
- `src/compat/browser_script_skill_tool.rs` executes those tools by calling the browser backend with `Action::Eval`, so actual execution already happens through browser-internal methods.
|
||||
- This is extensible, but tool choice currently depends too heavily on generic agent behavior.
|
||||
|
||||
The staged business scenes under `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging` already provide most of the metadata needed to bridge these two ideas. We need a first integration slice that uses scene metadata to improve routing without turning every scene into a hardcoded Zhihu-style exception.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- Support natural-language triggering for staged scenes.
|
||||
- Preserve the current browser-backed execution contract: both scene modes must end in browser-internal execution via the existing browser tool path.
|
||||
- Support both dispatch styles discussed with the user:
|
||||
- one scene that can execute without the model
|
||||
- one scene that still uses the model for orchestration
|
||||
- Keep the first slice small, covering only:
|
||||
- `fault-details-report`
|
||||
- `95598-repair-city-dispatch`
|
||||
- Keep the design extensible so more scene skills can be added in the same directory later without more ad hoc routing branches.
|
||||
- Avoid broad refactors or a new generic workflow platform in this slice.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not build a scene editor, scene UI, or registry authoring workflow.
|
||||
- Do not implement a full artifact post-processing platform for all report/monitor types.
|
||||
- Do not convert every staged scene into a direct Rust executor.
|
||||
- Do not replace the existing Zhihu-specific runtime path in this slice.
|
||||
|
||||
## Source of Truth and Paths
|
||||
|
||||
### Staged scene source
|
||||
The new staged scene source for this work is:
|
||||
|
||||
- `D:\data\ideaSpace\rust\sgClaw\claw\claw\skills\skill_staging`
|
||||
|
||||
The runtime integration must read scene metadata from this location for the initial slice.
|
||||
|
||||
### Existing runtime integration points
|
||||
- `src/compat/config_adapter.rs` — current skills-dir resolution logic
|
||||
- `src/compat/runtime.rs` — current skill loading and browser-script tool exposure
|
||||
- `src/runtime/engine.rs` — runtime instruction building and allowed-tool shaping
|
||||
- `src/compat/workflow_executor.rs` — existing direct execution routing pattern
|
||||
- `src/compat/browser_script_skill_tool.rs` — browser-backed execution path for `browser_script` tools
|
||||
|
||||
## Scene Contract Model
|
||||
|
||||
Introduce a small internal scene contract model derived from `scene.json` and paired runtime policy. The loader should extract only the fields needed for the first slice:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `summary`
|
||||
- `tags`
|
||||
- `inputs`
|
||||
- `outputs`
|
||||
- `skill.package`
|
||||
- `skill.tool`
|
||||
- `skill.artifact_type`
|
||||
|
||||
Add a runtime-only dispatch policy associated with each enabled scene inside the same internal registry entry used at runtime:
|
||||
|
||||
- `dispatch_mode`
|
||||
- `direct_browser`
|
||||
- `agent_browser`
|
||||
- `expected_domain`
|
||||
- bare hostname required by the underlying browser-backed skill tool
|
||||
- optional `aliases`
|
||||
- additional deterministic keywords/phrases when `id/name/summary/tags` are not enough for first-slice matching
|
||||
- optional `default_args`
|
||||
- runtime-supplied tool arguments when a scene needs fixed/default values for first execution
|
||||
|
||||
This runtime policy may be hardcoded in Rust for the first slice, but it must be represented through one consistent scene-routing abstraction so future scenes can join the same path without rewriting the whole design. The abstraction should be a single registry entry type that combines scene metadata with runtime dispatch policy, rather than a metadata loader plus a separate ad hoc match table.
|
||||
|
||||
## Dispatch Modes
|
||||
|
||||
### 1. `direct_browser`
|
||||
This mode is for scenes whose collection flow is deterministic enough to bypass the model once the scene is recognized.
|
||||
|
||||
**Initial scene:** `fault-details-report`
|
||||
|
||||
**Behavior:**
|
||||
- Detect scene from natural language.
|
||||
- Resolve the corresponding browser-backed skill tool.
|
||||
- Execute it directly through the existing browser-backed skill path.
|
||||
- Return the collected artifact result without delegating tool choice to the model.
|
||||
|
||||
**Important constraint:**
|
||||
This is not a local fake implementation. Even in direct mode, the actual collection must still go through the existing browser-backed execution path, meaning it ultimately uses browser-internal methods through the browser backend.
|
||||
|
||||
### 2. `agent_browser`
|
||||
This mode is for scenes that still benefit from agent orchestration, explanation, or downstream reasoning, but whose business data must still come from browser-backed execution.
|
||||
|
||||
**Initial scene:** `95598-repair-city-dispatch`
|
||||
|
||||
**Behavior:**
|
||||
- Detect scene from natural language.
|
||||
- Inject a strong scene execution contract into the runtime instruction.
|
||||
- Treat calling the matching browser-backed skill tool first as a policy requirement for the scene.
|
||||
- In slice one, enforce that policy through scene-specific instruction injection rather than a hard runtime gate.
|
||||
- Allow generic browser probing only as a fallback after the scene tool fails.
|
||||
- Keep final explanation/summarization in the agent path, but never let the model invent business data.
|
||||
|
||||
## Matching Strategy
|
||||
|
||||
Implement a minimal matcher that scores user instructions against enabled scenes using:
|
||||
|
||||
- scene `id`
|
||||
- scene `name`
|
||||
- scene `summary`
|
||||
- scene `tags`
|
||||
- optional runtime aliases for the first slice
|
||||
|
||||
The matcher should be intentionally simple and deterministic in this slice. Avoid semantic embedding or fuzzy retrieval infrastructure.
|
||||
|
||||
Expected first-slice matches:
|
||||
|
||||
- `fault-details-report`
|
||||
- phrases like `故障明细`, `故障明细报表`, `导出故障明细`
|
||||
- `95598-repair-city-dispatch`
|
||||
- phrases like `95598抢修市指`, `市指抢修监测`, `95598抢修队列`
|
||||
|
||||
If no scene matches, runtime behavior must remain unchanged.
|
||||
|
||||
## Runtime Loading Design
|
||||
|
||||
### Scene registry loading
|
||||
Add a small loader that reads enabled scenes from the staged scene directory. For the first slice, it is acceptable to read the concrete scene files directly instead of implementing a full generic registry parser, as long as the resulting module boundary is registry-oriented rather than one-off.
|
||||
|
||||
The loader should:
|
||||
- resolve the staged scene root
|
||||
- read the two initial `scene.json` files
|
||||
- deserialize them into a small internal scene metadata struct
|
||||
- pair them with dispatch policy in the same in-memory registry entry
|
||||
- ignore malformed or missing scenes safely
|
||||
- never fail runtime startup solely because one or both initial scene files are absent
|
||||
|
||||
### Skill loading alignment
|
||||
The corresponding skill packages must still be loaded into runtime skill exposure so the browser-backed tools are available to the runtime.
|
||||
|
||||
For this slice, the staged scene source and staged skill packages should be treated as coming from the same external root:
|
||||
- staged scenes under `.../skill_staging/scenes`
|
||||
- staged skill packages under `.../skill_staging/skills`
|
||||
|
||||
The implementation must make that staged skill package root visible to runtime skill loading. If current `skills_dir` resolution cannot express that directly, the design should extend configuration/path resolution to support a staged external skills root explicitly rather than relying on implicit mirroring.
|
||||
|
||||
## Execution Design
|
||||
|
||||
### Direct browser path (`fault-details-report`)
|
||||
Add a direct execution route that is scene-driven rather than Zhihu-specific.
|
||||
|
||||
High-level flow:
|
||||
1. Runtime receives user instruction.
|
||||
2. Scene matcher recognizes `fault-details-report`.
|
||||
3. Runtime resolves the browser-backed tool name `fault-details-report.collect_fault_details`.
|
||||
4. Runtime builds the required tool arguments, including:
|
||||
- `expected_domain` from the matched scene's runtime policy
|
||||
- any first-slice scene inputs that can be deterministically derived from the current request/context
|
||||
- any fixed/default args declared in runtime policy
|
||||
5. Runtime executes that skill through the existing browser-backed mechanism.
|
||||
6. Runtime returns normalized tool output as the direct route result.
|
||||
|
||||
Input/argument rules for the first slice:
|
||||
- Direct execution is only allowed when all required tool arguments are available.
|
||||
- `expected_domain` must always come from runtime scene policy, not from model inference.
|
||||
- If a required scene/tool input cannot be derived from the user request or current browser context, the direct route must fail clearly instead of fabricating values.
|
||||
- The first slice may keep direct-mode argument mapping intentionally narrow; unsupported requests should fall back safely rather than guessing.
|
||||
|
||||
Return-shape rule for the first slice:
|
||||
- The direct route should return normalized serialized tool output (for example, the tool payload string or normalized JSON text), not a model-authored prose summary. This keeps direct mode deterministic and makes the browser-backed result explicit.
|
||||
|
||||
Implementation note:
|
||||
The cleanest first slice is to add a small scene direct-execution helper in the compat runtime/workflow area that invokes the already-loaded browser-backed skill tool abstraction rather than duplicating browser request logic.
|
||||
|
||||
### Agent browser path (`95598-repair-city-dispatch`)
|
||||
This path stays inside the agent flow.
|
||||
|
||||
High-level flow:
|
||||
1. Runtime receives user instruction.
|
||||
2. Scene matcher recognizes `95598-repair-city-dispatch`.
|
||||
3. `RuntimeEngine::build_instruction` injects a scene execution contract containing:
|
||||
- the matched scene name
|
||||
- the required tool name `95598-repair-city-dispatch.collect_repair_orders`
|
||||
- explicit requirement that this is a browser workflow, not a text-only task
|
||||
- explicit requirement that business data must come from the browser-backed scene tool
|
||||
- fallback rules for generic browser probing only after tool failure
|
||||
4. Agent runs and chooses the required tool.
|
||||
5. Tool executes through the existing browser-backed skill path.
|
||||
6. Agent may summarize the result, but cannot fabricate data.
|
||||
|
||||
Enforcement note for the first slice:
|
||||
- The `agent_browser` guarantee is primarily an instruction-contract guarantee in slice one.
|
||||
- If allowed-tool shaping can narrow the exposed tool set for a matched scene without destabilizing existing behavior, that is a valid enhancement, but it is not required for the first slice.
|
||||
- The minimum guaranteed behavior for slice one is strong scene-specific prompt injection plus preservation of the rule that the model must not invent collected business data.
|
||||
|
||||
## Browser Execution Contract
|
||||
|
||||
This requirement is non-negotiable for both dispatch modes:
|
||||
|
||||
- scene skills must execute like the Zhihu flow in the sense that the final business action is performed through browser-internal methods
|
||||
- scene skills must not devolve into text-only pseudo execution
|
||||
- direct mode and agent mode both reuse the existing browser-backed skill execution path
|
||||
|
||||
Concretely, the final path for scene skill execution should remain compatible with:
|
||||
- `BrowserScriptSkillTool`
|
||||
- browser backend invocation
|
||||
- browser-side `Eval` / browser action execution semantics
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Scene metadata missing or invalid:** skip that scene and continue with normal runtime behavior.
|
||||
- **Scene matched but skill/tool unavailable:** do not crash; log enough context for diagnosis and fall back safely.
|
||||
- **Browser surface unavailable:** disable scene browser routing for that turn and fall back to current non-scene behavior.
|
||||
- **Tool execution fails in `agent_browser` mode:** allow existing fallback prompt behavior to continue, but preserve the rule that the model cannot invent collected data.
|
||||
- **Tool execution fails in `direct_browser` mode:** return a concise execution failure instead of pretending collection succeeded.
|
||||
|
||||
## Extensibility Rules
|
||||
|
||||
This slice should be built so future scene additions only need:
|
||||
- a new scene metadata file under the staged scene path
|
||||
- a matching skill package/tool
|
||||
- a dispatch-mode declaration/policy
|
||||
- optional aliases if the natural-language names are not sufficiently explicit
|
||||
|
||||
Avoid these anti-patterns:
|
||||
- per-scene `if user said X then do Y` branches scattered across runtime files
|
||||
- duplicating browser execution code for each scene
|
||||
- binding future scenes to Zhihu-specific assumptions
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Scene registry tests
|
||||
- load valid metadata for `fault-details-report`
|
||||
- load valid metadata for `95598-repair-city-dispatch`
|
||||
- ignore broken/missing scene files safely
|
||||
|
||||
### Matching tests
|
||||
- instruction variants match `fault-details-report`
|
||||
- instruction variants match `95598-repair-city-dispatch`
|
||||
- unrelated instructions do not match
|
||||
|
||||
### Instruction-building tests
|
||||
- `agent_browser` scene injects the required browser-first scene contract
|
||||
- unmatched instructions do not gain scene-specific constraints
|
||||
- Zhihu-specific instruction behavior remains unchanged
|
||||
|
||||
### Tool exposure tests
|
||||
- staged skills from the moved path are loaded into runtime
|
||||
- browser-backed tool names include:
|
||||
- `fault-details-report.collect_fault_details`
|
||||
- `95598-repair-city-dispatch.collect_repair_orders`
|
||||
|
||||
### Direct execution tests
|
||||
- `fault-details-report` direct route invokes the browser-backed tool path rather than bypassing the browser layer
|
||||
- direct route returns failure cleanly when tool execution fails
|
||||
|
||||
## Recommended First Implementation Slice
|
||||
|
||||
1. Add a tiny scene metadata loader and dispatch-mode policy module.
|
||||
2. Extend runtime path resolution so the moved staged skills/scenes are visible.
|
||||
3. Add deterministic scene matching for the two initial scenes.
|
||||
4. Implement `agent_browser` instruction injection for `95598-repair-city-dispatch`.
|
||||
5. Implement `direct_browser` execution for `fault-details-report` using the browser-backed skill path.
|
||||
6. Add focused tests for matching, loading, tool exposure, and direct-vs-agent behavior.
|
||||
|
||||
## Open Design Constraint Captured From Discussion
|
||||
|
||||
The user explicitly requires the following combined behavior:
|
||||
|
||||
- support both kinds of scene execution in the same architecture
|
||||
- one initial scene should be able to execute without the model
|
||||
- one initial scene should execute through the model
|
||||
- both must still use browser-internal execution methods like the Zhihu path
|
||||
- the design must stay extensible because more staged skills may be added under the same path later
|
||||
|
||||
This design is built around those exact constraints.
|
||||
@@ -1,219 +0,0 @@
|
||||
# Service Chat Web Console Design
|
||||
|
||||
## Background
|
||||
|
||||
The current natural-language entrypoint is the terminal client in `src/bin/sg_claw_client.rs`.
|
||||
That client already talks to the existing service websocket, sends `ClientMessage`, and prints
|
||||
`ServiceMessage` responses.
|
||||
|
||||
The repository also contains a separate browser callback helper at
|
||||
`http://127.0.0.1:61058/sgclaw/browser-helper.html`. That page is part of the browser backend
|
||||
execution path and must remain untouched.
|
||||
|
||||
For this slice, the authoritative boundary is:
|
||||
|
||||
- the new page may talk to the existing service websocket only
|
||||
- the page must not talk to the browser websocket directly
|
||||
- the page must not reuse or replace `browser-helper.html`
|
||||
- the page must not change the service protocol or browser execution logic
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Running `cargo run --bin sg_claw_client` and typing into stdin works, but it is inconvenient for
|
||||
routine usage. The user wants a simple local HTML page with a websocket connection field, a natural-
|
||||
language input box, and a send button.
|
||||
|
||||
The risk is scope drift: if the new page reaches into the browser-helper flow or changes backend
|
||||
logic, it could damage the working Zhihu/browser path.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a standalone local HTML console that connects to the existing service websocket and submits
|
||||
natural-language tasks using the current `submit_task` message shape.
|
||||
|
||||
The page should be usable without changing `sg_claw`, `sg_claw_client`, `browser-helper.html`, or
|
||||
any existing service/browser runtime behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This slice does not include:
|
||||
|
||||
- serving the page from the Rust service
|
||||
- changing `ClientMessage` or `ServiceMessage`
|
||||
- changing `src/service/server.rs`
|
||||
- changing `src/browser/callback_host.rs`
|
||||
- changing `src/browser/callback_backend.rs`
|
||||
- changing the helper-page bootstrap flow
|
||||
- adding authentication, persistence, or multi-session orchestration
|
||||
- replacing the terminal client
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Choose Option A: add one standalone HTML file that opens in a normal browser and talks to the
|
||||
existing service websocket at `ws://127.0.0.1:42321` by default.
|
||||
|
||||
Why this option:
|
||||
|
||||
- it is the narrowest possible change
|
||||
- it reuses the already-working service protocol
|
||||
- it does not alter the browser-helper path
|
||||
- it keeps all runtime ownership in the existing Rust service
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- extend `browser-helper.html` into a chat UI: wrong boundary; that page belongs to browser
|
||||
callback orchestration, not user task entry
|
||||
- add a new HTTP server inside `sg_claw`: unnecessary for the requested scope
|
||||
- replace the terminal client binary: not required; both clients can coexist
|
||||
|
||||
## File Placement
|
||||
|
||||
Create the page outside `frontend/runtime-host/`.
|
||||
|
||||
Chosen location:
|
||||
|
||||
- `frontend/service-console/sg_claw_service_console.html`
|
||||
|
||||
Reason:
|
||||
|
||||
- `frontend/runtime-host/` is reserved for SuperRPA runtime-host bundles
|
||||
- the new page is a standalone local tool, not a Chromium-hosted bundle
|
||||
- keeping it in its own directory makes the isolation explicit
|
||||
|
||||
## Page Architecture
|
||||
|
||||
The page is a single self-contained HTML file with inline CSS and inline JavaScript.
|
||||
No build step and no frontend framework are required.
|
||||
|
||||
The page has three UI regions:
|
||||
|
||||
1. Connection bar
|
||||
- websocket URL input
|
||||
- connect/disconnect button
|
||||
- current connection state label
|
||||
|
||||
2. Message stream
|
||||
- appends service logs in arrival order
|
||||
- distinguishes connection info, task logs, errors, and final completion
|
||||
- keeps the current session visible until the page is refreshed
|
||||
|
||||
3. Task composer
|
||||
- one textarea for natural-language input
|
||||
- one send button
|
||||
- send disabled while the websocket is disconnected
|
||||
- while a task is in flight, keep the composer enabled and let repeated submits surface the
|
||||
existing service-side `busy` response rather than adding a new frontend queue
|
||||
|
||||
## Protocol Contract
|
||||
|
||||
The page must reuse the existing service protocol exactly.
|
||||
|
||||
### Outbound message
|
||||
|
||||
When the user clicks send, the page sends:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "submit_task",
|
||||
"instruction": "<user input>",
|
||||
"conversation_id": "",
|
||||
"messages": [],
|
||||
"page_url": "",
|
||||
"page_title": ""
|
||||
}
|
||||
```
|
||||
|
||||
This matches the current terminal client shape in `src/bin/sg_claw_client.rs`.
|
||||
|
||||
### Inbound messages
|
||||
|
||||
The page displays these existing `ServiceMessage` variants:
|
||||
|
||||
- `status_changed` -> render as a compact connection/runtime status row
|
||||
- `log_entry` -> append as a chronological task log row
|
||||
- `task_complete` -> append as the terminal result row for that submission
|
||||
- `busy` -> append as a visible refusal/error row without automatic retry
|
||||
|
||||
No new message type is introduced.
|
||||
|
||||
## Interaction Flow
|
||||
|
||||
1. User opens the local HTML file with a normal browser, typically via `file://`.
|
||||
2. User connects to the service websocket.
|
||||
3. The page shows websocket connection status locally.
|
||||
4. User enters a natural-language instruction and clicks send.
|
||||
5. The page sends one `submit_task` payload over the service websocket.
|
||||
6. The service continues to execute tasks exactly as it already does.
|
||||
7. Incoming service messages are appended to the message stream.
|
||||
8. After `task_complete`, the websocket remains open so the user can send another task.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The page handles only UI-local failures:
|
||||
|
||||
- websocket connect failure -> show connection error and keep send disabled
|
||||
- websocket disconnect mid-session -> mark disconnected and require reconnect
|
||||
- empty instruction -> block send and show inline validation
|
||||
- `busy` response -> show as a visible service-side refusal without retry logic
|
||||
|
||||
The page does not add retries, protocol fallbacks, or browser-runtime recovery logic.
|
||||
|
||||
## Isolation From `browser-helper.html`
|
||||
|
||||
This is the critical constraint.
|
||||
|
||||
The new page must never:
|
||||
|
||||
- reference `/sgclaw/browser-helper.html`
|
||||
- reference `/sgclaw/callback/ready`
|
||||
- reference `/sgclaw/callback/events`
|
||||
- reference `/sgclaw/callback/commands/next`
|
||||
- reference `/sgclaw/callback/commands/ack`
|
||||
- connect to `ws://127.0.0.1:12345`
|
||||
|
||||
The only network target owned by the page is the service websocket, defaulting to
|
||||
`ws://127.0.0.1:42321`.
|
||||
|
||||
Because of that boundary, the page does not interfere with the helper-page bootstrap path.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
This slice stays minimal, so the automated guard is also minimal.
|
||||
|
||||
### Automated regression
|
||||
|
||||
Add one focused integration test in `tests/service_console_html_test.rs` that reads the standalone
|
||||
HTML source and asserts:
|
||||
|
||||
- the file exists at the agreed path and is resolved from `CARGO_MANIFEST_DIR` so the test is
|
||||
stable across working directories
|
||||
- it contains the service websocket default URL
|
||||
- it contains `submit_task` payload construction
|
||||
- it does not contain helper-page URLs or callback-host endpoints
|
||||
- it does not contain the browser websocket URL
|
||||
|
||||
This test is a scope guard, not a browser-E2E suite.
|
||||
|
||||
### Manual smoke verification
|
||||
|
||||
With the existing service binary running:
|
||||
|
||||
1. open the HTML file in a browser
|
||||
2. connect to the service websocket
|
||||
3. confirm local websocket open/close events and service `status_changed` messages both appear in the message stream
|
||||
4. submit a natural-language task
|
||||
5. confirm logs and completion render in the page
|
||||
6. confirm the helper-page path remains unchanged because the page never references it
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The slice is complete when all of the following are true:
|
||||
|
||||
1. `frontend/service-console/sg_claw_service_console.html` exists.
|
||||
2. The page connects to the existing service websocket without backend changes.
|
||||
3. The page sends the existing `submit_task` shape and receives existing `ServiceMessage` events.
|
||||
4. The page does not reference `browser-helper.html`, callback-host endpoints, or the browser
|
||||
websocket URL.
|
||||
5. Existing browser-helper logic remains untouched.
|
||||
6. The automated source guard passes.
|
||||
7. Manual smoke verification confirms a task can be submitted from the HTML page.
|
||||
@@ -1,373 +0,0 @@
|
||||
# Zhihu Hotlist Post-Export Auto-Open Design
|
||||
|
||||
## Background
|
||||
|
||||
The current Zhihu hotlist workflows already support two separate artifact outputs:
|
||||
|
||||
- `openxml_office` generates a local `.xlsx` file for hotlist export
|
||||
- `screen_html_export` generates a local `.html` dashboard for presentation
|
||||
|
||||
Today, the workflow stops after artifact generation and returns a summary string such as:
|
||||
|
||||
- `已导出知乎热榜 Excel <path>`
|
||||
- `已生成知乎热榜大屏 <path>`
|
||||
|
||||
That means the user still has to manually open the generated file.
|
||||
|
||||
The user wants one additional post-export action, but only one at a time:
|
||||
|
||||
1. for Excel-oriented tasks, automatically open the generated `.xlsx` with the system default spreadsheet application
|
||||
2. for dashboard-oriented tasks, automatically open the generated local dashboard HTML inside the running sgBrowser session
|
||||
|
||||
This is an exclusive choice, not a combined mode.
|
||||
|
||||
## Current Runtime Facts
|
||||
|
||||
The implementation must match the current browser/runtime boundary that already exists in the repo:
|
||||
|
||||
- the active service submit path in `src/service/server.rs` constructs `BrowserCallbackBackend`
|
||||
- `BrowserCallbackBackend::invoke(Action::Navigate, ...)` currently emits `sgBrowerserOpenPage`, which opens a new visible browser tab and keeps the helper page alive
|
||||
- `WsBrowserBackend::invoke(Action::Navigate, ...)` has different semantics and a different transport path from the callback-host service path
|
||||
- `MacPolicy::validate(...)` currently rejects empty or non-domain values, so a raw `file://...` navigation cannot pass through the normal domain validation path today
|
||||
- `screen_html_export` already returns `presentation.url`, which is the existing `file://` presentation URL contract for the generated dashboard
|
||||
|
||||
Those facts mean the design must not promise "replace the helper page" or "reuse identical tab behavior across all backends". The required success path for this slice is narrower: open the generated dashboard automatically in the current callback-host-backed sgBrowser service session without adding a new user-facing surface.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The existing workflow logic in `src/compat/workflow_executor.rs` already separates hotlist export from dashboard generation, but it treats both routes as artifact-only flows. The last mile is missing:
|
||||
|
||||
- the Excel route does not auto-open the generated file
|
||||
- the dashboard route does not consume the generated dashboard presentation URL and open it automatically in the browser runtime
|
||||
|
||||
The risk is scope drift. This change must not:
|
||||
|
||||
- turn Excel-open and dashboard-open into a combined workflow
|
||||
- add new help/help-like user-visible surfaces
|
||||
- move orchestration into `frontend/service-console/`
|
||||
- modify the websocket protocol
|
||||
- modify `browser-helper.html`
|
||||
- modify callback-host HTTP endpoints or their contracts
|
||||
- change the artifact-generation contract of `openxml_office` or `screen_html_export`
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the existing Zhihu hotlist post-export behavior so that:
|
||||
|
||||
- Excel tasks generate `.xlsx` and then auto-open it with the local system default spreadsheet application
|
||||
- dashboard tasks generate `.html` and then auto-open that generated dashboard inside sgBrowser
|
||||
|
||||
On the current callback-host service path, "inside sgBrowser" means opening the generated dashboard in a new visible browser tab while the helper page stays alive. The user does not need to open the file manually.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This slice does not include:
|
||||
|
||||
- opening Excel and dashboard in the same run
|
||||
- adding a new combined route that auto-opens both artifacts
|
||||
- adding any new help, helper, or user-visible assistance surface
|
||||
- modifying `frontend/service-console/sg_claw_service_console.html`
|
||||
- modifying `src/service/protocol.rs`
|
||||
- modifying `browser-helper.html`
|
||||
- modifying `/sgclaw/callback/*` contracts
|
||||
- turning the browser backend into a general-purpose local filesystem browser
|
||||
- changing the artifact-generation JSON contract of `openxml_office` or `screen_html_export`
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Keep the current two workflow routes, but add one route-specific post-export action to each:
|
||||
|
||||
- `ZhihuHotlistExportXlsx` -> generate `.xlsx`, then open it locally with the OS default app
|
||||
- `ZhihuHotlistScreen` -> generate `.html`, then open the generated dashboard presentation URL in the browser runtime
|
||||
|
||||
For the dashboard route, use the existing `presentation.url` returned by `screen_html_export` as the authoritative browser-open URL. Do not invent a separate normal-path URL conversion layer when the tool already returns the presentation contract.
|
||||
|
||||
The compat opener must emit one exact navigate request shape for this case.
|
||||
|
||||
- `action`: `Action::Navigate`
|
||||
- `expected_domain`: the exact literal `__sgclaw_local_dashboard__`
|
||||
- `params.url`: the exact `presentation.url` returned by `screen_html_export`
|
||||
- `params.sgclaw_local_dashboard_open.source`: the exact literal `compat.workflow_executor`
|
||||
- `params.sgclaw_local_dashboard_open.kind`: the exact literal `zhihu_hotlist_screen`
|
||||
- `params.sgclaw_local_dashboard_open.output_path`: the generated local dashboard artifact path
|
||||
- `params.sgclaw_local_dashboard_open.presentation_url`: the same `file://` URL stored in `params.url`
|
||||
|
||||
On the current callback-host-backed service path, only that exact request shape is approved for the local-dashboard special case. A plain `Action::Navigate` with an arbitrary `file://...` URL, or a request missing any one of the required marker fields above, must continue to be rejected.
|
||||
|
||||
Because normal `MacPolicy` domain validation cannot accept `file://...`, add a narrow local-dashboard presentation allowance in the browser backend/security boundary. That allowance must be limited to this one case:
|
||||
|
||||
- only for `Action::Navigate`
|
||||
- only for generated local dashboard presentation URLs
|
||||
- only for local HTML presentation, not arbitrary local paths or generic file browsing
|
||||
|
||||
Why this approach:
|
||||
|
||||
- it preserves the existing mutual exclusivity between Excel export and dashboard presentation
|
||||
- it keeps artifact generation in the existing tools
|
||||
- it keeps browser opening inside the existing browser backend boundary
|
||||
- it uses the existing `screen_html_export` presentation contract instead of duplicating it
|
||||
- it avoids pushing orchestration into the service console or protocol layer
|
||||
- it stays compatible with the current callback-host runtime, where visible navigation is new-tab based
|
||||
- it limits the guaranteed browser-open behavior in this slice to the callback-host-backed service path that the user is using today
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- add a combined "Excel + dashboard" route: explicitly rejected by user behavior
|
||||
- let `frontend/service-console/` decide when to open generated files: wrong layer; the console is only a submit/view surface
|
||||
- add help UI to expose output choices: explicitly unwanted by the user
|
||||
- change `browser-helper.html` so the helper page itself becomes the dashboard: this would break the current helper-page persistence model
|
||||
- promise a backend-agnostic "replace the current page" behavior: inaccurate because callback-host and websocket backends do not share identical navigate semantics
|
||||
- require the websocket backend to gain matching local-dashboard visible-open behavior in this slice: outside the narrow current-service-path goal
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### `src/compat/workflow_executor.rs`
|
||||
|
||||
Continue to own:
|
||||
|
||||
- route detection for Zhihu hotlist workflows
|
||||
- artifact generation orchestration
|
||||
- post-export summary construction
|
||||
|
||||
New responsibilities in this slice:
|
||||
|
||||
- parse the successful artifact payloads after `openxml_office` and `screen_html_export`
|
||||
- call the route-specific post-export opener only after artifact creation succeeds
|
||||
- for the dashboard route, consume `presentation.url` from the `screen_html_export` result payload
|
||||
- keep generation success and post-export open success/failure distinct in the returned summary
|
||||
|
||||
### `src/compat/artifact_open.rs`
|
||||
|
||||
New helper module to keep side effects out of `workflow_executor.rs`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- open a generated local `.xlsx` with the system default application
|
||||
- open a generated local dashboard presentation URL through the existing `BrowserBackend`
|
||||
- construct the exact approved dashboard navigate request shape used by this slice
|
||||
- define the narrow local-dashboard presentation token/constants used by the compat layer and backend compatibility path
|
||||
- return narrow success/failure results so `workflow_executor.rs` can produce accurate summaries
|
||||
|
||||
This module must stay small and focused. It is not a general launcher framework.
|
||||
|
||||
### `src/browser/callback_backend.rs`
|
||||
|
||||
New narrow responsibility in this slice:
|
||||
|
||||
- at the `BrowserCallbackBackend::invoke(Action::Navigate, params, expected_domain)` entrypoint, recognize only the exact approved local-dashboard presentation request shape
|
||||
- preserve the current callback-host behavior of using `sgBrowerserOpenPage`, which opens a new visible tab and keeps the helper page alive
|
||||
- reject local-file navigate attempts that do not include the exact post-export marker payload from the compat layer
|
||||
|
||||
This slice must not change callback-host polling, helper bootstrap, or callback endpoint behavior.
|
||||
|
||||
### `src/browser/ws_backend.rs`
|
||||
|
||||
No required behavior change in this slice.
|
||||
|
||||
Notes:
|
||||
|
||||
- websocket transport semantics differ from the callback-host service path
|
||||
- this spec does not require websocket backend local-dashboard visible-open support
|
||||
- websocket-specific parity can be designed later as a separate slice if needed
|
||||
|
||||
### `src/security/mac_policy.rs`
|
||||
|
||||
New narrow responsibility in this slice:
|
||||
|
||||
- expose a small validation helper for the approved local-dashboard presentation case
|
||||
- validate the real local presentation URL and artifact path for that case rather than treating `file://` as a normal allowed domain
|
||||
- keep the normal domain-based validation path unchanged for ordinary remote navigation
|
||||
|
||||
The policy layer must not turn `file://` into a generally allowed "domain". This is an explicit special case for generated local dashboard presentation only.
|
||||
|
||||
### `src/compat/mod.rs`
|
||||
|
||||
Expose the new helper module.
|
||||
|
||||
## Route Semantics
|
||||
|
||||
### Excel export route
|
||||
|
||||
Trigger examples:
|
||||
|
||||
- `读取知乎热榜数据,并导出 excel 文件`
|
||||
- `导出知乎热榜 xlsx`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. collect hotlist rows
|
||||
2. call `openxml_office`
|
||||
3. obtain `output_path`
|
||||
4. open the generated `.xlsx` using the local OS default spreadsheet application
|
||||
5. return a success summary reflecting both generation and open state
|
||||
|
||||
Summary rules:
|
||||
|
||||
- open succeeded -> `已导出并打开知乎热榜 Excel <path>`
|
||||
- open failed but file exists -> `已导出知乎热榜 Excel <path>,但自动打开失败:<reason>`
|
||||
|
||||
The workflow still counts artifact generation as successful even if the post-export open step fails.
|
||||
|
||||
### Dashboard route
|
||||
|
||||
Trigger examples:
|
||||
|
||||
- `读取知乎热榜数据并生成领导演示大屏`
|
||||
- `生成知乎热榜 dashboard`
|
||||
- `展示知乎热榜大屏`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. collect hotlist rows
|
||||
2. call `screen_html_export`
|
||||
3. obtain `output_path`
|
||||
4. obtain `presentation.url` from the tool result payload
|
||||
5. invoke the browser opener through the existing `BrowserBackend`
|
||||
6. return a success summary reflecting both generation and browser-open state
|
||||
|
||||
Summary rules:
|
||||
|
||||
- browser open succeeded -> `已在浏览器中打开知乎热榜大屏 <path>`
|
||||
- browser open failed but file exists -> `已生成知乎热榜大屏 <path>,但浏览器自动打开失败:<reason>`
|
||||
|
||||
The workflow still counts artifact generation as successful even if the browser-open step fails.
|
||||
|
||||
## Browser Boundary
|
||||
|
||||
This slice must preserve the current browser/runtime boundary.
|
||||
|
||||
Allowed:
|
||||
|
||||
- use the existing `BrowserBackend`
|
||||
- use the existing `Action::Navigate`
|
||||
- use the existing `screen_html_export` `presentation.url`
|
||||
- add a narrow compatibility path so local generated dashboard presentation can pass backend validation
|
||||
|
||||
Not allowed:
|
||||
|
||||
- change `browser-helper.html`
|
||||
- introduce a new callback-host endpoint
|
||||
- move file-opening responsibility into the frontend service console
|
||||
- add a new browser-side bootstrap flow
|
||||
- require websocket protocol changes
|
||||
|
||||
Important semantic note:
|
||||
|
||||
- on the current service callback-host path, dashboard open is expected to use `sgBrowerserOpenPage`, so the generated dashboard appears in a new visible browser tab while the helper page remains available for later tasks
|
||||
- websocket-backed browser execution may continue to differ; this slice does not require matching visible-open semantics there
|
||||
|
||||
## Local Dashboard Presentation Allowance
|
||||
|
||||
The local dashboard browser-open path needs an explicit narrow validation rule because `file://...` cannot pass the normal domain allowlist.
|
||||
|
||||
Requirements for the narrow allowance:
|
||||
|
||||
- only approved for `Action::Navigate`
|
||||
- only approved for the exact compat marker payload described above
|
||||
- only approved for generated local dashboard presentation URLs
|
||||
- only approved when the validated local artifact path points to the generated dashboard HTML artifact returned by the same `screen_html_export` success payload
|
||||
- only approved for local HTML presentation, not arbitrary executables or unrelated local files
|
||||
- ordinary remote navigation must continue using the existing `MacPolicy::validate(...)` domain rules unchanged
|
||||
|
||||
This keeps the behavior small and auditable while still satisfying the user-visible dashboard auto-open requirement.
|
||||
|
||||
## Local File Opening Boundary
|
||||
|
||||
The Excel auto-open action is a local runtime side effect, not a browser action.
|
||||
|
||||
Requirements:
|
||||
|
||||
- use the system default application for `.xlsx`
|
||||
- support the current Windows environment first
|
||||
- keep the implementation minimal and focused on the generated artifact path
|
||||
|
||||
Not required in this slice:
|
||||
|
||||
- a cross-platform abstraction beyond the minimal shape needed for the current repo environment
|
||||
- opening arbitrary user-selected files
|
||||
- exposing local file opening to the service websocket protocol
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Excel route
|
||||
|
||||
If `.xlsx` generation fails:
|
||||
|
||||
- return the existing export failure
|
||||
|
||||
If `.xlsx` generation succeeds but auto-open fails:
|
||||
|
||||
- keep the artifact path in the summary
|
||||
- mark only the auto-open step as failed
|
||||
- do not delete the generated file
|
||||
|
||||
### Dashboard route
|
||||
|
||||
If `.html` generation fails:
|
||||
|
||||
- return the existing screen export failure
|
||||
|
||||
If `.html` generation succeeds but browser open fails:
|
||||
|
||||
- keep the artifact path in the summary
|
||||
- mark only the browser-open step as failed
|
||||
- do not delete the generated file
|
||||
|
||||
If the tool result is missing `presentation.url`:
|
||||
|
||||
- treat that as a protocol error in the post-export open step for this route
|
||||
- keep the generated artifact path in the summary if it is available
|
||||
- do not silently invent a different contract in the normal path
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Workflow tests
|
||||
|
||||
Update or add focused workflow coverage so that:
|
||||
|
||||
- Excel workflow still calls `openxml_office`
|
||||
- dashboard workflow still calls `screen_html_export`
|
||||
- the two routes remain mutually exclusive
|
||||
- dashboard workflow consumes the tool's existing `presentation.url`
|
||||
|
||||
### New Excel post-export test
|
||||
|
||||
Add a focused regression proving:
|
||||
|
||||
- an Excel-oriented hotlist request triggers export
|
||||
- the generated `.xlsx` path is passed into the local default-app opener
|
||||
- no browser dashboard navigate is triggered for that route
|
||||
|
||||
### New dashboard post-export test
|
||||
|
||||
Add a focused regression proving:
|
||||
|
||||
- a dashboard-oriented hotlist request triggers HTML generation
|
||||
- the generated tool payload `presentation.url` is used for browser open
|
||||
- the browser backend receives a local-dashboard navigate request through the approved compat path
|
||||
- no local spreadsheet opener is triggered for that route
|
||||
|
||||
### Backend/security compatibility tests
|
||||
|
||||
Add focused regressions proving:
|
||||
|
||||
- callback backend accepts the approved local-dashboard navigate case and still emits `sgBrowerserOpenPage`
|
||||
- the narrow local-dashboard allowance rejects non-local or malformed URLs
|
||||
- ordinary domain validation behavior remains unchanged for normal remote navigation
|
||||
|
||||
### Existing boundary tests remain unchanged
|
||||
|
||||
Do not change the service-console boundary guard. This slice is runtime behavior only.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The slice is complete when all of the following are true:
|
||||
|
||||
1. Excel hotlist export still generates a local `.xlsx` artifact.
|
||||
2. Excel hotlist export auto-opens that `.xlsx` with the system default spreadsheet application.
|
||||
3. Dashboard hotlist export still generates a local `.html` artifact.
|
||||
4. Dashboard hotlist export consumes the existing `screen_html_export` `presentation.url` and auto-opens it in the current callback-host-backed sgBrowser service session.
|
||||
5. On the current callback-host service path, the dashboard opens automatically in a visible browser tab without breaking the helper-page runtime.
|
||||
6. Excel-open and dashboard-open remain separate user-chosen flows, not a combined mode.
|
||||
7. No new help/help-like user-visible surface is added.
|
||||
8. The service console, websocket protocol, `browser-helper.html`, and callback-host endpoint surface remain untouched.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Config-Owned Direct Skill Dispatch Design
|
||||
|
||||
**Goal:** Preserve the current minimal submit flow where sgClaw accepts natural-language input, directly invokes one configured staged browser skill without calling an LLM, and keeps dispatch ownership in sgClaw configuration rather than external skill metadata.
|
||||
|
||||
**Status:** Approved design direction for the next slice. The current minimal direct-submit path already works; this document records the ownership boundary that future dispatch-policy work should follow.
|
||||
|
||||
---
|
||||
|
||||
## Decision Summary
|
||||
|
||||
1. Keep direct-skill selection in sgClaw configuration.
|
||||
2. Continue using `skillsDir` plus `directSubmitSkill` as the only control surface for the no-LLM direct path.
|
||||
3. Do not add sgClaw-specific dispatch fields to files under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` in this slice.
|
||||
4. Keep the currently bound skill as `fault-details-report.collect_fault_details`.
|
||||
5. When dispatch expands beyond one fixed skill, add the next policy layer on the sgClaw side first, not in `scene.json` or `SKILL.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Current Minimal Flow
|
||||
|
||||
The intended user experience stays unchanged:
|
||||
- the user types natural language into the input box
|
||||
- sgClaw receives `BrowserMessage::SubmitTask`
|
||||
- sgClaw loads runtime config
|
||||
- if `directSubmitSkill` is configured, sgClaw bypasses LLM routing and directly resolves the configured staged skill from `skillsDir`
|
||||
- sgClaw executes the target `browser_script` tool through the browser runtime and returns the result
|
||||
- if `directSubmitSkill` is absent, sgClaw falls back to the existing orchestration / compat behavior
|
||||
|
||||
This keeps the first slice small while preserving a clear seam for future expansion.
|
||||
|
||||
---
|
||||
|
||||
## Ownership Boundary
|
||||
|
||||
### sgClaw configuration owns dispatch choice
|
||||
|
||||
sgClaw configuration is responsible for deciding whether submit-task should bypass the LLM path and which direct skill should run.
|
||||
|
||||
For the current slice, that means:
|
||||
- `skillsDir` tells sgClaw where to load staged skills from
|
||||
- `directSubmitSkill` tells sgClaw which `skill.tool` should be used for the direct path
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"skillsDir": "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging",
|
||||
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||
}
|
||||
```
|
||||
|
||||
### skill_staging owns skill identity and execution assets
|
||||
|
||||
Files under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging` remain responsible for describing the skill package, tool identity, and browser-script implementation.
|
||||
|
||||
For the current bound skill:
|
||||
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/scenes/fault-details-report/scene.json`
|
||||
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/SKILL.toml`
|
||||
- `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
|
||||
These files already provide enough information for sgClaw to locate the package and run the tool. This slice does not add a new dispatch field inside them.
|
||||
|
||||
---
|
||||
|
||||
## Why This Boundary Is Recommended
|
||||
|
||||
### One source of truth for routing
|
||||
|
||||
If sgClaw configuration owns the direct-skill decision, the operator can switch the direct skill by changing config only. There is no need to edit code and no need to mutate external skill assets just to change routing.
|
||||
|
||||
### Avoid freezing external manifest semantics too early
|
||||
|
||||
`skill_staging` is an external skill asset set. Adding sgClaw-specific dispatch metadata now would couple the staged-skill format to one integration strategy before the policy model is stable.
|
||||
|
||||
### Preserve a clean migration path
|
||||
|
||||
The current minimal path is intentionally narrow: one fixed configured direct skill, no LLM dispatch, no per-skill policy registry yet. Keeping dispatch control in sgClaw makes it easier to add a broader policy layer later without rewriting the staged-skill package format first.
|
||||
|
||||
---
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
This design does not do the following:
|
||||
- redesign the submit-task protocol
|
||||
- move dispatch control into `scene.json` or `SKILL.toml`
|
||||
- require every staged skill to declare `direct_browser` or `llm_agent` right now
|
||||
- expand the current direct path into generic natural-language intent classification
|
||||
- change the browser-script execution model
|
||||
- change the current fallback orchestration / compat execution semantics when `directSubmitSkill` is not configured
|
||||
|
||||
---
|
||||
|
||||
## Current Skill Contract
|
||||
|
||||
The current direct path remains intentionally deterministic.
|
||||
|
||||
For `fault-details-report.collect_fault_details`, sgClaw derives only the minimum required arguments:
|
||||
- `expected_domain` from the current `page_url`
|
||||
- `period` from an explicit `YYYY-MM` token in the user's natural-language input
|
||||
|
||||
That means the UX still looks like natural-language submission, but the runtime does not ask an LLM to infer intent or invent missing parameters. If the period is missing, sgClaw should return a clear error instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
## Future Dispatch Policy Direction
|
||||
|
||||
When more than one staged skill needs routing control, the next layer should still begin on the sgClaw side.
|
||||
|
||||
Recommended direction:
|
||||
- keep `directSubmitSkill` as the current bootstrap switch for the minimal fixed-skill path
|
||||
- introduce a sgClaw-owned registry or config mapping that can later express `skill.tool -> direct_browser | llm_agent`
|
||||
- keep external skill manifests unchanged until the policy surface proves stable in real use
|
||||
|
||||
Only after the routing model is stable should we consider whether external skill metadata needs a default dispatch hint.
|
||||
|
||||
---
|
||||
|
||||
## Resulting Design Rule
|
||||
|
||||
For this project, the direct-skill decision remains config-owned:
|
||||
- sgClaw config decides whether submit-task bypasses the LLM path
|
||||
- staged skill metadata identifies what the skill is and how its browser tool runs
|
||||
- future per-skill dispatch policy should be added in sgClaw first, not in `skill_staging`
|
||||
|
||||
This is the approved baseline for the next dispatch-policy slice.
|
||||
@@ -0,0 +1,495 @@
|
||||
# Fault Details Full Skill Alignment Design
|
||||
|
||||
**Goal:** Upgrade `fault-details-report.collect_fault_details` from an empty artifact shell into a real staged business skill that matches the original fault-details package's collection, normalization, summary, export, and report-history behavior, while keeping direct-skill routing config-owned in `claw-new`.
|
||||
|
||||
**Status:** Approved design direction for the next remediation slice.
|
||||
|
||||
---
|
||||
|
||||
## Decision Summary
|
||||
|
||||
1. Keep direct-skill selection in `claw-new` via `skillsDir` + `directSubmitSkill`; do not move dispatch ownership into `skill_staging` manifests.
|
||||
2. Put the fault-details business logic in `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`, not in `claw-new`.
|
||||
3. Align the staged skill with the original package's real behavior: query raw rows, normalize detail columns, derive summary rows, call localhost export, and write report history.
|
||||
4. Keep the current browser-execution seam narrow: use the existing `browser_script` / browser-eval path, not a new browser protocol or new opcodes.
|
||||
5. Add a narrow artifact interpreter in `claw-new` so structured fault-results map cleanly to `TaskComplete.success` and a readable completion summary.
|
||||
|
||||
---
|
||||
|
||||
## Why This Slice Exists
|
||||
|
||||
The current staged skill contract and the current staged skill implementation do not match.
|
||||
|
||||
### What the original package actually does
|
||||
|
||||
The original package under `D:/desk/智能体资料/大四区报告监测项/故障明细` does all of the following:
|
||||
|
||||
- reads the selected date range from the page UI
|
||||
- queries the D4 repair-order data source
|
||||
- filters and normalizes raw rows into the canonical detail export schema
|
||||
- derives grouped summary rows by `gds`
|
||||
- calls `http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSXS`
|
||||
- auto-opens/downloads the generated file
|
||||
- writes report history through `http://localhost:13313/ReportServices/Api/setReportLog`
|
||||
|
||||
### What the staged skill currently does
|
||||
|
||||
The current staged `collect_fault_details.js` only returns an empty `report-artifact` shell with empty `rows` and empty summary `sections`.
|
||||
|
||||
It also still uses a Node-style export shape instead of the browser-eval entrypoint shape that the current `browser_script` runtime expects. In practice, this means the staged script is not yet aligned with the real runtime contract even before business behavior is considered.
|
||||
|
||||
This slice closes that gap by making the staged skill actually perform the work the original package performs, but through the current sgClaw direct-skill runtime.
|
||||
|
||||
---
|
||||
|
||||
## Design Rules
|
||||
|
||||
### 1. `claw-new` owns routing, not business transforms
|
||||
|
||||
`claw-new` stays responsible for:
|
||||
|
||||
- loading config
|
||||
- deciding whether submit-task takes the direct-skill path
|
||||
- resolving the configured staged skill
|
||||
- executing the staged browser-script tool
|
||||
- turning the returned artifact into `TaskComplete.success` + human-readable summary
|
||||
|
||||
`claw-new` must **not** become the place where the original fault classification table, detail-row field mapping, or summary aggregation rules are reimplemented.
|
||||
|
||||
### 2. `skill_staging` owns fault-details business behavior
|
||||
|
||||
The staged skill package owns:
|
||||
|
||||
- query orchestration inside the browser page context
|
||||
- raw-row extraction
|
||||
- canonical detail-row normalization
|
||||
- classification and derived fields
|
||||
- summary-sheet derivation
|
||||
- localhost export request
|
||||
- localhost report-log request
|
||||
- structured result payload
|
||||
|
||||
### 3. Keep the current browser seam narrow
|
||||
|
||||
Do not introduce a new browser bridge, callback protocol, or skill-specific browser opcode for this slice.
|
||||
|
||||
The implementation should continue using the current `browser_script` execution seam already wired through `claw-new/src/compat/browser_script_skill_tool.rs` and `claw-new/src/compat/direct_skill_runtime.rs`.
|
||||
|
||||
### 4. Match business behavior, not the original shell verbatim
|
||||
|
||||
The original package is a local HTML/Vue shell that uses `BrowserAction(...)`, timers, and hidden-browser choreography. That shell does **not** need to be recreated inside `claw-new`.
|
||||
|
||||
What must be preserved is the business outcome:
|
||||
|
||||
- same canonical detail columns
|
||||
- same key field mappings
|
||||
- same classification rules
|
||||
- same summary metrics
|
||||
- same downstream export/history behavior
|
||||
- same distinction between empty, partial, blocked, and failed work
|
||||
|
||||
---
|
||||
|
||||
## Ownership Boundary and Landing Zones
|
||||
|
||||
### Staged skill changes
|
||||
|
||||
These changes land in `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`.
|
||||
|
||||
Primary files:
|
||||
|
||||
- `skills/fault-details-report/scripts/collect_fault_details.js`
|
||||
- becomes the real browser-eval entrypoint
|
||||
- must directly `return` the final structured artifact from the wrapped browser script
|
||||
- may contain internal helper functions, but should remain self-contained for the current runtime
|
||||
- `skills/fault-details-report/SKILL.toml`
|
||||
- keep `browser_script`
|
||||
- tighten the tool description so it matches the real behavior
|
||||
- do not turn `SKILL.toml` into the source of truth for classification rules or routing policy
|
||||
- `skills/fault-details-report/SKILL.md`
|
||||
- align the written contract with the implemented runtime behavior
|
||||
- `skills/fault-details-report/references/collection-flow.md`
|
||||
- align the staged flow with the implemented query/export/history sequence
|
||||
- `skills/fault-details-report/references/data-quality.md`
|
||||
- stay authoritative for canonical columns, required fields, classification tables, `qxxcjl`-based reason heuristics, summary rules, and partial semantics
|
||||
- `scenes/fault-details-report/scene.json`
|
||||
- keep the scene contract aligned with the actual output and state semantics
|
||||
- do not move classification or routing policy into scene metadata
|
||||
|
||||
### Caller/runtime changes
|
||||
|
||||
These changes land in `D:/data/ideaSpace/rust/sgClaw/claw-new`.
|
||||
|
||||
Primary files:
|
||||
|
||||
- `src/compat/direct_skill_runtime.rs`
|
||||
- keep configured direct-skill execution here
|
||||
- add narrow structured-artifact interpretation after the browser-script returns
|
||||
- `src/agent/mod.rs`
|
||||
- keep the current direct-submit routing seam here
|
||||
- do not add fault-specific business logic here
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- keep the browser-script contract strict: browser-eval entrypoint, no Node-only assumptions
|
||||
- `tests/agent_runtime_test.rs`
|
||||
- direct-submit path and result-surface regressions
|
||||
- `tests/browser_script_skill_tool_test.rs`
|
||||
- browser-script execution-shape regressions
|
||||
|
||||
If a new helper is needed in `claw-new`, it should be a narrow artifact-format/parser helper, not a new business-rules module.
|
||||
|
||||
---
|
||||
|
||||
## Target Runtime Flow
|
||||
|
||||
### Step 1: Submit-task stays config-owned
|
||||
|
||||
The user still types natural language into the current sgClaw input.
|
||||
|
||||
`claw-new`:
|
||||
|
||||
- receives `BrowserMessage::SubmitTask`
|
||||
- loads `SgClawSettings`
|
||||
- sees `directSubmitSkill = "fault-details-report.collect_fault_details"`
|
||||
- bypasses LLM routing exactly as it does now
|
||||
- resolves the staged skill from `skillsDir`
|
||||
|
||||
This preserves the already approved config-owned routing boundary.
|
||||
|
||||
### Step 2: Browser-script tool executes as a true browser entrypoint
|
||||
|
||||
`collect_fault_details.js` must be shaped for the current runtime:
|
||||
|
||||
- the script runs inside the current browser page context through `eval`
|
||||
- it must not rely on `module.exports`
|
||||
- it must directly `return collectFaultDetails(args)` from the wrapped script body
|
||||
|
||||
This is required because the current sgClaw browser-script runtime reads one script file and wraps it in a browser-side IIFE.
|
||||
|
||||
### Step 3: The skill reads the page-selected time range
|
||||
|
||||
The source-of-truth query window should come from the current page state, matching the original package behavior.
|
||||
|
||||
Design rule:
|
||||
|
||||
- read the selected start and end time from the business page controls or page state
|
||||
- include that exact selected range in the returned artifact
|
||||
- keep `period` as a bootstrap label from `claw-new`, not as a license to silently guess a different business range
|
||||
|
||||
Compatibility rule with the current direct-submit seam:
|
||||
|
||||
- the current `claw-new` direct path still requires an explicit `YYYY-MM` token in the user's instruction in order to enter the configured direct-skill flow
|
||||
- that requirement remains in place for this slice
|
||||
- once inside the skill, the browser page's selected start/end range is the source of truth for collection
|
||||
- the returned artifact should include both the user-visible `period` label and the exact selected page range so mismatches are observable instead of hidden
|
||||
|
||||
If the page-selected range cannot be read reliably, the skill should return `blocked` instead of inventing a month-wide query window from `period` alone.
|
||||
|
||||
### Step 4: The skill collects raw rows and normalizes detail fields
|
||||
|
||||
The staged skill must reproduce the original package's detail normalization logic inside the browser-executed script.
|
||||
|
||||
That includes preserving the canonical detail schema from the original `excleIni[0].cols`, including the key transforms already present in the original package, such as:
|
||||
|
||||
- `slsj = bxsj`
|
||||
- `gssgs = "甘肃省电力公司"`
|
||||
- `sgs` derived from the current company/city context
|
||||
- `gddw = maintOrgName`
|
||||
- `gds = maintGroupName`
|
||||
- `clzt = "处理完成"`
|
||||
- `bdz = bdzMc`
|
||||
- `line = xlmc10`
|
||||
- `pb = byqmc`
|
||||
|
||||
The staged skill must also port the original classification/derivation logic that fills:
|
||||
|
||||
- `sxfl1`
|
||||
- `sxfl2`
|
||||
- `sxfl3`
|
||||
- `gzsb`
|
||||
- `gzyy`
|
||||
|
||||
That includes the original matching table and the `qxxcjl`-based text extraction heuristics that derive the fault reason.
|
||||
|
||||
### Step 5: The skill derives summary rows from normalized detail rows
|
||||
|
||||
The staged skill must derive the summary sheet from grouped detail rows, keyed around the same business totals the original package computes.
|
||||
|
||||
At minimum that includes:
|
||||
|
||||
- `index`
|
||||
- `gsName`
|
||||
- `fwDept`
|
||||
- `className`
|
||||
- `allCount`
|
||||
- `wxCount`
|
||||
- `khcCount`
|
||||
- `sbdSbCount`
|
||||
- `gyGzCount`
|
||||
- `dyGzCount`
|
||||
- `tqdzCount`
|
||||
- `tqbxCount`
|
||||
- `dyxlCount`
|
||||
- `bqxCount`
|
||||
- `jllCount`
|
||||
- `bhxCount`
|
||||
- `qftdCount`
|
||||
|
||||
The summary derivation must stay in the staged skill so the same package can later be routed by LLM without moving business logic back into `claw-new`.
|
||||
|
||||
### Step 6: The skill performs downstream export and report logging
|
||||
|
||||
After detail rows and summary rows are available, the staged skill should reproduce the original package's downstream behavior:
|
||||
|
||||
- build the export payload for `faultDetailsExportXLSXS`
|
||||
- call the localhost export endpoint
|
||||
- capture the returned export path/URL
|
||||
- write report history via `setReportLog`
|
||||
|
||||
Important boundary:
|
||||
|
||||
- export/report-log are downstream side effects
|
||||
- they do not redefine whether collection itself succeeded
|
||||
- if collection succeeds but export/logging fails, the result is `partial`, not a full collection failure
|
||||
- auto-opening/downloading the exported file is out of scope for this slice; this slice records the export path/result in the artifact but does not add new opener/UI behavior in `claw-new`
|
||||
|
||||
### Step 7: The skill returns one structured artifact
|
||||
|
||||
The staged skill should return one self-describing JSON artifact containing:
|
||||
|
||||
- business identity (`type`, `report_name`)
|
||||
- selected period label
|
||||
- exact selected start/end range
|
||||
- canonical detail columns + normalized rows
|
||||
- summary section columns + rows
|
||||
- counts
|
||||
- business status
|
||||
- partial reasons if any
|
||||
- downstream export outcome
|
||||
- downstream report-log outcome
|
||||
|
||||
### Step 8: `claw-new` interprets the artifact, not the business rules
|
||||
|
||||
After the browser-script returns, `claw-new` should parse the JSON artifact and map it into final submit-task behavior.
|
||||
|
||||
Recommended mapping:
|
||||
|
||||
- `status = ok` -> `TaskComplete.success = true`
|
||||
- `status = partial` -> `TaskComplete.success = true`, with warnings in summary
|
||||
- `status = empty` -> `TaskComplete.success = true`, clearly reported as empty-result
|
||||
- `status = blocked` -> `TaskComplete.success = false`
|
||||
- `status = error` -> `TaskComplete.success = false`
|
||||
|
||||
This keeps business classification in the staged skill while preventing false-positive success in the direct path.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Contract
|
||||
|
||||
The returned payload should stay `type = "report-artifact"`, but it must become rich enough to describe the real run.
|
||||
|
||||
Recommended contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"period": "2026-03",
|
||||
"selected_range": {
|
||||
"start": "2026-03-08 16:00:00",
|
||||
"end": "2026-03-09 16:00:00"
|
||||
},
|
||||
"columns": ["qxdbh", "gssgs", "sgs", "gddw", "gds", "slsj", "yjflMc", "ejflMc", "sjflMc", "gzms", "yhbh", "yhmc", "lxr", "gzdd", "lxdh", "bxsj", "gdsj", "clzt", "qxxcjl", "bdz", "line", "pb", "sxfl1", "sxfl2", "sxfl3", "gzsb", "gzyy", "bz"],
|
||||
"rows": [],
|
||||
"sections": [
|
||||
{
|
||||
"name": "summary-sheet",
|
||||
"columns": ["index", "gsName", "fwDept", "className", "allCount", "wxCount", "khcCount", "sbdSbCount", "gyGzCount", "dyGzCount", "tqdzCount", "tqbxCount", "dyxlCount", "bqxCount", "jllCount", "bhxCount", "qftdCount"],
|
||||
"rows": []
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"detail_rows": 0,
|
||||
"summary_rows": 0
|
||||
},
|
||||
"status": "ok",
|
||||
"partial_reasons": [],
|
||||
"downstream": {
|
||||
"export": {
|
||||
"attempted": true,
|
||||
"success": true,
|
||||
"path": "http://localhost:13313/.../fault-details.xlsx"
|
||||
},
|
||||
"report_log": {
|
||||
"attempted": true,
|
||||
"success": true,
|
||||
"report_name": "国网XX故障报修明细表(03月09日)",
|
||||
"path": "http://localhost:13313/.../fault-details.xlsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contract notes
|
||||
|
||||
- `rows` is the canonical returned detail table, not the export-service transport payload.
|
||||
- If the export service still requires a placeholder row for an empty spreadsheet, that placeholder should be synthesized only for the downstream export call, not as the canonical returned `rows` contract.
|
||||
- `counts` should be computed from the canonical returned tables.
|
||||
- `selected_range`, `columns`, `sections`, `counts`, `status`, and `partial_reasons` should always be present for `ok`, `partial`, and `empty`.
|
||||
- For `blocked` and `error`, the artifact should still include `type`, `report_name`, `period`, `status`, and `partial_reasons`; `selected_range`, `columns`, `sections`, and `counts` should be included whenever they were already known before the failure point.
|
||||
- `downstream` should be omitted only when export/report-log were not attempted yet; otherwise include it with `attempted` / `success` flags and any available path or failure detail.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling and Status Semantics
|
||||
|
||||
### `ok`
|
||||
|
||||
Use `ok` when all of the following are true:
|
||||
|
||||
- raw collection succeeded
|
||||
- required detail-field normalization succeeded
|
||||
- summary derivation succeeded
|
||||
- export succeeded
|
||||
- report-log write succeeded
|
||||
|
||||
### `partial`
|
||||
|
||||
Use `partial` when detail collection succeeded but at least one downstream stage degraded, including:
|
||||
|
||||
- one or more required fields could not be normalized, but the row set still remains exportable and summary derivation can proceed with explicit gaps recorded
|
||||
- summary derivation was incomplete, but the detail table is still available
|
||||
- export failed after rows were available
|
||||
- report-log write failed after rows/export were available
|
||||
|
||||
Escalation rule:
|
||||
|
||||
- if the raw query succeeds but required fields are missing so broadly that the canonical detail table cannot be produced at all, use `error`, not `partial`
|
||||
- if summary derivation cannot even start because the normalized detail rows are structurally unusable, use `error`, not `partial`
|
||||
|
||||
`partial_reasons` must name the degraded stage instead of hiding it.
|
||||
|
||||
### `empty`
|
||||
|
||||
Use `empty` when:
|
||||
|
||||
- the query succeeds for the selected range
|
||||
- zero real detail rows match
|
||||
|
||||
This is not a failure.
|
||||
|
||||
If the business flow still wants an empty export file or placeholder export payload, that happens downstream without changing the semantic meaning of the result.
|
||||
|
||||
### `blocked`
|
||||
|
||||
Use `blocked` when the page/session preconditions are not met, for example:
|
||||
|
||||
- expected page/session is not available
|
||||
- required page controls cannot be read
|
||||
- login/session state is missing or expired
|
||||
- required browser-visible APIs are unavailable in the current page context
|
||||
|
||||
### `error`
|
||||
|
||||
Use `error` when the run starts but fails due to operational or parsing problems, for example:
|
||||
|
||||
- request failure
|
||||
- page script failure
|
||||
- raw response parse failure
|
||||
- malformed export response
|
||||
|
||||
### `claw-new` completion mapping
|
||||
|
||||
`claw-new` should convert structured status into final submit completion behavior:
|
||||
|
||||
- `ok` / `partial` / `empty`: return a success completion with a concise human summary
|
||||
- `blocked` / `error`: return a failed completion with a concise human summary
|
||||
|
||||
This avoids the current risk where a structured error-like payload could still be surfaced as a nominal success string.
|
||||
|
||||
---
|
||||
|
||||
## Testing and Acceptance Strategy
|
||||
|
||||
### Skill-side deterministic coverage
|
||||
|
||||
Add deterministic coverage around the staged skill's business logic in `skill_staging` for:
|
||||
|
||||
- canonical detail field mapping
|
||||
- classification table parity
|
||||
- `gzyy` extraction heuristics
|
||||
- summary aggregation parity
|
||||
- empty-result handling
|
||||
- partial-result generation when downstream export/logging fails
|
||||
- browser-script entrypoint shape (`return ...`, not `module.exports`)
|
||||
|
||||
The classification/summary tests should use fixed raw-row fixtures so the business rules are validated without a live browser session.
|
||||
|
||||
### `claw-new` runtime regressions
|
||||
|
||||
Add Rust coverage in `claw-new` for:
|
||||
|
||||
- direct-submit success with a populated `report-artifact`
|
||||
- `partial` artifact mapping to `TaskComplete.success = true`
|
||||
- `empty` artifact mapping to `TaskComplete.success = true`
|
||||
- `blocked` / `error` artifact mapping to `TaskComplete.success = false`
|
||||
- browser-script helper behavior for a real browser-eval return payload
|
||||
|
||||
### Manual acceptance
|
||||
|
||||
The live manual acceptance bar for this slice should be:
|
||||
|
||||
1. Configure `skillsDir` to the staged skill root and `directSubmitSkill` to `fault-details-report.collect_fault_details`.
|
||||
2. Attach sgClaw to the real target browser page/session.
|
||||
3. Submit a natural-language fault-details request without LLM routing.
|
||||
4. Verify the staged skill:
|
||||
- reads the selected page range
|
||||
- queries real fault rows
|
||||
- produces populated detail rows
|
||||
- produces populated summary rows
|
||||
- exports the workbook through localhost
|
||||
- writes report history
|
||||
5. Verify the final sgClaw completion message reports the correct status, counts, and downstream file/log outcome.
|
||||
|
||||
### Acceptance matrix
|
||||
|
||||
At minimum, acceptance should cover:
|
||||
|
||||
- normal populated result
|
||||
- empty result with no matching rows
|
||||
- partial result where export or report-log fails after collection
|
||||
- blocked result where page/session preconditions are missing
|
||||
- error result where parsing/query execution fails
|
||||
|
||||
---
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
This slice does **not**:
|
||||
|
||||
- move routing ownership out of `claw-new`
|
||||
- require LLM routing to be available first
|
||||
- add per-skill dispatch metadata to external manifests for routing policy
|
||||
- introduce a new browser protocol or browser opcode
|
||||
- recreate the original Vue shell inside `claw-new`
|
||||
- move fault classification logic into Rust
|
||||
- redesign the submit-task protocol beyond better interpretation of the returned artifact
|
||||
|
||||
---
|
||||
|
||||
## Resulting Design Rule
|
||||
|
||||
For the fault-details path:
|
||||
|
||||
- `claw-new` decides whether to invoke the fixed staged skill
|
||||
- the staged skill performs the real fault business workflow
|
||||
- the staged skill returns a structured artifact that describes collection + downstream outcomes
|
||||
- `claw-new` interprets that artifact for submit-task success/failure and summary output
|
||||
|
||||
That keeps routing config-owned, keeps business logic with the staged skill, and makes `fault-details-report.collect_fault_details` ready for both the current no-LLM path and a later LLM-routed path.
|
||||
|
||||
---
|
||||
|
||||
## Document Landing Zones
|
||||
|
||||
- Approved spec: `docs/superpowers/specs/2026-04-10-fault-details-full-skill-alignment-design.md`
|
||||
- Follow-up implementation plan: `docs/superpowers/plans/2026-04-10-fault-details-full-skill-alignment-plan.md`
|
||||
@@ -0,0 +1,618 @@
|
||||
# TQ Line-Loss Deterministic Skill Design
|
||||
|
||||
**Goal:** Add a staged business skill for `台区线损大数据-月_周累计线损率统计分析` and a deterministic natural-language routing path in `claw-new` that can bypass LLM when the instruction ends with `。。。`, while preserving the existing Zhihu hotlist behavior and keeping the execution seam pipe-first but ws-ready.
|
||||
|
||||
**Status:** Approved design direction for implementation planning.
|
||||
|
||||
---
|
||||
|
||||
## Decision Summary
|
||||
|
||||
1. Add a new staged skill package `tq-lineloss-report` under `D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills/`, following the same packaging discipline as `fault-details-report`.
|
||||
2. In `claw-new`, add a deterministic submit path triggered only when the instruction ends with the three-Chinese-dot suffix `。。。`.
|
||||
3. In deterministic mode, route only through a fixed whitelist of staged skills; for this slice the new target is `tq-lineloss-report.collect_lineloss`.
|
||||
4. Deterministic mode must extract business parameters from natural language without using an LLM: company/unit, month-vs-week mode, and period text.
|
||||
5. Parsed natural-language parameters are not the final backend parameters. They must be normalized into the canonical codes required by the source page / source APIs (for example company code and period mode code).
|
||||
6. If required parameters are missing or ambiguous, the runtime must stop and ask the user to provide them explicitly. It must **not** silently fall back to page defaults in this slice.
|
||||
7. Skill execution must reuse the existing browser-script → pipe injection seam already proven by the Zhihu hotlist path. Do not create a second browser execution protocol.
|
||||
8. The design must not regress or weaken the existing Zhihu hotlist direct path, browser-script path, export path, or current routing behavior.
|
||||
9. The main branch implementation remains pipe-only, but all new deterministic-routing and skill contracts must stay backend-neutral so the execution backend can later be swapped to ws on the ws branch.
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Boundaries
|
||||
|
||||
### 1. Do not break the existing Zhihu hotlist flow
|
||||
|
||||
This is the top safety boundary for the slice.
|
||||
|
||||
The new deterministic routing for `tq-lineloss-report` must not break, narrow, or silently change:
|
||||
|
||||
- current Zhihu hotlist routing
|
||||
- current Zhihu direct browser-script execution
|
||||
- current Zhihu export behavior
|
||||
- current browser-script skill loading/execution
|
||||
- existing direct-submit configuration behavior
|
||||
|
||||
Design implication:
|
||||
|
||||
- The new deterministic path must be added as a narrow, explicit branch.
|
||||
- Existing Zhihu logic must keep its current trigger semantics and current execution seam.
|
||||
- Verification for this slice must include targeted Zhihu regression coverage before implementation is considered complete.
|
||||
|
||||
### 2. Current main branch is pipe-only
|
||||
|
||||
The implementation landing on `main` must execute browser-script skills through the current pipe-backed browser execution seam.
|
||||
|
||||
Do not introduce ws as an active runtime requirement for this slice.
|
||||
|
||||
### 3. Future ws migration must stay cheap
|
||||
|
||||
Although `main` remains pipe-only, the new work must leave a clean extension seam so that after this slice is merged into `ws`, the browser backend can be switched without redesigning:
|
||||
|
||||
- the staged skill package
|
||||
- the deterministic trigger contract
|
||||
- the parameter extraction contract
|
||||
- the parameter normalization contract
|
||||
- the returned artifact contract
|
||||
|
||||
---
|
||||
|
||||
## Why This Slice Exists
|
||||
|
||||
The user wants a staged business skill for `台区线损大数据-月_周累计线损率统计分析` that behaves like a deterministic business operation, not a free-form LLM task.
|
||||
|
||||
The desired operator experience is:
|
||||
|
||||
- ordinary instructions continue to use the current normal routing / LLM path
|
||||
- an instruction ending in `。。。` switches to deterministic business execution
|
||||
- deterministic execution targets a fixed staged skill
|
||||
- business parameters are extracted from the instruction
|
||||
- those parameters are normalized to the real coded values the source page/API needs
|
||||
- the staged browser-script is injected into the third-party browser through the existing pipe seam
|
||||
|
||||
This provides an inner-network-safe path that can work without a model today, while reserving an upgrade path for future semantic fallback.
|
||||
|
||||
---
|
||||
|
||||
## Terminology
|
||||
|
||||
### Deterministic mode
|
||||
|
||||
A submit-task mode enabled only when the instruction ends with `。。。`.
|
||||
|
||||
### Natural-language business parameters
|
||||
|
||||
Values expressed by the user in text, such as:
|
||||
|
||||
- `兰州公司`
|
||||
- `天水公司`
|
||||
- `月累计`
|
||||
- `周累计`
|
||||
- `2026-03`
|
||||
- `2026年第12周`
|
||||
|
||||
These are intermediate semantic values, not final backend parameters.
|
||||
|
||||
### Canonical execution parameters
|
||||
|
||||
The normalized values required by the source page / source API, such as:
|
||||
|
||||
- canonical company label
|
||||
- canonical company code
|
||||
- period mode code (month/week)
|
||||
- canonical request period payload
|
||||
|
||||
---
|
||||
|
||||
## Ownership Boundary and Landing Zones
|
||||
|
||||
### Staged skill changes
|
||||
|
||||
These land in:
|
||||
|
||||
`D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging`
|
||||
|
||||
Primary landing zone:
|
||||
|
||||
- `skills/tq-lineloss-report/`
|
||||
|
||||
Target package structure:
|
||||
|
||||
- `SKILL.md`
|
||||
- `SKILL.toml`
|
||||
- `references/collection-flow.md`
|
||||
- `references/data-quality.md`
|
||||
- `assets/scene-snapshot/index.html`
|
||||
- `scripts/collect_lineloss.js`
|
||||
- `scripts/collect_lineloss.test.js`
|
||||
|
||||
Potential aligned scene metadata (if included in this slice):
|
||||
|
||||
- `scenes/tq-lineloss-report/scene.json`
|
||||
- optional scene registry updates if the current staging conventions require it
|
||||
|
||||
### Caller/runtime changes
|
||||
|
||||
These land in:
|
||||
|
||||
`D:/data/ideaSpace/rust/sgClaw/claw-new`
|
||||
|
||||
Likely ownership areas:
|
||||
|
||||
- deterministic instruction detection and deterministic skill matching
|
||||
- parameter extraction and normalization
|
||||
- deterministic skill dispatch to the existing browser-script seam
|
||||
- narrow result interpretation for the returned artifact
|
||||
- focused regression tests
|
||||
|
||||
Design rule:
|
||||
|
||||
`claw-new` owns routing, extraction, normalization, and dispatch.
|
||||
|
||||
`claw-new` must **not** absorb the line-loss business logic itself.
|
||||
|
||||
The staged skill package owns:
|
||||
|
||||
- page inspection
|
||||
- page-side state reading
|
||||
- page/API data collection
|
||||
- row normalization
|
||||
- export/report-log behavior
|
||||
- final artifact generation
|
||||
|
||||
---
|
||||
|
||||
## Target Runtime Flow
|
||||
|
||||
### Step 1: Submit-task enters deterministic mode only on `。。。`
|
||||
|
||||
When the user instruction does **not** end in `。。。`:
|
||||
|
||||
- keep the current runtime behavior unchanged
|
||||
- preserve existing Zhihu hotlist behavior exactly
|
||||
- preserve existing direct-submit and compat/LLM flows
|
||||
|
||||
When the instruction **does** end in `。。。`:
|
||||
|
||||
- enter deterministic mode
|
||||
- do not run the ordinary LLM interpretation branch for this request
|
||||
- evaluate only the deterministic skill whitelist
|
||||
|
||||
### Step 2: Deterministic whitelist match
|
||||
|
||||
The runtime should match the instruction against deterministic business scenes.
|
||||
|
||||
For this slice the new required deterministic scene is:
|
||||
|
||||
- `tq-lineloss-report.collect_lineloss`
|
||||
|
||||
The matching layer should remain narrow and explicit. It should not become a general scene-registry runtime in this slice.
|
||||
|
||||
Matching should use a deterministic combination of:
|
||||
|
||||
- instruction keywords
|
||||
- optional page URL/title constraints when available
|
||||
|
||||
The runtime must not accidentally steal instructions that should still go down the Zhihu path.
|
||||
|
||||
### Step 3: Extract semantic business parameters from natural language
|
||||
|
||||
After `tq-lineloss-report` is matched, the runtime extracts semantic business parameters from the instruction.
|
||||
|
||||
Required semantic categories:
|
||||
|
||||
- company/unit expression
|
||||
- period mode (`month` vs `week`)
|
||||
- period text/value
|
||||
|
||||
Examples of accepted user-facing expressions include:
|
||||
|
||||
- `兰州公司`
|
||||
- `天水公司`
|
||||
- `国网兰州供电公司`
|
||||
- `城关供电分公司`
|
||||
- `2026-03`
|
||||
- `2026年3月`
|
||||
- `2026年第12周`
|
||||
- `第12周`
|
||||
- `月累计`
|
||||
- `周累计`
|
||||
|
||||
### Step 4: Normalize semantic values into canonical coded values
|
||||
|
||||
This is a required separate design step.
|
||||
|
||||
The runtime must not pass raw natural-language company text directly to the business request layer.
|
||||
|
||||
Instead it must normalize semantic values into canonical execution parameters, including:
|
||||
|
||||
- `org_label` — canonical unit label
|
||||
- `org_code` — the actual code/value required by the business page/API
|
||||
- `period_mode` — canonical mode (`month` or `week`)
|
||||
- `period_mode_code` — the page/API code (for example `timeChage`-style encoded mode)
|
||||
- canonical time payload required by the source APIs/page state
|
||||
|
||||
This normalization should be derived from the actual source materials, including page-side dictionaries such as the existing unit tree data.
|
||||
|
||||
### Step 5: Missing and ambiguous parameters must stop execution
|
||||
|
||||
This slice must not silently infer missing parameters from page defaults.
|
||||
|
||||
If a required parameter is missing, execution must stop with an explicit prompt to the user.
|
||||
|
||||
If a parameter is ambiguous, execution must stop with an explicit ambiguity prompt.
|
||||
|
||||
Examples:
|
||||
|
||||
- no company matched
|
||||
- no month/week mode matched
|
||||
- no period value matched when required
|
||||
- a short company alias matches multiple canonical units
|
||||
- both monthly and weekly intent appear in the same instruction
|
||||
|
||||
This is preferable to silently using the wrong company code or the wrong query period.
|
||||
|
||||
### Step 6: Execute the staged skill through the existing pipe seam
|
||||
|
||||
If and only if parameters are present and successfully normalized:
|
||||
|
||||
- resolve `tq-lineloss-report.collect_lineloss`
|
||||
- build the args object
|
||||
- execute it through the current `browser_script` runtime
|
||||
- inject the script into the browser through the existing pipe-backed browser tool seam
|
||||
|
||||
This slice must reuse the execution pattern already proven by the current browser-script/direct-skill infrastructure and the current Zhihu hotlist path.
|
||||
|
||||
Do not introduce a second browser protocol, new browser opcode family, or parallel execution harness.
|
||||
|
||||
### Step 7: Skill JS performs page-side work and returns one artifact
|
||||
|
||||
The staged script owns the actual line-loss business behavior:
|
||||
|
||||
- reading page-side state when needed
|
||||
- validating the page context
|
||||
- using normalized codes/parameters from args
|
||||
- building source API requests
|
||||
- collecting/normalizing rows
|
||||
- export/report logging behavior if required by the final business contract
|
||||
- returning a structured artifact
|
||||
|
||||
---
|
||||
|
||||
## Deterministic Trigger Contract
|
||||
|
||||
### Trigger rule
|
||||
|
||||
Deterministic mode is activated only when the raw instruction ends with the exact three-Chinese-dot suffix:
|
||||
|
||||
- `。。。`
|
||||
|
||||
This suffix is a user-controlled explicit mode switch.
|
||||
|
||||
### Why the suffix exists
|
||||
|
||||
It lets the user force business-deterministic behavior without relying on a model, while preserving the normal LLM path for ordinary requests.
|
||||
|
||||
### Scope rule
|
||||
|
||||
The suffix is not a free pass to run arbitrary browser actions.
|
||||
|
||||
It only selects among the deterministic skill whitelist.
|
||||
|
||||
If no deterministic scene matches, the runtime should return a deterministic-mode mismatch error that explains the currently supported deterministic scenes, rather than silently dropping into another behavior.
|
||||
|
||||
---
|
||||
|
||||
## Company / Unit Matching Contract
|
||||
|
||||
### Accepted input style
|
||||
|
||||
The user does **not** need to type the exact full canonical label.
|
||||
|
||||
The runtime should support business shorthand such as:
|
||||
|
||||
- `兰州公司`
|
||||
- `天水公司`
|
||||
- `白银公司`
|
||||
- `城关供电分公司`
|
||||
- `榆中县供电公司`
|
||||
|
||||
### Matching approach
|
||||
|
||||
Do not use regex alone as the primary company-resolution mechanism.
|
||||
|
||||
Use a three-stage resolution strategy:
|
||||
|
||||
1. text normalization
|
||||
2. alias/candidate generation from canonical unit names
|
||||
3. uniqueness resolution against the real unit dictionary
|
||||
|
||||
### Normalization examples
|
||||
|
||||
Canonical names such as:
|
||||
|
||||
- `国网兰州供电公司`
|
||||
- `国网天水供电公司`
|
||||
- `国网榆中县供电公司`
|
||||
|
||||
should be matchable from business shorthand forms such as:
|
||||
|
||||
- `兰州公司`
|
||||
- `天水公司`
|
||||
- `榆中县公司`
|
||||
- `榆中供电公司`
|
||||
|
||||
### Data source for canonical mapping
|
||||
|
||||
The company/unit resolver should derive canonical mappings from the real source materials used by the business page, such as the current unit tree dictionary embedded in the source page resources.
|
||||
|
||||
Design implication:
|
||||
|
||||
- the resolver should produce the real `value`/code required downstream
|
||||
- the resolver should also keep the canonical label for display/auditability
|
||||
|
||||
### Ambiguity rule
|
||||
|
||||
If a short alias resolves to more than one valid unit, execution must stop and ask the user to be more specific.
|
||||
|
||||
Do not auto-guess.
|
||||
|
||||
### Supported granularity
|
||||
|
||||
The first implementation must support both:
|
||||
|
||||
- city-company level
|
||||
- district/county/sub-company level
|
||||
|
||||
This includes forms like:
|
||||
|
||||
- `兰州公司`
|
||||
- `天水公司`
|
||||
- `城关供电分公司`
|
||||
- `榆中县供电公司`
|
||||
|
||||
---
|
||||
|
||||
## Period Extraction and Normalization Contract
|
||||
|
||||
### Required period dimensions
|
||||
|
||||
The runtime must identify:
|
||||
|
||||
- mode: `month` or `week`
|
||||
- actual requested period value in a canonical form
|
||||
|
||||
### Accepted user-facing patterns
|
||||
|
||||
At minimum the design should account for patterns such as:
|
||||
|
||||
- `月累计`
|
||||
- `周累计`
|
||||
- `2026-03`
|
||||
- `2026年3月`
|
||||
- `2026年第12周`
|
||||
- `第12周`
|
||||
|
||||
### Normalization output
|
||||
|
||||
The resolver should produce:
|
||||
|
||||
- a canonical mode enum/string
|
||||
- a mode code required by the page/API
|
||||
- a canonical period payload consumable by the script/business request layer
|
||||
|
||||
### Ambiguity rule
|
||||
|
||||
If both month and week intent appear, stop and ask the user to clarify.
|
||||
|
||||
### Missing-period rule
|
||||
|
||||
If the selected line-loss query requires a time period and the instruction does not provide enough information to construct one, stop and ask the user to provide it.
|
||||
|
||||
Do not default to the page-selected period in this slice.
|
||||
|
||||
---
|
||||
|
||||
## Parameter Prompting Contract
|
||||
|
||||
When deterministic mode matches `tq-lineloss-report` but one or more required parameters are missing or ambiguous, the runtime should return a user-facing prompt rather than executing.
|
||||
|
||||
Expected prompting cases include:
|
||||
|
||||
- missing company/unit
|
||||
- missing month/week mode
|
||||
- missing period value
|
||||
- ambiguous company alias
|
||||
- contradictory period expressions
|
||||
|
||||
The prompt should be specific enough to let the user correct only the missing field(s).
|
||||
|
||||
Example style:
|
||||
|
||||
- `已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。`
|
||||
- `已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。`
|
||||
|
||||
---
|
||||
|
||||
## Skill Package Contract
|
||||
|
||||
### SKILL.toml
|
||||
|
||||
The new skill package must declare a single deterministic collection entrypoint:
|
||||
|
||||
- tool name: `collect_lineloss`
|
||||
- kind: `browser_script`
|
||||
|
||||
The tool description must reflect the real staged behavior, not a placeholder shell.
|
||||
|
||||
### SKILL.md
|
||||
|
||||
The written contract should cover:
|
||||
|
||||
- when to use the skill
|
||||
- when not to use it
|
||||
- collection workflow
|
||||
- runtime contract
|
||||
- explicit missing/partial/error semantics
|
||||
- returned artifact contract
|
||||
|
||||
### references/collection-flow.md
|
||||
|
||||
Must explain:
|
||||
|
||||
- the source page state used by the skill
|
||||
- how company and period parameters map to business requests
|
||||
- which page/API calls are used for month vs week
|
||||
- export/report-log sequencing if retained in the business flow
|
||||
|
||||
### references/data-quality.md
|
||||
|
||||
Must define:
|
||||
|
||||
- canonical output columns
|
||||
- required field coverage
|
||||
- status semantics
|
||||
- partial/error conditions
|
||||
- company/period normalization assumptions that the script relies on
|
||||
|
||||
### scripts/collect_lineloss.js
|
||||
|
||||
This is the real browser-side entrypoint. It should:
|
||||
|
||||
- accept normalized args
|
||||
- validate page context
|
||||
- execute deterministic page/API data collection
|
||||
- normalize rows
|
||||
- perform downstream export/report-history behavior if required
|
||||
- directly return the final artifact from the browser-script runtime entrypoint shape
|
||||
|
||||
### scripts/collect_lineloss.test.js
|
||||
|
||||
Must cover the business transforms that can be tested off-browser, especially:
|
||||
|
||||
- company normalization assumptions consumed by the script
|
||||
- monthly vs weekly request-shape logic
|
||||
- status semantics
|
||||
- artifact shaping
|
||||
|
||||
---
|
||||
|
||||
## Returned Artifact Contract
|
||||
|
||||
The final line-loss skill should return one structured artifact object rather than free-form prose.
|
||||
|
||||
At minimum it should expose:
|
||||
|
||||
- artifact type
|
||||
- report name
|
||||
- canonical company label/code used for the query
|
||||
- period mode and canonical period value used for the query
|
||||
- columns
|
||||
- rows
|
||||
- status
|
||||
- counts
|
||||
- downstream export/report-log status when applicable
|
||||
- clear reasons for blocked/partial/error states
|
||||
|
||||
The exact field names may be finalized during implementation planning, but the contract must be structured enough for `claw-new` to interpret success vs partial vs blocked without re-embedding business logic.
|
||||
|
||||
---
|
||||
|
||||
## Pipe-First / Ws-Ready Execution Seam
|
||||
|
||||
### Current requirement
|
||||
|
||||
The first implementation on `main` must use the existing pipe-backed browser execution path.
|
||||
|
||||
### Future requirement
|
||||
|
||||
The design must allow later ws adoption without redesigning the skill or routing contract.
|
||||
|
||||
### Practical design rule
|
||||
|
||||
Keep these backend-neutral:
|
||||
|
||||
- deterministic trigger contract
|
||||
- skill matching contract
|
||||
- parameter extraction contract
|
||||
- parameter normalization contract
|
||||
- tool args contract
|
||||
- artifact contract
|
||||
|
||||
Keep backend-specific code isolated to the execution seam only.
|
||||
|
||||
That way the later ws migration can replace the browser backend beneath the same deterministic skill contract.
|
||||
|
||||
---
|
||||
|
||||
## Caller/Runtime Design Rules
|
||||
|
||||
### 1. Keep new business logic out of broad orchestration
|
||||
|
||||
Do not thread line-loss-specific business behavior through the general orchestration/runtime path.
|
||||
|
||||
### 2. Add a narrow deterministic-routing seam
|
||||
|
||||
This slice should add a narrow deterministic branch around submit-task routing, rather than rewriting the whole runtime decision tree.
|
||||
|
||||
### 3. Separate extraction from normalization
|
||||
|
||||
Do not mix “what the user typed” with “what the backend needs”.
|
||||
|
||||
There must be a distinct normalization step.
|
||||
|
||||
### 4. Keep the direct-skill browser seam narrow
|
||||
|
||||
Reuse the current `browser_script` execution seam instead of inventing a new browser bridge.
|
||||
|
||||
### 5. Preserve Zhihu behavior by design, not by hope
|
||||
|
||||
The design should assume new deterministic routing can accidentally steal or alter existing Zhihu behavior unless explicitly guarded against.
|
||||
|
||||
This is why focused Zhihu regression coverage is mandatory.
|
||||
|
||||
---
|
||||
|
||||
## Verification Requirements for the Future Implementation Plan
|
||||
|
||||
Implementation planning must include explicit verification for:
|
||||
|
||||
1. deterministic suffix detection
|
||||
2. deterministic lineloss scene matching
|
||||
3. company alias normalization to canonical code
|
||||
4. support for both company-level and district/county/sub-company-level units
|
||||
5. month/week extraction and normalization
|
||||
6. missing-parameter prompt behavior
|
||||
7. ambiguous-company prompt behavior
|
||||
8. pipe-backed browser-script execution for the new skill
|
||||
9. no regression to the existing Zhihu hotlist path
|
||||
10. preserved direct-skill/browser-script behavior outside the new line-loss scene
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope for This Slice
|
||||
|
||||
- enabling ws execution on `main`
|
||||
- replacing the current Zhihu routing model
|
||||
- general scene-registry runtime architecture redesign
|
||||
- full free-form semantic understanding of arbitrary business language
|
||||
- typo-tolerant fuzzy NLP beyond deterministic business-safe matching
|
||||
- making page defaults the hidden source of truth when the user omitted parameters
|
||||
|
||||
---
|
||||
|
||||
## Planning Notes
|
||||
|
||||
The implementation plan should likely split into distinct work items for:
|
||||
|
||||
1. staged skill package creation and business contract definition
|
||||
2. deterministic trigger + scene match in `claw-new`
|
||||
3. company/unit normalization and ambiguity handling
|
||||
4. period extraction/normalization and ambiguity handling
|
||||
5. pipe-backed direct execution integration
|
||||
6. returned artifact interpretation
|
||||
7. Zhihu regression verification
|
||||
8. ws-readiness seam verification
|
||||
|
||||
The plan should explicitly keep the “do not break Zhihu hotlist” boundary visible in every execution and verification stage.
|
||||
@@ -1,532 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>sgClaw Service Console</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3efe4;
|
||||
--panel: rgba(255, 252, 247, 0.88);
|
||||
--panel-strong: #fffaf2;
|
||||
--text: #1f2329;
|
||||
--muted: #636b74;
|
||||
--line: rgba(31, 35, 41, 0.12);
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #115e59;
|
||||
--warn: #b45309;
|
||||
--error: #b42318;
|
||||
--success: #166534;
|
||||
--shadow: 0 24px 60px rgba(34, 42, 53, 0.14);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
|
||||
radial-gradient(circle at right, rgba(180, 83, 9, 0.14), transparent 28%),
|
||||
linear-gradient(160deg, #f5f0e6 0%, #eef5f4 56%, #f7f3eb 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0 auto;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px 28px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(135deg, rgba(255, 250, 242, 0.96), rgba(237, 246, 243, 0.92));
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 10px 0 0;
|
||||
max-width: 60ch;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.stream-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
padding: 14px 16px;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: rgba(15, 118, 110, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
padding: 14px 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, opacity 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #f6fffd;
|
||||
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
background: rgba(99, 107, 116, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state-chip[data-state="connected"] {
|
||||
background: rgba(22, 101, 52, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.state-chip[data-state="connecting"] {
|
||||
background: rgba(180, 83, 9, 0.12);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-height: 1.4em;
|
||||
margin: 10px 0 14px;
|
||||
color: var(--error);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.stream-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(320px, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stream-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stream-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.stream-head p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.stream {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
min-height: 320px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 22px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
border: 1px dashed rgba(31, 35, 41, 0.16);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(31, 35, 41, 0.08);
|
||||
animation: rise 180ms ease;
|
||||
}
|
||||
|
||||
.row-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 76px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(99, 107, 116, 0.14);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row.status .row-badge {
|
||||
background: rgba(15, 118, 110, 0.14);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.row.log .row-badge {
|
||||
background: rgba(57, 91, 163, 0.14);
|
||||
color: #315aa2;
|
||||
}
|
||||
|
||||
.row.complete .row-badge {
|
||||
background: rgba(22, 101, 52, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.row.error .row-badge {
|
||||
background: rgba(180, 35, 24, 0.14);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.row-text {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stream {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell" id="app">
|
||||
<div class="hero">
|
||||
<h1>sgClaw Service Console</h1>
|
||||
<p>直接连接现有 service websocket,提交自然语言任务,并持续查看 service 返回的状态、日志和完成结果。</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="sidebar">
|
||||
<p class="section-label">Connection</p>
|
||||
<div class="status-card">
|
||||
<span id="connectionState" class="state-chip" data-state="disconnected">未连接</span>
|
||||
<span>默认地址使用现有 service websocket。</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wsUrl">WebSocket 地址</label>
|
||||
<input id="wsUrl" value="ws://127.0.0.1:42321" />
|
||||
</div>
|
||||
<button id="connectBtn" class="ghost-btn">连接</button>
|
||||
|
||||
<p class="section-label" style="margin-top: 26px;">Composer</p>
|
||||
<div class="field">
|
||||
<label for="instructionInput">任务内容</label>
|
||||
<textarea id="instructionInput" placeholder="例如:打开百度"></textarea>
|
||||
</div>
|
||||
<div id="validationText" class="validation"></div>
|
||||
<button id="sendBtn" class="primary-btn" disabled>发送任务</button>
|
||||
</div>
|
||||
|
||||
<div class="stream-panel">
|
||||
<div class="stream-head">
|
||||
<div>
|
||||
<p class="section-label">Service Stream</p>
|
||||
<h2>消息流</h2>
|
||||
<p>只展示本地连接状态与现有 service message。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="messageStream" class="stream">
|
||||
<div class="empty-state" id="emptyState">尚无消息。先连接 service websocket,再发送一条自然语言任务。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const defaultWsUrl = "ws://127.0.0.1:42321";
|
||||
const elements = {
|
||||
wsUrl: document.getElementById("wsUrl"),
|
||||
connectBtn: document.getElementById("connectBtn"),
|
||||
connectionState: document.getElementById("connectionState"),
|
||||
messageStream: document.getElementById("messageStream"),
|
||||
instructionInput: document.getElementById("instructionInput"),
|
||||
validationText: document.getElementById("validationText"),
|
||||
sendBtn: document.getElementById("sendBtn"),
|
||||
emptyState: document.getElementById("emptyState")
|
||||
};
|
||||
|
||||
let socket = null;
|
||||
|
||||
function appendRow(kind, text) {
|
||||
if (elements.emptyState) {
|
||||
elements.emptyState.remove();
|
||||
elements.emptyState = null;
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "row " + kind;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "row-badge";
|
||||
badge.textContent = kind;
|
||||
|
||||
const content = document.createElement("p");
|
||||
content.className = "row-text";
|
||||
content.textContent = text;
|
||||
|
||||
row.appendChild(badge);
|
||||
row.appendChild(content);
|
||||
elements.messageStream.appendChild(row);
|
||||
elements.messageStream.scrollTop = elements.messageStream.scrollHeight;
|
||||
}
|
||||
|
||||
function setValidation(message) {
|
||||
elements.validationText.textContent = message;
|
||||
}
|
||||
|
||||
function updateUiState() {
|
||||
const readyState = socket ? socket.readyState : WebSocket.CLOSED;
|
||||
const connected = readyState === WebSocket.OPEN;
|
||||
const connecting = readyState === WebSocket.CONNECTING;
|
||||
let stateText = "未连接";
|
||||
let stateValue = "disconnected";
|
||||
|
||||
if (connected) {
|
||||
stateText = "已连接";
|
||||
stateValue = "connected";
|
||||
} else if (connecting) {
|
||||
stateText = "连接中";
|
||||
stateValue = "connecting";
|
||||
}
|
||||
|
||||
elements.connectBtn.textContent = connected || connecting ? "断开" : "连接";
|
||||
elements.sendBtn.disabled = !connected;
|
||||
elements.connectionState.textContent = stateText;
|
||||
elements.connectionState.dataset.state = stateValue;
|
||||
}
|
||||
|
||||
function connectOrDisconnectService() {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = elements.wsUrl.value.trim() || defaultWsUrl;
|
||||
elements.wsUrl.value = url;
|
||||
const nextSocket = new WebSocket(url);
|
||||
socket = nextSocket;
|
||||
updateUiState();
|
||||
|
||||
nextSocket.addEventListener("open", () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
appendRow("status", "service websocket connected");
|
||||
updateUiState();
|
||||
});
|
||||
|
||||
nextSocket.addEventListener("close", () => {
|
||||
if (socket === nextSocket) {
|
||||
socket = null;
|
||||
}
|
||||
appendRow("status", "service websocket disconnected");
|
||||
updateUiState();
|
||||
});
|
||||
|
||||
nextSocket.addEventListener("error", () => {
|
||||
appendRow("error", "service websocket error");
|
||||
});
|
||||
|
||||
nextSocket.addEventListener("message", handleMessage);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (_error) {
|
||||
appendRow("error", "invalid service message: " + event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "status_changed":
|
||||
appendRow("status", message.state);
|
||||
break;
|
||||
case "log_entry":
|
||||
appendRow("log", message.message);
|
||||
break;
|
||||
case "task_complete":
|
||||
appendRow(message.success ? "complete" : "error", message.summary);
|
||||
break;
|
||||
case "busy":
|
||||
appendRow("error", message.message);
|
||||
break;
|
||||
default:
|
||||
appendRow("error", "unknown service message: " + event.data);
|
||||
}
|
||||
}
|
||||
|
||||
function sendTask() {
|
||||
const instruction = elements.instructionInput.value.trim();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!instruction) {
|
||||
setValidation("请输入任务内容。");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidation("");
|
||||
socket.send(JSON.stringify({
|
||||
type: "submit_task",
|
||||
instruction,
|
||||
conversation_id: "",
|
||||
messages: [],
|
||||
page_url: "",
|
||||
page_title: ""
|
||||
}));
|
||||
}
|
||||
|
||||
elements.connectBtn.addEventListener("click", connectOrDisconnectService);
|
||||
elements.sendBtn.addEventListener("click", sendTask);
|
||||
elements.instructionInput.addEventListener("input", () => {
|
||||
if (elements.instructionInput.value.trim()) {
|
||||
setValidation("");
|
||||
}
|
||||
});
|
||||
|
||||
updateUiState();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,8 +6,6 @@
|
||||
"oa.example.com",
|
||||
"erp.example.com",
|
||||
"hr.example.com",
|
||||
"sgcc.example.invalid",
|
||||
"95598.example.invalid",
|
||||
"baidu.com",
|
||||
"www.baidu.com",
|
||||
"zhihu.com",
|
||||
|
||||
637
resources/zhihu-hotlist-echarts.html
Normal file
637
resources/zhihu-hotlist-echarts.html
Normal file
@@ -0,0 +1,637 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>知乎热榜图表驾驶舱</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #06111f;
|
||||
--bg-2: #0a1f37;
|
||||
--panel: rgba(8, 25, 42, 0.88);
|
||||
--panel-strong: rgba(10, 32, 55, 0.95);
|
||||
--line: rgba(101, 187, 255, 0.18);
|
||||
--line-strong: rgba(236, 186, 81, 0.26);
|
||||
--text: #eef6ff;
|
||||
--muted: #8ea6c2;
|
||||
--accent: #62d0ff;
|
||||
--accent-2: #ecba51;
|
||||
--accent-3: #6df0c2;
|
||||
--danger: #ff8b7e;
|
||||
--shadow: 0 20px 48px rgba(0, 0, 0, 0.34);
|
||||
--font-heading: "DIN Alternate", "Bahnschrift", "Microsoft YaHei UI", sans-serif;
|
||||
--font-body: "Segoe UI Variable Text", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 16% 10%, rgba(98, 208, 255, 0.18), transparent 22%),
|
||||
radial-gradient(circle at 86% 12%, rgba(236, 186, 81, 0.14), transparent 18%),
|
||||
linear-gradient(145deg, var(--bg) 0%, var(--bg-2) 42%, #030910 100%);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(101, 187, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(101, 187, 255, 0.05) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
mask-image: radial-gradient(circle at center, black 34%, rgba(0, 0, 0, 0.22) 88%, transparent 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)),
|
||||
linear-gradient(145deg, rgba(9, 30, 51, 0.97), rgba(6, 20, 34, 0.92));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 18px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 38px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#snapshot-meta {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hero-notes {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(98, 208, 255, 0.08), rgba(236, 186, 81, 0.08));
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.note-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.note-card span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 34px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
margin-top: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.charts {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 0.95fr;
|
||||
grid-template-rows: 360px 320px;
|
||||
gap: 14px;
|
||||
grid-template-areas:
|
||||
"bar top pie"
|
||||
"bubble table table";
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
|
||||
.bar-panel { grid-area: bar; }
|
||||
.top-panel { grid-area: top; }
|
||||
.pie-panel { grid-area: pie; }
|
||||
.bubble-panel { grid-area: bubble; }
|
||||
.table-panel { grid-area: table; padding: 14px 16px 10px; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.section-head span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: calc(100% - 42px);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
height: calc(100% - 42px);
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: rgba(6, 19, 32, 0.96);
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 11px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
background: rgba(255, 255, 255, 0.016);
|
||||
}
|
||||
|
||||
.rank {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--accent-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heat {
|
||||
color: var(--accent-3);
|
||||
font-family: var(--font-heading);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(98, 208, 255, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.charts {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 320px 320px 320px 320px 420px;
|
||||
grid-template-areas:
|
||||
"bar"
|
||||
"top"
|
||||
"pie"
|
||||
"bubble"
|
||||
"table";
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="panel hero">
|
||||
<div>
|
||||
<div class="eyebrow">Zhihu Hotlist Visual Command Center</div>
|
||||
<h1>知乎热榜图表驾驶舱</h1>
|
||||
<p id="snapshot-meta">由 sgClaw screen_html_export 生成的本地静态展示页</p>
|
||||
</div>
|
||||
<div class="hero-notes">
|
||||
<div class="note-card">
|
||||
<strong>图表表达</strong>
|
||||
<span>同一份热榜数据同时映射为分类热度、头部热点、结构占比和热度散点,适合现场讲解图表能力。</span>
|
||||
</div>
|
||||
<div class="note-card">
|
||||
<strong>演示建议</strong>
|
||||
<span id="lead-summary">优先讲解榜首热点、分类分布与热度层级,再向下展开全量榜单细节。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metrics">
|
||||
<article class="panel metric">
|
||||
<div class="metric-label">热榜条目数</div>
|
||||
<div id="metric-total" class="metric-value">0</div>
|
||||
<div class="metric-sub">Tracked items</div>
|
||||
</article>
|
||||
<article class="panel metric">
|
||||
<div class="metric-label">主题分类数</div>
|
||||
<div id="metric-categories" class="metric-value">0</div>
|
||||
<div class="metric-sub">Topic groups</div>
|
||||
</article>
|
||||
<article class="panel metric">
|
||||
<div class="metric-label">累计热度</div>
|
||||
<div id="metric-heat" class="metric-value">0</div>
|
||||
<div class="metric-sub">Total heat</div>
|
||||
</article>
|
||||
<article class="panel metric">
|
||||
<div class="metric-label">头部峰值</div>
|
||||
<div id="metric-peak" class="metric-value">0</div>
|
||||
<div class="metric-sub">Peak topic heat</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="charts">
|
||||
<section class="panel chart-panel bar-panel">
|
||||
<div class="section-head">
|
||||
<h2>分类总热度</h2>
|
||||
<span>横向对比</span>
|
||||
</div>
|
||||
<div id="bar-chart" class="chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel top-panel">
|
||||
<div class="section-head">
|
||||
<h2>Top10 热点</h2>
|
||||
<span>柱状排行</span>
|
||||
</div>
|
||||
<div id="top-chart" class="chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel pie-panel">
|
||||
<div class="section-head">
|
||||
<h2>分类占比</h2>
|
||||
<span>环形结构</span>
|
||||
</div>
|
||||
<div id="pie-chart" class="chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel bubble-panel">
|
||||
<div class="section-head">
|
||||
<h2>热度分层</h2>
|
||||
<span>散点气泡</span>
|
||||
</div>
|
||||
<div id="bubble-chart" class="chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<div class="section-head">
|
||||
<h2>热榜明细</h2>
|
||||
<span id="table-note">按原始顺序保留</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>热度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel footer">
|
||||
本页由 `screen_html_export` 生成,适合在系统浏览器中直接打开进行展示。
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const defaultPayload = {
|
||||
"snapshot_id": "template-snapshot",
|
||||
"generated_at_ms": 0,
|
||||
"categories": [],
|
||||
"table": []
|
||||
}
|
||||
|
||||
const themeMeta = {
|
||||
title: "知乎热榜图表驾驶舱",
|
||||
renderer: "screen_html_export"
|
||||
};
|
||||
|
||||
const chartColors = ["#62d0ff", "#ecba51", "#6df0c2", "#7f8cff", "#ff8b7e", "#9fcbff", "#58a6ff"];
|
||||
const charts = {};
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function getTotalHeat(categories) {
|
||||
return (categories || []).reduce((sum, item) => sum + Number(item.total_heat || 0), 0);
|
||||
}
|
||||
|
||||
function getPeakHeat(table) {
|
||||
return (table || []).reduce((max, row) => Math.max(max, Number(row.heat_value || 0)), 0);
|
||||
}
|
||||
|
||||
function buildLeadSummary(table, categories) {
|
||||
const top = (table || [])[0];
|
||||
const category = (categories || []).slice().sort((a, b) => (b.total_heat || 0) - (a.total_heat || 0))[0];
|
||||
const parts = [];
|
||||
if (top) {
|
||||
parts.push(`榜首是“${top.title}”`);
|
||||
}
|
||||
if (category) {
|
||||
parts.push(`主导分类为“${category.category_label}”`);
|
||||
}
|
||||
parts.push(`共覆盖 ${(table || []).length} 条热点`);
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
function ensureCharts() {
|
||||
if (!window.echarts) {
|
||||
return;
|
||||
}
|
||||
charts.bar = charts.bar || echarts.init(document.getElementById("bar-chart"));
|
||||
charts.top = charts.top || echarts.init(document.getElementById("top-chart"));
|
||||
charts.pie = charts.pie || echarts.init(document.getElementById("pie-chart"));
|
||||
charts.bubble = charts.bubble || echarts.init(document.getElementById("bubble-chart"));
|
||||
}
|
||||
|
||||
function renderBarChart(categories) {
|
||||
const sorted = (categories || []).slice().sort((a, b) => Number(a.total_heat || 0) - Number(b.total_heat || 0));
|
||||
charts.bar.setOption({
|
||||
animationDuration: 700,
|
||||
grid: {left: 90, right: 18, top: 10, bottom: 20},
|
||||
xAxis: {
|
||||
type: "value",
|
||||
axisLabel: {color: "#8ea6c2"},
|
||||
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
data: sorted.map((item) => item.category_label),
|
||||
axisLabel: {color: "#eef6ff"},
|
||||
axisLine: {lineStyle: {color: "rgba(255,255,255,0.1)"}}
|
||||
},
|
||||
tooltip: {trigger: "axis", axisPointer: {type: "shadow"}},
|
||||
series: [{
|
||||
type: "bar",
|
||||
data: sorted.map((item, index) => ({
|
||||
value: Number(item.total_heat || 0),
|
||||
itemStyle: {color: chartColors[index % chartColors.length], borderRadius: [0, 8, 8, 0]}
|
||||
})),
|
||||
label: {show: true, position: "right", color: "#dfeeff"}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopChart(table) {
|
||||
const top = (table || []).slice(0, 10);
|
||||
charts.top.setOption({
|
||||
animationDuration: 700,
|
||||
grid: {left: 42, right: 12, top: 26, bottom: 46},
|
||||
tooltip: {trigger: "axis", axisPointer: {type: "shadow"}},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: top.map((row) => `#${row.rank}`),
|
||||
axisLabel: {color: "#8ea6c2"},
|
||||
axisLine: {lineStyle: {color: "rgba(255,255,255,0.1)"}}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: {color: "#8ea6c2"},
|
||||
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
|
||||
},
|
||||
series: [{
|
||||
type: "bar",
|
||||
data: top.map((row, index) => ({
|
||||
value: Number(row.heat_value || 0),
|
||||
itemStyle: {color: chartColors[index % chartColors.length], borderRadius: [8, 8, 0, 0]}
|
||||
})),
|
||||
label: {show: true, position: "top", color: "#eef6ff", formatter: ({dataIndex}) => top[dataIndex].heat_text}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderPieChart(categories) {
|
||||
charts.pie.setOption({
|
||||
animationDuration: 700,
|
||||
color: chartColors,
|
||||
tooltip: {trigger: "item"},
|
||||
legend: {
|
||||
bottom: 2,
|
||||
textStyle: {color: "#8ea6c2", fontSize: 11},
|
||||
itemWidth: 12,
|
||||
itemHeight: 8
|
||||
},
|
||||
series: [{
|
||||
type: "pie",
|
||||
radius: ["44%", "72%"],
|
||||
center: ["50%", "44%"],
|
||||
itemStyle: {borderColor: "#081a2c", borderWidth: 2},
|
||||
label: {
|
||||
color: "#eef6ff",
|
||||
formatter: "{b}\n{d}%"
|
||||
},
|
||||
data: (categories || []).map((item) => ({
|
||||
name: item.category_label,
|
||||
value: Number(item.total_heat || 0)
|
||||
}))
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderBubbleChart(table) {
|
||||
const top = (table || []).slice(0, 12);
|
||||
charts.bubble.setOption({
|
||||
animationDuration: 700,
|
||||
color: chartColors,
|
||||
grid: {left: 44, right: 18, top: 16, bottom: 36},
|
||||
xAxis: {
|
||||
type: "value",
|
||||
name: "排名",
|
||||
inverse: true,
|
||||
min: 0,
|
||||
max: Math.max(...top.map((row) => Number(row.rank || 0)), 10) + 1,
|
||||
nameTextStyle: {color: "#8ea6c2"},
|
||||
axisLabel: {color: "#8ea6c2"},
|
||||
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "热度值",
|
||||
nameTextStyle: {color: "#8ea6c2"},
|
||||
axisLabel: {color: "#8ea6c2"},
|
||||
splitLine: {lineStyle: {color: "rgba(255,255,255,0.06)"}}
|
||||
},
|
||||
tooltip: {
|
||||
formatter: (params) => {
|
||||
const row = params.data.raw;
|
||||
return `${row.title}<br/>排名 #${row.rank}<br/>热度 ${row.heat_text}<br/>分类 ${row.category_label}`;
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
type: "scatter",
|
||||
symbolSize: (value) => Math.max(18, Math.min(56, value[2] / 80000)),
|
||||
data: top.map((row, index) => ({
|
||||
value: [Number(row.rank || 0), Number(row.heat_value || 0), Number(row.heat_value || 0)],
|
||||
raw: row,
|
||||
itemStyle: {color: chartColors[index % chartColors.length], opacity: 0.82}
|
||||
}))
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(table) {
|
||||
document.getElementById("table-body").innerHTML = (table || []).map((row) => `
|
||||
<tr>
|
||||
<td class="rank">#${row.rank}</td>
|
||||
<td>${row.title}</td>
|
||||
<td><span class="tag">${row.category_label}</span></td>
|
||||
<td class="heat">${row.heat_text}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
const data = payload || defaultPayload;
|
||||
const categories = data.categories || [];
|
||||
const table = data.table || [];
|
||||
|
||||
document.title = themeMeta.title;
|
||||
document.getElementById("snapshot-meta").textContent =
|
||||
`${data.snapshot_id} | 生成时间 ${new Date(data.generated_at_ms || 0).toLocaleString()}`;
|
||||
document.getElementById("metric-total").textContent = formatNumber(table.length);
|
||||
document.getElementById("metric-categories").textContent = formatNumber(categories.length);
|
||||
document.getElementById("metric-heat").textContent = formatNumber(getTotalHeat(categories));
|
||||
document.getElementById("metric-peak").textContent = formatNumber(getPeakHeat(table));
|
||||
document.getElementById("lead-summary").textContent = buildLeadSummary(table, categories);
|
||||
document.getElementById("table-note").textContent =
|
||||
table.length > 0 ? `当前展示 ${table.length} 条热点` : "暂无热榜数据";
|
||||
|
||||
renderTable(table);
|
||||
ensureCharts();
|
||||
if (window.echarts) {
|
||||
renderBarChart(categories);
|
||||
renderTopChart(table);
|
||||
renderPieChart(categories);
|
||||
renderBubbleChart(table);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
Object.values(charts).forEach((chart) => chart && chart.resize());
|
||||
});
|
||||
|
||||
render(defaultPayload);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"ui-ux-pro-max": {
|
||||
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
|
||||
"sourceType": "github",
|
||||
"computedHash": "6337038fe1fe6bbe1b9f252ab678ee575859190bab6f0f246f4061824eb40875"
|
||||
}
|
||||
}
|
||||
}
|
||||
394
src/agent/mod.rs
394
src/agent/mod.rs
@@ -1,63 +1,142 @@
|
||||
pub mod task_runner;
|
||||
pub mod planner;
|
||||
pub mod runtime;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::browser::ws_backend::WsBrowserBackend;
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::pipe::{BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
pub use task_runner::{
|
||||
run_submit_task, run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
|
||||
SubmitTaskRequest,
|
||||
};
|
||||
|
||||
fn normalize_optional_submit_field(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRuntimeContext {
|
||||
config_path: Option<PathBuf>,
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
fn browser_backend_for_submit<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: &SubmitTaskRequest,
|
||||
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
|
||||
if let Some(browser_ws_url) = configured_browser_ws_url(context) {
|
||||
return Ok(Arc::new(
|
||||
WsBrowserBackend::new(
|
||||
Arc::new(crate::service::browser_ws_client::ServiceWsClient::connect(
|
||||
&browser_ws_url,
|
||||
)?),
|
||||
browser_tool.mac_policy().clone(),
|
||||
crate::service::browser_ws_client::initial_request_url_for_submit_task(request),
|
||||
)
|
||||
.with_response_timeout(browser_tool.response_timeout()),
|
||||
));
|
||||
impl AgentRuntimeContext {
|
||||
pub fn new(config_path: Option<PathBuf>, workspace_root: PathBuf) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
workspace_root,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone())))
|
||||
pub fn from_process_args<I, S>(args: I) -> Result<Self, PipeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let mut config_path = None;
|
||||
let mut args = args.into_iter().map(Into::into);
|
||||
let _ = args.next();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == OsString::from("--config-path") {
|
||||
let Some(value) = args.next() else {
|
||||
return Err(PipeError::Protocol(
|
||||
"missing value for --config-path".to_string(),
|
||||
));
|
||||
};
|
||||
config_path = Some(PathBuf::from(value));
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg_string = arg.to_string_lossy();
|
||||
if let Some(value) = arg_string.strip_prefix("--config-path=") {
|
||||
config_path = Some(PathBuf::from(value));
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_root = config_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
|
||||
.unwrap_or_else(default_workspace_root);
|
||||
|
||||
Ok(Self::new(config_path, workspace_root))
|
||||
}
|
||||
|
||||
fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||
SgClawSettings::load(self.config_path.as_deref())
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
fn settings_source_label(&self) -> String {
|
||||
match &self.config_path {
|
||||
Some(path) if path.exists() => path.display().to_string(),
|
||||
_ => "environment".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_browser_ws_url(context: &AgentRuntimeContext) -> Option<String> {
|
||||
std::env::var("SGCLAW_BROWSER_WS_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
context
|
||||
.load_sgclaw_settings()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|settings| settings.browser_ws_url)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
impl Default for AgentRuntimeContext {
|
||||
fn default() -> Self {
|
||||
Self::new(None, default_workspace_root())
|
||||
}
|
||||
}
|
||||
|
||||
fn send_status_changed<T: Transport>(transport: &T, state: &str) -> Result<(), PipeError> {
|
||||
transport.send(&crate::pipe::AgentMessage::StatusChanged {
|
||||
state: state.to_string(),
|
||||
fn default_workspace_root() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn send_mode_log<T: Transport>(transport: &T, mode: &str) -> Result<(), PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "mode".to_string(),
|
||||
message: mode.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_llm_configuration_summary() -> String {
|
||||
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn runtime_version_log_message() -> String {
|
||||
format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_plan<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
plan: &planner::TaskPlan,
|
||||
) -> Result<String, PipeError> {
|
||||
for step in &plan.steps {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: step.log_message.clone(),
|
||||
})?;
|
||||
|
||||
let result = browser_tool.invoke(
|
||||
step.action.clone(),
|
||||
step.params.clone(),
|
||||
&step.expected_domain,
|
||||
)?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser action failed: {}",
|
||||
result.data
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plan.summary.clone())
|
||||
}
|
||||
|
||||
pub fn execute_task<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
instruction: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let plan = planner::plan_instruction(instruction)
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
execute_plan(transport, browser_tool, &plan)
|
||||
}
|
||||
|
||||
pub fn handle_browser_message<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
@@ -78,9 +157,6 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
message: BrowserMessage,
|
||||
) -> Result<(), PipeError> {
|
||||
match message {
|
||||
BrowserMessage::Connect => send_status_changed(transport, "connected"),
|
||||
BrowserMessage::Start => send_status_changed(transport, "started"),
|
||||
BrowserMessage::Stop => send_status_changed(transport, "stopped"),
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction,
|
||||
conversation_id,
|
||||
@@ -88,15 +164,188 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
page_url,
|
||||
page_title,
|
||||
} => {
|
||||
let request = SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id: normalize_optional_submit_field(conversation_id),
|
||||
let raw_instruction = instruction;
|
||||
let trimmed_instruction = raw_instruction.trim().to_string();
|
||||
if trimmed_instruction.is_empty() {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id: (!conversation_id.trim().is_empty())
|
||||
.then_some(conversation_id.clone()),
|
||||
messages,
|
||||
page_url: normalize_optional_submit_field(page_url),
|
||||
page_title: normalize_optional_submit_field(page_title),
|
||||
page_url: (!page_url.trim().is_empty()).then_some(page_url),
|
||||
page_title: (!page_title.trim().is_empty()).then_some(page_title),
|
||||
};
|
||||
let browser_backend = browser_backend_for_submit(browser_tool, context, &request)?;
|
||||
run_submit_task_with_browser_backend(transport, transport, browser_backend, context, request)
|
||||
let mut instruction = trimmed_instruction;
|
||||
let mut deterministic_plan = None;
|
||||
match crate::compat::deterministic_submit::decide_deterministic_submit(
|
||||
&raw_instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::NotDeterministic => {}
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::Prompt { summary } => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) => {
|
||||
instruction = plan.instruction.clone();
|
||||
deterministic_plan = Some(plan);
|
||||
}
|
||||
}
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"skills dir resolved to {}",
|
||||
resolved_skills_dir.display()
|
||||
),
|
||||
});
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if let Some(plan) = deterministic_plan.as_ref() {
|
||||
let _ = send_mode_log(transport, "direct_skill_primary");
|
||||
let completion = match crate::compat::deterministic_submit::execute_deterministic_submit(
|
||||
browser_tool.clone(),
|
||||
plan,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
};
|
||||
return transport.send(&completion);
|
||||
}
|
||||
if crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
let _ = send_mode_log(transport, "zeroclaw_process_message_primary");
|
||||
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings
|
||||
.direct_submit_skill
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
let _ = send_mode_log(transport, "direct_skill_primary");
|
||||
let completion = match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
};
|
||||
return transport.send(&completion);
|
||||
}
|
||||
let _ = send_mode_log(transport, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: format!("failed to load DeepSeek config: {err}"),
|
||||
});
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
transport.send(&completion)
|
||||
}
|
||||
BrowserMessage::Init { .. } => {
|
||||
eprintln!("ignoring duplicate init after handshake");
|
||||
@@ -108,36 +357,3 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_optional_submit_field;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn normalize_optional_submit_field_trims_and_drops_blank_values() {
|
||||
assert_eq!(normalize_optional_submit_field(" \n\t ".to_string()), None);
|
||||
assert_eq!(
|
||||
normalize_optional_submit_field(" https://example.com/page ".to_string()),
|
||||
Some("https://example.com/page".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_module_cleanup_removes_legacy_runtime_and_planner_sources() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let agent_module = fs::read_to_string(manifest_dir.join("src/agent/mod.rs")).unwrap();
|
||||
let top_lines = agent_module
|
||||
.lines()
|
||||
.take(10)
|
||||
.map(str::trim)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(!manifest_dir.join("src/agent/runtime.rs").exists());
|
||||
assert!(!manifest_dir.join("src/agent/planner.rs").exists());
|
||||
assert!(!top_lines.iter().any(|line| *line == "pub mod runtime;"));
|
||||
assert!(!top_lines.iter().any(|line| *line == "pub mod planner;"));
|
||||
assert!(top_lines.iter().any(|line| *line == "pub mod task_runner;"));
|
||||
}
|
||||
}
|
||||
|
||||
216
src/agent/planner.rs
Normal file
216
src/agent/planner.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::PlannerMode;
|
||||
use crate::pipe::Action;
|
||||
|
||||
/// Legacy deterministic planner kept for dev-only verification and fixture coverage.
|
||||
/// Production browser submit flow no longer routes into this planner.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BAIDU_URL: &str = "https://www.baidu.com";
|
||||
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
||||
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
||||
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
|
||||
const ZHIHU_HOME_URL: &str = "https://www.zhihu.com";
|
||||
const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search";
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PlannedStep {
|
||||
pub action: Action,
|
||||
pub params: Value,
|
||||
pub expected_domain: String,
|
||||
pub log_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TaskPlan {
|
||||
pub summary: String,
|
||||
pub steps: Vec<PlannedStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExecutionPreview {
|
||||
pub summary: String,
|
||||
pub steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum PlannerError {
|
||||
#[error("unsupported instruction: {0}")]
|
||||
UnsupportedInstruction(String),
|
||||
#[error("missing search query in instruction")]
|
||||
MissingQuery,
|
||||
}
|
||||
|
||||
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
||||
let trimmed = instruction.trim();
|
||||
if matches_exact(trimmed, &["打开百度"]) {
|
||||
return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
||||
return Ok(plan_baidu_search(query));
|
||||
}
|
||||
|
||||
if matches_exact(trimmed, &["打开知乎"]) {
|
||||
return Ok(plan_homepage(
|
||||
"已打开知乎首页",
|
||||
ZHIHU_HOME_URL,
|
||||
ZHIHU_DOMAIN,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
|
||||
return Ok(plan_zhihu_search(query));
|
||||
}
|
||||
|
||||
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn build_execution_preview(
|
||||
mode: PlannerMode,
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Option<ExecutionPreview> {
|
||||
if matches!(mode, PlannerMode::LegacyDeterministic) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = instruction.trim();
|
||||
if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) {
|
||||
return Some(build_zhihu_hotlist_preview(trimmed));
|
||||
}
|
||||
|
||||
if let Ok(plan) = plan_instruction(trimmed) {
|
||||
return Some(ExecutionPreview {
|
||||
summary: format!("先规划再执行:{}", plan.summary),
|
||||
steps: plan
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| step.log_message)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(ExecutionPreview {
|
||||
summary: "先规划再执行当前任务".to_string(),
|
||||
steps: vec![
|
||||
"inspect current browser context".to_string(),
|
||||
"choose the required sgclaw runtime tools".to_string(),
|
||||
"execute and return the concrete result".to_string(),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_query<'a>(
|
||||
instruction: &'a str,
|
||||
prefixes: &[&str],
|
||||
) -> Result<Option<&'a str>, PlannerError> {
|
||||
let Some(query) = prefixes
|
||||
.iter()
|
||||
.find_map(|prefix| instruction.strip_prefix(prefix))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Err(PlannerError::MissingQuery);
|
||||
}
|
||||
|
||||
Ok(Some(query))
|
||||
}
|
||||
|
||||
fn matches_exact(instruction: &str, candidates: &[&str]) -> bool {
|
||||
candidates.iter().any(|candidate| instruction == *candidate)
|
||||
}
|
||||
|
||||
fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: summary.to_string(),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: domain.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_baidu_search(query: &str) -> TaskPlan {
|
||||
TaskPlan {
|
||||
summary: format!("已在百度搜索{query}"),
|
||||
steps: vec![
|
||||
PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": BAIDU_URL }),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: "navigate https://www.baidu.com".to_string(),
|
||||
},
|
||||
PlannedStep {
|
||||
action: Action::Type,
|
||||
params: json!({
|
||||
"selector": BAIDU_INPUT_SELECTOR,
|
||||
"text": query,
|
||||
"clear_first": true
|
||||
}),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: format!("type {query} into {BAIDU_INPUT_SELECTOR}"),
|
||||
},
|
||||
PlannedStep {
|
||||
action: Action::Click,
|
||||
params: json!({ "selector": BAIDU_SEARCH_BUTTON_SELECTOR }),
|
||||
expected_domain: BAIDU_DOMAIN.to_string(),
|
||||
log_message: format!("click {BAIDU_SEARCH_BUTTON_SELECTOR}"),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_zhihu_search(query: &str) -> TaskPlan {
|
||||
let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)])
|
||||
.expect("valid Zhihu search URL");
|
||||
let url: String = url.into();
|
||||
|
||||
TaskPlan {
|
||||
summary: format!("已在知乎搜索{query}"),
|
||||
steps: vec![PlannedStep {
|
||||
action: Action::Navigate,
|
||||
params: json!({ "url": url }),
|
||||
expected_domain: ZHIHU_DOMAIN.to_string(),
|
||||
log_message: format!("navigate {url}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
{
|
||||
return ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜大屏生成".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call screen_html_export".to_string(),
|
||||
"return generated local .html path".to_string(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜 Excel 导出".to_string(),
|
||||
steps: vec![
|
||||
"navigate https://www.zhihu.com/hot".to_string(),
|
||||
"getText main".to_string(),
|
||||
"call openxml_office".to_string(),
|
||||
"return generated local .xlsx path".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
155
src/agent/runtime.rs
Normal file
155
src/agent/runtime.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
/// Legacy browser-only runtime kept for dev-only validation and narrow regression coverage.
|
||||
/// Production browser submit flow uses `compat::runtime` plus `runtime::engine`.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct BrowserActionCall {
|
||||
action: Action,
|
||||
expected_domain: String,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
provider: &P,
|
||||
instruction: &str,
|
||||
) -> Result<String, PipeError> {
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: "You are sgClaw. Use browser_action to complete the browser task.".to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: instruction.to_string(),
|
||||
},
|
||||
];
|
||||
let tools = vec![browser_action_tool_definition()];
|
||||
let calls = provider
|
||||
.chat(&messages, &tools)
|
||||
.map_err(map_llm_error_to_pipe_error)?;
|
||||
|
||||
for call in calls {
|
||||
let browser_call =
|
||||
parse_browser_action_call(call).map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"{} {}",
|
||||
browser_call.action.as_str(),
|
||||
browser_call.expected_domain
|
||||
),
|
||||
})?;
|
||||
|
||||
let result = browser_tool.invoke(
|
||||
browser_call.action,
|
||||
browser_call.params,
|
||||
&browser_call.expected_domain,
|
||||
)?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser action failed: {}",
|
||||
result.data
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("已通过 Agent 执行任务: {instruction}"))
|
||||
}
|
||||
|
||||
pub fn browser_action_tool_definition() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: BROWSER_ACTION_TOOL_NAME.to_string(),
|
||||
description: "Execute browser actions in SuperRPA".to_string(),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"required": ["action", "expected_domain"],
|
||||
"properties": {
|
||||
"action": { "type": "string", "enum": ["click", "type", "navigate", "getText"] },
|
||||
"expected_domain": { "type": "string" },
|
||||
"selector": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"clear_first": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_browser_action_call(call: ToolFunctionCall) -> Result<BrowserActionCall, RuntimeError> {
|
||||
if call.name != BROWSER_ACTION_TOOL_NAME {
|
||||
return Err(RuntimeError::UnsupportedTool(call.name));
|
||||
}
|
||||
|
||||
let mut args = match call.arguments {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
return Err(RuntimeError::InvalidArguments(format!(
|
||||
"expected object arguments, got {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let action_name = take_required_string(&mut args, "action")?;
|
||||
let expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||
let action = parse_action(&action_name)?;
|
||||
let params = Value::Object(action_params_from_args(args));
|
||||
|
||||
Ok(BrowserActionCall {
|
||||
action,
|
||||
expected_domain,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_llm_error_to_pipe_error(err: LlmError) -> PipeError {
|
||||
PipeError::Protocol(err.to_string())
|
||||
}
|
||||
|
||||
fn parse_action(action_name: &str) -> Result<Action, RuntimeError> {
|
||||
match action_name {
|
||||
"click" => Ok(Action::Click),
|
||||
"type" => Ok(Action::Type),
|
||||
"navigate" => Ok(Action::Navigate),
|
||||
"getText" => Ok(Action::GetText),
|
||||
other => Err(RuntimeError::UnsupportedAction(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_required_string(
|
||||
args: &mut Map<String, Value>,
|
||||
key: &'static str,
|
||||
) -> Result<String, RuntimeError> {
|
||||
match args.remove(key) {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => Ok(value),
|
||||
Some(other) => Err(RuntimeError::InvalidArguments(format!(
|
||||
"{key} must be a non-empty string, got {other}"
|
||||
))),
|
||||
None => Err(RuntimeError::MissingField(key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn action_params_from_args(args: Map<String, Value>) -> Map<String, Value> {
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum RuntimeError {
|
||||
#[error("unsupported tool: {0}")]
|
||||
UnsupportedTool(String),
|
||||
#[error("unsupported action: {0}")]
|
||||
UnsupportedAction(String),
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid tool arguments: {0}")]
|
||||
InvalidArguments(String),
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{
|
||||
AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
||||
};
|
||||
use crate::runtime::RuntimeEngine;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRuntimeContext {
|
||||
config_path: Option<PathBuf>,
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl AgentRuntimeContext {
|
||||
pub fn new(config_path: Option<PathBuf>, workspace_root: PathBuf) -> Self {
|
||||
Self {
|
||||
config_path,
|
||||
workspace_root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_process_args<I, S>(args: I) -> Result<Self, PipeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let mut config_path = None;
|
||||
let mut args = args.into_iter().map(Into::into);
|
||||
let _ = args.next();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == OsString::from("--config-path") {
|
||||
let Some(value) = args.next() else {
|
||||
return Err(PipeError::Protocol(
|
||||
"missing value for --config-path".to_string(),
|
||||
));
|
||||
};
|
||||
config_path = Some(resolve_process_path(PathBuf::from(value)));
|
||||
continue;
|
||||
}
|
||||
|
||||
let arg_string = arg.to_string_lossy();
|
||||
if let Some(value) = arg_string.strip_prefix("--config-path=") {
|
||||
config_path = Some(resolve_process_path(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_root = config_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
|
||||
.unwrap_or_else(default_workspace_root);
|
||||
|
||||
Ok(Self::new(config_path, workspace_root))
|
||||
}
|
||||
|
||||
pub(crate) fn load_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||
SgClawSettings::load(self.config_path.as_deref())
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
fn settings_source_label(&self) -> String {
|
||||
match &self.config_path {
|
||||
Some(path) if path.exists() => path.display().to_string(),
|
||||
_ => "environment".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentRuntimeContext {
|
||||
fn default() -> Self {
|
||||
Self::new(None, default_workspace_root())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_workspace_root() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn resolve_process_path(path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
default_workspace_root().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_process_args_resolves_relative_config_path_against_current_dir() {
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let context = AgentRuntimeContext::from_process_args([
|
||||
OsString::from("sg_claw"),
|
||||
OsString::from("--config-path"),
|
||||
OsString::from("../tmp/sgclaw_config.json"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context.config_path,
|
||||
Some(current_dir.join("../tmp/sgclaw_config.json"))
|
||||
);
|
||||
assert_eq!(context.workspace_root, current_dir.join("../tmp"));
|
||||
assert!(context.workspace_root.is_absolute());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SubmitTaskRequest {
|
||||
pub instruction: String,
|
||||
pub conversation_id: Option<String>,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
pub page_url: Option<String>,
|
||||
pub page_title: Option<String>,
|
||||
}
|
||||
|
||||
pub trait AgentEventSink: Send + Sync {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError>;
|
||||
}
|
||||
|
||||
impl<T: Transport + ?Sized> AgentEventSink for T {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
||||
Transport::send(self, message)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_submit_task<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError> {
|
||||
let SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
} = request;
|
||||
let instruction = instruction.trim().to_string();
|
||||
if instruction.is_empty() {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
};
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dirs =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
||||
&& crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
)
|
||||
{
|
||||
let _ = send_mode_log(sink, "zeroclaw_process_message_primary");
|
||||
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_mode_log(sink, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: format!("failed to load DeepSeek config: {err}"),
|
||||
});
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sink.send(&completion)
|
||||
}
|
||||
|
||||
pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
||||
_transport: &T,
|
||||
sink: &dyn AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: SubmitTaskRequest,
|
||||
) -> Result<(), PipeError> {
|
||||
let SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
} = request;
|
||||
let instruction = instruction.trim().to_string();
|
||||
if instruction.is_empty() {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
};
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dirs =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
||||
&& crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
)
|
||||
{
|
||||
let _ = send_mode_log(sink, "zeroclaw_process_message_primary");
|
||||
match crate::compat::orchestration::execute_task_with_browser_backend(
|
||||
sink,
|
||||
browser_backend.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_mode_log(sink, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_browser_backend(
|
||||
sink,
|
||||
browser_backend,
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: format!("failed to load DeepSeek config: {err}"),
|
||||
});
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sink.send(&completion)
|
||||
}
|
||||
|
||||
fn send_mode_log(sink: &dyn AgentEventSink, mode: &str) -> Result<(), PipeError> {
|
||||
sink.send(&AgentMessage::LogEntry {
|
||||
level: "mode".to_string(),
|
||||
message: mode.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_llm_configuration_summary() -> String {
|
||||
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn runtime_version_log_message() -> String {
|
||||
format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
if let Err(err) = sgclaw::service::run() {
|
||||
eprintln!("sg_claw failed: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
use sgclaw::service::{ClientMessage, ServiceMessage};
|
||||
use tungstenite::{connect, Message};
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
match run() {
|
||||
Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("sg_claw_client failed: {err}");
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_request(input: &str) -> (ClientMessage, bool) {
|
||||
match input.trim() {
|
||||
"/connect" => (ClientMessage::Connect, true),
|
||||
"/start" => (ClientMessage::Start, true),
|
||||
"/stop" => (ClientMessage::Stop, true),
|
||||
instruction => (
|
||||
ClientMessage::SubmitTask {
|
||||
instruction: instruction.to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let service_url = std::env::var("SG_CLAW_SERVICE_WS_URL")
|
||||
.unwrap_or_else(|_| "ws://127.0.0.1:42321".to_string());
|
||||
let (mut socket, _) = connect(service_url.as_str()).map_err(|err| err.to_string())?;
|
||||
|
||||
let stdin = io::stdin();
|
||||
|
||||
loop {
|
||||
eprint!("> ");
|
||||
let mut input = String::new();
|
||||
let bytes_read = stdin
|
||||
.lock()
|
||||
.read_line(&mut input)
|
||||
.map_err(|err| err.to_string())?;
|
||||
if bytes_read == 0 {
|
||||
break; // EOF — graceful exit
|
||||
}
|
||||
if input.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (request, exit_on_status) = parse_request(&input);
|
||||
|
||||
let payload = serde_json::to_string(&request).map_err(|err| err.to_string())?;
|
||||
socket
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Inner loop: consume service messages until the task finishes.
|
||||
loop {
|
||||
match socket.read().map_err(|err| err.to_string())? {
|
||||
Message::Text(text) => {
|
||||
let message: ServiceMessage =
|
||||
serde_json::from_str(&text).map_err(|err| err.to_string())?;
|
||||
match message {
|
||||
ServiceMessage::StatusChanged { state } => {
|
||||
println!("status: {state}");
|
||||
if exit_on_status {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ServiceMessage::LogEntry { level: _, message } => {
|
||||
println!("{message}");
|
||||
}
|
||||
ServiceMessage::TaskComplete { success: _, summary } => {
|
||||
println!("{summary}");
|
||||
break;
|
||||
}
|
||||
ServiceMessage::Busy { message } => {
|
||||
eprintln!("busy: {message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => {
|
||||
return Err("service disconnected".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use sgclaw::{parse_probe_args, run_probe_script, ProbeOutcome};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("sgbrowser_ws_probe failed: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let config = match parse_probe_args(&args) {
|
||||
Ok(config) => config,
|
||||
Err(err) => return Err(err.to_string()),
|
||||
};
|
||||
let results = match run_probe_script(
|
||||
&config.ws_url,
|
||||
Duration::from_millis(config.timeout_ms),
|
||||
config.steps,
|
||||
) {
|
||||
Ok(results) => results,
|
||||
Err(err) => return Err(err.to_string()),
|
||||
};
|
||||
|
||||
for (index, result) in results.iter().enumerate() {
|
||||
println!("STEP {} {}", index + 1, result.label);
|
||||
println!("SEND: {}", result.sent);
|
||||
match &result.outcome {
|
||||
ProbeOutcome::Received(frames) => {
|
||||
if frames.is_empty() {
|
||||
println!("RECV: <none>");
|
||||
} else {
|
||||
for frame in frames {
|
||||
println!("RECV: {}", frame);
|
||||
}
|
||||
}
|
||||
println!("OUTCOME: received");
|
||||
}
|
||||
ProbeOutcome::NoReplyExpected => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: no-reply-expected");
|
||||
}
|
||||
ProbeOutcome::TimedOut => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: timeout");
|
||||
}
|
||||
ProbeOutcome::Closed => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: closed");
|
||||
}
|
||||
ProbeOutcome::ConnectFailed(message) => {
|
||||
println!("RECV: <none>");
|
||||
println!("OUTCOME: connect-failed");
|
||||
println!("DETAIL: {}", message);
|
||||
}
|
||||
}
|
||||
if index + 1 < results.len() {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError};
|
||||
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError>;
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata;
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn supports_live_input(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: BrowserBackend + ?Sized> BrowserBackend for Arc<T> {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.as_ref().invoke(action, params, expected_domain)
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.as_ref().surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.as_ref().supports_eval()
|
||||
}
|
||||
|
||||
fn supports_live_input(&self) -> bool {
|
||||
self.as_ref().supports_live_input()
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::browser::backend::BrowserBackend;
|
||||
use crate::browser::bridge_contract::{BridgeBrowserActionReply, BridgeBrowserActionRequest};
|
||||
use crate::browser::bridge_transport::BridgeActionTransport;
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
pub struct BridgeBrowserBackend {
|
||||
transport: Arc<dyn BridgeActionTransport>,
|
||||
mac_policy: MacPolicy,
|
||||
next_seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl BridgeBrowserBackend {
|
||||
pub fn new(transport: Arc<dyn BridgeActionTransport>, mac_policy: MacPolicy) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
mac_policy,
|
||||
next_seq: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for BridgeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let reply = self.transport.execute(BridgeBrowserActionRequest::new(
|
||||
action.as_str(),
|
||||
params,
|
||||
expected_domain,
|
||||
))?;
|
||||
|
||||
match reply {
|
||||
BridgeBrowserActionReply::Success(success) => Ok(CommandOutput {
|
||||
seq,
|
||||
success: true,
|
||||
data: success.data,
|
||||
aom_snapshot: success.aom_snapshot,
|
||||
timing: success.timing,
|
||||
}),
|
||||
BridgeBrowserActionReply::Error(error) => Err(PipeError::Protocol(format!(
|
||||
"bridge action failed: {}",
|
||||
error.message
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::pipe::Timing;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeLifecycleCall {
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask,
|
||||
}
|
||||
|
||||
impl BridgeLifecycleCall {
|
||||
pub fn bridge_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Connect => "sgclawConnect",
|
||||
Self::Start => "sgclawStart",
|
||||
Self::Stop => "sgclawStop",
|
||||
Self::SubmitTask => "sgclawSubmitTask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BridgeBrowserActionRequest {
|
||||
pub action: String,
|
||||
pub params: Value,
|
||||
pub expected_domain: String,
|
||||
}
|
||||
|
||||
impl BridgeBrowserActionRequest {
|
||||
pub fn new(
|
||||
action: impl Into<String>,
|
||||
params: Value,
|
||||
expected_domain: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
action: action.into(),
|
||||
params,
|
||||
expected_domain: expected_domain.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BridgeBrowserActionReply {
|
||||
Success(BridgeBrowserActionSuccess),
|
||||
Error(BridgeBrowserActionError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BridgeBrowserActionSuccess {
|
||||
pub data: Value,
|
||||
pub aom_snapshot: Vec<Value>,
|
||||
pub timing: Timing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BridgeBrowserActionError {
|
||||
pub message: String,
|
||||
pub details: Value,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
use crate::browser::bridge_contract::{BridgeBrowserActionReply, BridgeBrowserActionRequest};
|
||||
use crate::pipe::PipeError;
|
||||
|
||||
pub trait BridgeActionTransport: Send + Sync {
|
||||
fn execute(
|
||||
&self,
|
||||
request: BridgeBrowserActionRequest,
|
||||
) -> Result<BridgeBrowserActionReply, PipeError>;
|
||||
}
|
||||
@@ -1,970 +0,0 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::backend::BrowserBackend;
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
|
||||
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
|
||||
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
|
||||
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
|
||||
const SHOW_AREA: &str = "show";
|
||||
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
|
||||
pub trait BrowserCallbackHost: Send + Sync {
|
||||
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BrowserCallbackRequest {
|
||||
pub seq: u64,
|
||||
pub request_url: String,
|
||||
pub expected_domain: String,
|
||||
pub action: String,
|
||||
pub command: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BrowserCallbackResponse {
|
||||
Success(BrowserCallbackSuccess),
|
||||
Error(BrowserCallbackError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BrowserCallbackSuccess {
|
||||
pub success: bool,
|
||||
pub data: Value,
|
||||
pub aom_snapshot: Vec<Value>,
|
||||
pub timing: Timing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BrowserCallbackError {
|
||||
pub message: String,
|
||||
pub details: Value,
|
||||
}
|
||||
|
||||
pub struct BrowserCallbackBackend {
|
||||
host: Arc<dyn BrowserCallbackHost>,
|
||||
mac_policy: MacPolicy,
|
||||
helper_page_url: String,
|
||||
current_target_url: Mutex<Option<String>>,
|
||||
next_seq: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CallbackInputMode {
|
||||
Click,
|
||||
Type,
|
||||
}
|
||||
|
||||
impl BrowserCallbackBackend {
|
||||
pub fn new(
|
||||
host: Arc<dyn BrowserCallbackHost>,
|
||||
mac_policy: MacPolicy,
|
||||
helper_page_url: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
host,
|
||||
mac_policy,
|
||||
helper_page_url: helper_page_url.into(),
|
||||
current_target_url: Mutex::new(None),
|
||||
next_seq: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command(&self, action: &Action, params: &Value) -> Result<Value, PipeError> {
|
||||
match action {
|
||||
Action::Navigate => {
|
||||
let target_url = required_string(params, "url")?;
|
||||
// Use sgBrowerserOpenPage to open the target URL in a **new**
|
||||
// visible browser tab. This keeps the helper page alive so its
|
||||
// WebSocket connection, command polling, and callback functions
|
||||
// remain functional for subsequent GetText / Eval commands.
|
||||
//
|
||||
// sgBrowserCallAfterLoaded would navigate the helper page tab
|
||||
// itself to the target URL, destroying all helper-page JS
|
||||
// context and making further communication impossible.
|
||||
//
|
||||
// sgBrowerserOpenPage does not fire a JS callback; the callback
|
||||
// host will treat the navigate action as fire-and-forget and
|
||||
// return success once the command has been forwarded.
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowerserOpenPage",
|
||||
target_url,
|
||||
]))
|
||||
}
|
||||
Action::Click => self.build_input_command(action, params, CallbackInputMode::Click),
|
||||
Action::Type => self.build_input_command(action, params, CallbackInputMode::Type),
|
||||
Action::GetText => {
|
||||
let target_url = self.target_url(action, params)?;
|
||||
let domain = extract_domain(&target_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let js_code = build_get_text_js(&self.helper_page_url, &selector);
|
||||
// Use sgBrowserExcuteJsCodeByDomain (API #25) which matches
|
||||
// pages by domain rather than exact URL. This is far more
|
||||
// robust than sgBrowserExcuteJsCodeByArea because the actual
|
||||
// page URL may differ from what we navigated to (redirects,
|
||||
// query parameters, etc.).
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
domain,
|
||||
js_code,
|
||||
SHOW_AREA,
|
||||
]))
|
||||
}
|
||||
Action::Eval => {
|
||||
let target_url = self.target_url(action, params)?;
|
||||
let domain = extract_domain(&target_url)?;
|
||||
let script = required_string(params, "script")?;
|
||||
let js_code = build_eval_js(&self.helper_page_url, &script);
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
domain,
|
||||
js_code,
|
||||
SHOW_AREA,
|
||||
]))
|
||||
}
|
||||
_ => Err(PipeError::Protocol(format!(
|
||||
"unsupported callback-host browser action: {}",
|
||||
action.as_str()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_command(
|
||||
&self,
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
mode: CallbackInputMode,
|
||||
) -> Result<Value, PipeError> {
|
||||
let target_url = self.target_url(action, params)?;
|
||||
let domain = extract_domain(&target_url)?;
|
||||
let selector = optional_string(params, "selector");
|
||||
let probe_script = optional_string(params, "probe_script");
|
||||
let text = matches!(mode, CallbackInputMode::Type)
|
||||
.then(|| required_string(params, "text"))
|
||||
.transpose()?;
|
||||
let js_code = build_input_probe_js(
|
||||
mode,
|
||||
&self.helper_page_url,
|
||||
selector.as_deref(),
|
||||
probe_script.as_deref(),
|
||||
text.as_deref(),
|
||||
)?;
|
||||
Ok(json!([
|
||||
self.helper_page_url,
|
||||
"sgBrowserExcuteJsCodeByDomain",
|
||||
domain,
|
||||
js_code,
|
||||
SHOW_AREA,
|
||||
]))
|
||||
}
|
||||
|
||||
fn target_url(&self, action: &Action, params: &Value) -> Result<String, PipeError> {
|
||||
if let Some(target_url) = params
|
||||
.get("target_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
{
|
||||
return Ok(target_url);
|
||||
}
|
||||
|
||||
self.current_target_url
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("callback backend target url lock poisoned".to_string()))?
|
||||
.clone()
|
||||
.ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
|
||||
}
|
||||
|
||||
fn execute_simulated_click(
|
||||
&self,
|
||||
seq: u64,
|
||||
expected_domain: &str,
|
||||
success: &BrowserCallbackSuccess,
|
||||
) -> Result<BrowserCallbackSuccess, PipeError> {
|
||||
let probe = success
|
||||
.data
|
||||
.get("probe")
|
||||
.ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?;
|
||||
let x = probe
|
||||
.get("x")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback click probe missing x".to_string()))?;
|
||||
let y = probe
|
||||
.get("y")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback click probe missing y".to_string()))?;
|
||||
let timing = success.timing.clone();
|
||||
match self.host.execute(BrowserCallbackRequest {
|
||||
seq,
|
||||
request_url: self.helper_page_url.clone(),
|
||||
expected_domain: expected_domain.to_string(),
|
||||
action: Action::Click.as_str().to_string(),
|
||||
command: json!([
|
||||
self.helper_page_url,
|
||||
"sgBroewserSimulateMouse",
|
||||
x,
|
||||
y,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]),
|
||||
}) {
|
||||
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
||||
"callback host browser action failed: {} ({})",
|
||||
error.message, error.details
|
||||
))),
|
||||
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
||||
Ok(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"clicked": true,
|
||||
"probe": { "x": x, "y": y },
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_simulated_type(
|
||||
&self,
|
||||
seq: u64,
|
||||
expected_domain: &str,
|
||||
params: &Value,
|
||||
success: &BrowserCallbackSuccess,
|
||||
) -> Result<BrowserCallbackSuccess, PipeError> {
|
||||
let probe = success
|
||||
.data
|
||||
.get("probe")
|
||||
.ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?;
|
||||
let x = probe
|
||||
.get("x")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback type probe missing x".to_string()))?;
|
||||
let y = probe
|
||||
.get("y")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| PipeError::Protocol("callback type probe missing y".to_string()))?;
|
||||
let text = params
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol("text is required".to_string()))?;
|
||||
let timing = success.timing.clone();
|
||||
match self.host.execute(BrowserCallbackRequest {
|
||||
seq,
|
||||
request_url: self.helper_page_url.clone(),
|
||||
expected_domain: expected_domain.to_string(),
|
||||
action: Action::Type.as_str().to_string(),
|
||||
command: json!([
|
||||
self.helper_page_url,
|
||||
"sgBroewserSimulateKeyborad",
|
||||
x,
|
||||
y,
|
||||
text
|
||||
]),
|
||||
}) {
|
||||
Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
|
||||
"callback host browser action failed: {} ({})",
|
||||
error.message, error.details
|
||||
))),
|
||||
Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
|
||||
Ok(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({
|
||||
"typed": true,
|
||||
"probe": { "x": x, "y": y, "text": text },
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for BrowserCallbackBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
if let Some(local_dashboard) = approved_local_dashboard_request(&action, ¶ms, expected_domain)
|
||||
{
|
||||
self.mac_policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&action,
|
||||
expected_domain,
|
||||
&local_dashboard.presentation_url,
|
||||
&local_dashboard.output_path,
|
||||
)
|
||||
.map_err(PipeError::Security)?;
|
||||
} else {
|
||||
self.mac_policy
|
||||
.validate(&action, expected_domain)
|
||||
.map_err(PipeError::Security)?;
|
||||
}
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let reply = self.host.execute(BrowserCallbackRequest {
|
||||
seq,
|
||||
request_url: self.helper_page_url.clone(),
|
||||
expected_domain: expected_domain.to_string(),
|
||||
action: action.as_str().to_string(),
|
||||
command: self.build_command(&action, ¶ms)?,
|
||||
})?;
|
||||
|
||||
match reply {
|
||||
BrowserCallbackResponse::Success(success) => {
|
||||
let success = match action {
|
||||
Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?,
|
||||
Action::Type => {
|
||||
self.execute_simulated_type(seq, expected_domain, ¶ms, &success)?
|
||||
}
|
||||
_ => success,
|
||||
};
|
||||
if matches!(action, Action::Navigate) {
|
||||
if let Some(url) = params
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
*self.current_target_url.lock().map_err(|_| {
|
||||
PipeError::Protocol("callback backend target url lock poisoned".to_string())
|
||||
})? = Some(url.to_string());
|
||||
}
|
||||
}
|
||||
Ok(CommandOutput {
|
||||
seq,
|
||||
success: success.success,
|
||||
data: success.data,
|
||||
aom_snapshot: success.aom_snapshot,
|
||||
timing: success.timing,
|
||||
})
|
||||
}
|
||||
BrowserCallbackResponse::Error(error) => Err(PipeError::Protocol(format!(
|
||||
"callback host browser action failed: {} ({})",
|
||||
error.message, error.details
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
|
||||
fn supports_live_input(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Click)
|
||||
&& self.mac_policy.supports_pipe_action(&Action::Type)
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn optional_string(params: &Value, key: &str) -> Option<String> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn build_get_text_js(source_url: &str, selector: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let escaped_selector = escape_js_single_quoted(selector);
|
||||
let callback = GET_TEXT_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
// Three delivery paths for getting the result back to the callback host:
|
||||
//
|
||||
// 1. callBackJsToCpp (API #40) — browser-native IPC that routes the
|
||||
// callback function to the helper page.
|
||||
// 2. XMLHttpRequest POST to callback host — localhost (127.0.0.1) is
|
||||
// exempt from mixed-content restrictions in Chromium.
|
||||
// 3. navigator.sendBeacon fallback — same localhost exemption.
|
||||
//
|
||||
// The XHR / sendBeacon paths POST the event DIRECTLY in the format the
|
||||
// callback host expects (callback="sgclawOnGetText", payload={text:...})
|
||||
// so normalize_callback_result can process it via Path A.
|
||||
format!(
|
||||
"(function(){{try{{\
|
||||
var el=document.querySelector('{escaped_selector}');\
|
||||
var t=el?((el.innerText||el.textContent||'').trim()):'';\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+t)}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{text:t}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_eval_js(source_url: &str, script: &str) -> String {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = EVAL_CALLBACK_NAME;
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
|
||||
format!(
|
||||
"(function(){{try{{var v=(function(){{return {script}}})();\
|
||||
var t=(typeof v==='string')?v:JSON.stringify(v);\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_input_probe_js(
|
||||
mode: CallbackInputMode,
|
||||
source_url: &str,
|
||||
selector: Option<&str>,
|
||||
probe_script: Option<&str>,
|
||||
text: Option<&str>,
|
||||
) -> Result<String, PipeError> {
|
||||
let escaped_source_url = escape_js_single_quoted(source_url);
|
||||
let callback = match mode {
|
||||
CallbackInputMode::Click => CLICK_PROBE_CALLBACK_NAME,
|
||||
CallbackInputMode::Type => TYPE_PROBE_CALLBACK_NAME,
|
||||
};
|
||||
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
|
||||
let payload_expression = match mode {
|
||||
CallbackInputMode::Click => "JSON.stringify({x:x,y:y})".to_string(),
|
||||
CallbackInputMode::Type => {
|
||||
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
||||
format!("JSON.stringify({{x:x,y:y,text:'{escaped_text}'}})")
|
||||
}
|
||||
};
|
||||
let payload_object = match mode {
|
||||
CallbackInputMode::Click => "{x:x,y:y}".to_string(),
|
||||
CallbackInputMode::Type => {
|
||||
let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
|
||||
format!("{{x:x,y:y,text:'{escaped_text}'}}")
|
||||
}
|
||||
};
|
||||
let element_lookup = if let Some(script) = probe_script {
|
||||
format!("(function(){{{script}}})()")
|
||||
} else if let Some(selector) = selector {
|
||||
let escaped_selector = escape_js_single_quoted(selector);
|
||||
format!("document.querySelector('{escaped_selector}')")
|
||||
} else {
|
||||
return Err(PipeError::Protocol(
|
||||
"selector or probe_script is required".to_string(),
|
||||
));
|
||||
};
|
||||
let missing_hint = selector
|
||||
.map(|value| format!("selector not found: {}", escape_js_single_quoted(value)))
|
||||
.unwrap_or_else(|| "input probe target not found".to_string());
|
||||
|
||||
Ok(format!(
|
||||
"(function(){{try{{\
|
||||
var el={element_lookup};\
|
||||
if(!el){{throw new Error('{missing_hint}');}}\
|
||||
var rect=(typeof el.getBoundingClientRect==='function')?el.getBoundingClientRect():null;\
|
||||
var x=rect?(rect.left+(rect.width/2)):0;\
|
||||
var y=rect?(rect.top+(rect.height/2)):0;\
|
||||
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+String({payload_expression}))}}catch(_){{}}\
|
||||
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{payload_object}}});\
|
||||
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
|
||||
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
|
||||
}}catch(e){{}}}})()"
|
||||
))
|
||||
}
|
||||
|
||||
/// Derive the callback host events endpoint URL from the helper page URL.
|
||||
/// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html"
|
||||
/// → "http://127.0.0.1:62819/sgclaw/callback/events"
|
||||
fn events_endpoint_url(helper_page_url: &str) -> String {
|
||||
let origin = helper_page_url
|
||||
.find("://")
|
||||
.and_then(|scheme_end| {
|
||||
helper_page_url[scheme_end + 3..]
|
||||
.find('/')
|
||||
.map(|path_start| &helper_page_url[..scheme_end + 3 + path_start])
|
||||
})
|
||||
.unwrap_or(helper_page_url);
|
||||
format!("{origin}/sgclaw/callback/events")
|
||||
}
|
||||
|
||||
/// Extract the domain from a URL.
|
||||
/// e.g. "https://www.zhihu.com/hot" → "www.zhihu.com"
|
||||
fn extract_domain(url: &str) -> Result<String, PipeError> {
|
||||
let after_scheme = url
|
||||
.find("://")
|
||||
.map(|i| &url[i + 3..])
|
||||
.unwrap_or(url);
|
||||
let domain = after_scheme
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(after_scheme)
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(after_scheme);
|
||||
if domain.is_empty() {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"failed to extract domain from URL: {url}"
|
||||
)));
|
||||
}
|
||||
Ok(domain.to_string())
|
||||
}
|
||||
|
||||
fn escape_js_single_quoted(raw: &str) -> String {
|
||||
raw.replace('\\', "\\\\")
|
||||
.replace('\'', "\\'")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\0', "\\0")
|
||||
.replace('\u{2028}', "\\u2028")
|
||||
.replace('\u{2029}', "\\u2029")
|
||||
}
|
||||
|
||||
struct LocalDashboardRequest {
|
||||
presentation_url: String,
|
||||
output_path: String,
|
||||
}
|
||||
|
||||
fn approved_local_dashboard_request(
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
expected_domain: &str,
|
||||
) -> Option<LocalDashboardRequest> {
|
||||
if action != &Action::Navigate || expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let presentation_url = params.get("url")?.as_str()?.trim();
|
||||
let marker = params.get("sgclaw_local_dashboard_open")?.as_object()?;
|
||||
let source = marker.get("source")?.as_str()?.trim();
|
||||
let kind = marker.get("kind")?.as_str()?.trim();
|
||||
let output_path = marker.get("output_path")?.as_str()?.trim();
|
||||
let marker_presentation_url = marker.get("presentation_url")?.as_str()?.trim();
|
||||
|
||||
if source != LOCAL_DASHBOARD_SOURCE
|
||||
|| kind != LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN
|
||||
|| output_path.is_empty()
|
||||
|| presentation_url.is_empty()
|
||||
|| marker_presentation_url != presentation_url
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(LocalDashboardRequest {
|
||||
presentation_url: presentation_url.to_string(),
|
||||
output_path: output_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct FakeCallbackHost {
|
||||
requests: Mutex<Vec<BrowserCallbackRequest>>,
|
||||
replies: Mutex<VecDeque<Result<BrowserCallbackResponse, PipeError>>>,
|
||||
}
|
||||
|
||||
impl FakeCallbackHost {
|
||||
fn new(replies: Vec<Result<BrowserCallbackResponse, PipeError>>) -> Self {
|
||||
Self {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
replies: Mutex::new(VecDeque::from(replies)),
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<BrowserCallbackRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserCallbackHost for FakeCallbackHost {
|
||||
fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
self.replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||
}
|
||||
}
|
||||
|
||||
fn success_reply(data: Value) -> Result<BrowserCallbackResponse, PipeError> {
|
||||
Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_click_treats_simulated_mouse_follow_up_as_fire_and_forget() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
||||
json!({ "probe": { "x": 320.5, "y": 240.25 } }),
|
||||
)]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "button"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateMouse",
|
||||
320.5,
|
||||
240.25,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_click_survives_simulated_mouse_timeout() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
||||
Err(PipeError::Timeout),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "button"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.expect("simulated mouse timeout should be treated as fire-and-forget success");
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_click_uses_domain_probe_then_simulated_mouse_input() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
|
||||
success_reply(json!({ "clicked": true })),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "button"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].action, "click");
|
||||
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
||||
let script = requests[0].command[3].as_str().unwrap();
|
||||
assert!(script.contains("document.querySelector('button')"));
|
||||
assert!(script.contains("sgclawOnClick"));
|
||||
assert_eq!(requests[1].action, "click");
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateMouse",
|
||||
320.5,
|
||||
240.25,
|
||||
"left",
|
||||
"",
|
||||
""
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_type_treats_simulated_keyboard_follow_up_as_fire_and_forget() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
|
||||
json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } }),
|
||||
)]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "div[contenteditable='true']",
|
||||
"text": "正文"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateKeyborad",
|
||||
160.0,
|
||||
90.0,
|
||||
"正文"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_type_uses_custom_probe_script_when_provided() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
||||
success_reply(json!({ "typed": true })),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"probe_script": "return document.body;",
|
||||
"text": "正文"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let script = requests[0].command[3].as_str().unwrap();
|
||||
assert!(script.contains("return document.body;"));
|
||||
assert!(!script.contains("selector not found: div[contenteditable='true']"));
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateKeyborad",
|
||||
160.0,
|
||||
90.0,
|
||||
"正文"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_type_uses_domain_probe_then_simulated_keyboard_input() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![
|
||||
success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
|
||||
success_reply(json!({ "typed": true })),
|
||||
]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://zhuanlan.zhihu.com/write",
|
||||
"selector": "div[contenteditable='true']",
|
||||
"text": "正文"
|
||||
}),
|
||||
"zhuanlan.zhihu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].action, "type");
|
||||
assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||
assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
|
||||
let script = requests[0].command[3].as_str().unwrap();
|
||||
assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')"));
|
||||
assert!(script.contains("sgclawOnType"));
|
||||
assert!(!script.contains("el.value="));
|
||||
assert_eq!(requests[1].action, "type");
|
||||
assert_eq!(requests[1].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBroewserSimulateKeyborad",
|
||||
160.0,
|
||||
90.0,
|
||||
"正文"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_accepts_approved_local_dashboard_navigate_request() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({
|
||||
"navigated": true
|
||||
}))]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
)
|
||||
.expect("approved local dashboard request should be accepted");
|
||||
|
||||
assert!(output.success);
|
||||
let requests = host.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].command, json!([
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
"sgBrowerserOpenPage",
|
||||
"file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() {
|
||||
let host = Arc::new(FakeCallbackHost::new(vec![]));
|
||||
let backend = BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
test_policy(),
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
);
|
||||
|
||||
let err = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(host.requests().is_empty());
|
||||
assert!(err.to_string().contains("domain is not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_js_single_quoted_escapes_newlines_and_control_chars() {
|
||||
let raw = "第一行\n第二行\r\n第三行";
|
||||
let escaped = escape_js_single_quoted(raw);
|
||||
assert!(!escaped.contains('\n'), "literal newline must be escaped");
|
||||
assert!(!escaped.contains('\r'), "literal carriage return must be escaped");
|
||||
assert!(escaped.contains("\\n"), "should contain escaped newline");
|
||||
assert!(escaped.contains("\\r"), "should contain escaped carriage return");
|
||||
assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_probe_script_with_multiline_text_is_valid_js() {
|
||||
let text_with_newlines = "标题\n\n正文第一段\n正文第二段";
|
||||
let js = build_input_probe_js(
|
||||
CallbackInputMode::Type,
|
||||
"http://127.0.0.1:17888/sgclaw/browser-helper.html",
|
||||
Some("div[contenteditable='true']"),
|
||||
None,
|
||||
Some(text_with_newlines),
|
||||
)
|
||||
.unwrap();
|
||||
// The generated JS must NOT contain literal newlines inside single-quoted strings.
|
||||
// Split on single quotes and check inner segments.
|
||||
assert!(
|
||||
!js.contains("标题\n"),
|
||||
"literal newline must not appear in the JS probe script"
|
||||
);
|
||||
assert!(js.contains("标题\\n"));
|
||||
assert!(js.contains("sgclawOnTypeProbe"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
pub mod bridge_backend;
|
||||
pub mod bridge_contract;
|
||||
pub mod bridge_transport;
|
||||
pub mod callback_backend;
|
||||
mod backend;
|
||||
pub(crate) mod callback_host;
|
||||
mod pipe_backend;
|
||||
pub mod ws_backend;
|
||||
pub mod ws_probe;
|
||||
pub mod ws_protocol;
|
||||
|
||||
pub use backend::BrowserBackend;
|
||||
pub use bridge_backend::BridgeBrowserBackend;
|
||||
pub use callback_backend::{
|
||||
BrowserCallbackBackend, BrowserCallbackError, BrowserCallbackHost,
|
||||
BrowserCallbackRequest, BrowserCallbackResponse, BrowserCallbackSuccess,
|
||||
};
|
||||
pub use pipe_backend::PipeBrowserBackend;
|
||||
pub use ws_backend::WsBrowserBackend;
|
||||
@@ -1,55 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, BrowserPipeTool, CommandOutput, ExecutionSurfaceMetadata, PipeError, Transport};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
pub struct PipeBrowserBackend<T: Transport> {
|
||||
inner: BrowserPipeTool<T>,
|
||||
}
|
||||
|
||||
impl<T: Transport> PipeBrowserBackend<T> {
|
||||
pub fn new(transport: Arc<T>, mac_policy: MacPolicy, session_key: Vec<u8>) -> Self {
|
||||
Self {
|
||||
inner: BrowserPipeTool::new(transport, mac_policy, session_key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_inner(inner: BrowserPipeTool<T>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub fn with_response_timeout(mut self, response_timeout: std::time::Duration) -> Self {
|
||||
self.inner = self.inner.with_response_timeout(response_timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport> Clone for PipeBrowserBackend<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport> BrowserBackend for PipeBrowserBackend<T> {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.inner.invoke(action, params, expected_domain)
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.inner.surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.inner.supports_eval()
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::{ws_protocol, BrowserBackend};
|
||||
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
pub trait WsClient: Send + Sync {
|
||||
fn send_text(&self, payload: &str) -> Result<(), PipeError>;
|
||||
fn recv_text_timeout(&self, timeout: Duration) -> Result<String, PipeError>;
|
||||
}
|
||||
|
||||
pub struct WsBrowserBackend<C: WsClient> {
|
||||
client: Arc<C>,
|
||||
mac_policy: MacPolicy,
|
||||
request_url: Mutex<String>,
|
||||
next_seq: AtomicU64,
|
||||
response_timeout: Duration,
|
||||
in_flight: Mutex<()>,
|
||||
}
|
||||
|
||||
impl<C: WsClient> WsBrowserBackend<C> {
|
||||
pub fn new(client: Arc<C>, mac_policy: MacPolicy, request_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
mac_policy,
|
||||
request_url: Mutex::new(request_url.into()),
|
||||
next_seq: AtomicU64::new(1),
|
||||
response_timeout: Duration::from_secs(30),
|
||||
in_flight: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self {
|
||||
self.response_timeout = response_timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: WsClient> BrowserBackend for WsBrowserBackend<C> {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
let _guard = self
|
||||
.in_flight
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("browser ws request lock poisoned".to_string()))?;
|
||||
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let request_id = seq.to_string();
|
||||
let request_url = self
|
||||
.request_url
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("browser ws request url lock poisoned".to_string()))?
|
||||
.clone();
|
||||
let encoded = ws_protocol::encode_v1_action(
|
||||
&action,
|
||||
¶ms,
|
||||
&request_url,
|
||||
Some(request_id.as_str()),
|
||||
)?;
|
||||
|
||||
self.client.send_text(&encoded.payload)?;
|
||||
|
||||
let status = Some(recv_status_frame(&*self.client, self.response_timeout)?);
|
||||
if let Some(status) = status {
|
||||
let status_code = parse_status_code(&status)?;
|
||||
if status_code != 0 {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser returned non-zero status: {status_code}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if action == Action::Navigate {
|
||||
if let Some(url) = params.get("url").and_then(Value::as_str) {
|
||||
let mut request_url = self.request_url.lock().map_err(|_| {
|
||||
PipeError::Protocol("browser ws request url lock poisoned".to_string())
|
||||
})?;
|
||||
*request_url = url.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(callback) = encoded.callback {
|
||||
loop {
|
||||
let frame = self.client.recv_text_timeout(self.response_timeout)?;
|
||||
let decoded = ws_protocol::decode_callback_frame(&frame)?;
|
||||
if decoded.callback_name == callback.callback_name {
|
||||
return Ok(CommandOutput {
|
||||
seq,
|
||||
success: true,
|
||||
data: json!({ "text": decoded.response_text }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 0,
|
||||
exec_ms: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommandOutput {
|
||||
seq,
|
||||
success: true,
|
||||
data: json!({}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 0,
|
||||
exec_ms: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status_code(raw: &str) -> Result<i64, PipeError> {
|
||||
raw.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| PipeError::Protocol(format!("invalid browser status frame: {raw}")))
|
||||
}
|
||||
|
||||
fn recv_status_frame(client: &dyn WsClient, timeout: Duration) -> Result<String, PipeError> {
|
||||
loop {
|
||||
let frame = client.recv_text_timeout(timeout)?;
|
||||
if is_ignorable_status_prelude(&frame) {
|
||||
continue;
|
||||
}
|
||||
return Ok(frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignorable_status_prelude(frame: &str) -> bool {
|
||||
let trimmed = frame.trim();
|
||||
if trimmed.starts_with("Welcome!") || trimmed.starts_with("Welcome ") {
|
||||
return true;
|
||||
}
|
||||
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.ok()
|
||||
.and_then(|value| value.get("type").and_then(Value::as_str).map(str::to_string))
|
||||
.is_some_and(|kind| kind == "welcome")
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
use tungstenite::stream::MaybeTlsStream;
|
||||
use tungstenite::{connect, Message, WebSocket};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeStep {
|
||||
pub label: String,
|
||||
pub payload: String,
|
||||
pub expect_reply: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProbeOutcome {
|
||||
Received(Vec<String>),
|
||||
NoReplyExpected,
|
||||
TimedOut,
|
||||
Closed,
|
||||
ConnectFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeStepResult {
|
||||
pub label: String,
|
||||
pub sent: String,
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeCliConfig {
|
||||
pub ws_url: String,
|
||||
pub timeout_ms: u64,
|
||||
pub steps: Vec<ProbeStep>,
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 1500;
|
||||
const DEFAULT_REGISTER_STEP_LABEL: &str = "register";
|
||||
const DEFAULT_REGISTER_STEP_PAYLOAD: &str = r#"{"type":"register","role":"web"}"#;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProbeError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("probe timeout while waiting for websocket frame")]
|
||||
Timeout,
|
||||
#[error("probe websocket closed")]
|
||||
Closed,
|
||||
#[error("probe protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("probe argument error: {0}")]
|
||||
Args(String),
|
||||
}
|
||||
|
||||
pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError> {
|
||||
let mut ws_url = None;
|
||||
let mut timeout_ms = None;
|
||||
let mut steps = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < args.len() {
|
||||
match args[index].as_str() {
|
||||
"--ws-url" => {
|
||||
index += 1;
|
||||
let value = args
|
||||
.get(index)
|
||||
.ok_or_else(|| ProbeError::Args("missing value for --ws-url".to_string()))?;
|
||||
ws_url = Some(value.clone());
|
||||
}
|
||||
"--timeout-ms" => {
|
||||
index += 1;
|
||||
let value = args.get(index).ok_or_else(|| {
|
||||
ProbeError::Args("missing value for --timeout-ms".to_string())
|
||||
})?;
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
ProbeError::Args(format!("invalid --timeout-ms value: {value}"))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
"--step" => {
|
||||
index += 1;
|
||||
let value = args
|
||||
.get(index)
|
||||
.ok_or_else(|| ProbeError::Args("missing value for --step".to_string()))?;
|
||||
let (label, payload) = value.split_once("::").ok_or_else(|| {
|
||||
ProbeError::Args(format!(
|
||||
"invalid --step value (expected <label>::<payload>): {value}"
|
||||
))
|
||||
})?;
|
||||
if label.is_empty() {
|
||||
return Err(ProbeError::Args("step label must not be empty".to_string()));
|
||||
}
|
||||
if payload.is_empty() {
|
||||
return Err(ProbeError::Args("step payload must not be empty".to_string()));
|
||||
}
|
||||
steps.push(ProbeStep {
|
||||
label: label.to_string(),
|
||||
payload: payload.to_string(),
|
||||
expect_reply: true,
|
||||
});
|
||||
}
|
||||
flag => {
|
||||
return Err(ProbeError::Args(format!("unknown argument: {flag}")));
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
let ws_url = ws_url.ok_or_else(|| ProbeError::Args("missing required --ws-url".to_string()))?;
|
||||
validate_ws_url(&ws_url)?;
|
||||
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
|
||||
if steps.is_empty() {
|
||||
steps.push(ProbeStep {
|
||||
label: DEFAULT_REGISTER_STEP_LABEL.to_string(),
|
||||
payload: DEFAULT_REGISTER_STEP_PAYLOAD.to_string(),
|
||||
expect_reply: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProbeCliConfig {
|
||||
ws_url,
|
||||
timeout_ms,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ws_url(ws_url: &str) -> Result<(), ProbeError> {
|
||||
if ws_url.starts_with("ws://") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ProbeError::Args(format!(
|
||||
"unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn run_probe_script(
|
||||
ws_url: &str,
|
||||
timeout: Duration,
|
||||
steps: Vec<ProbeStep>,
|
||||
) -> Result<Vec<ProbeStepResult>, ProbeError> {
|
||||
let mut socket = match connect(ws_url) {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
return Ok(steps
|
||||
.into_iter()
|
||||
.map(|step| ProbeStepResult {
|
||||
label: step.label,
|
||||
sent: step.payload,
|
||||
outcome: ProbeOutcome::ConnectFailed(message.clone()),
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
};
|
||||
|
||||
configure_socket_timeout(&mut socket, timeout)?;
|
||||
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
for step in steps {
|
||||
let ProbeStep {
|
||||
label,
|
||||
payload,
|
||||
expect_reply,
|
||||
} = step;
|
||||
|
||||
let send_outcome = match socket.send(Message::Text(payload.clone().into())) {
|
||||
Ok(()) => None,
|
||||
Err(err) => Some(map_websocket_error(err, "browser websocket send")),
|
||||
};
|
||||
|
||||
let outcome = match send_outcome {
|
||||
Some(ProbeError::Timeout) => ProbeOutcome::TimedOut,
|
||||
Some(ProbeError::Closed) => ProbeOutcome::Closed,
|
||||
Some(err) => return Err(err),
|
||||
None if expect_reply => match read_probe_frames(&mut socket) {
|
||||
Ok(frames) => ProbeOutcome::Received(frames),
|
||||
Err(ProbeError::Timeout) => ProbeOutcome::TimedOut,
|
||||
Err(ProbeError::Closed) => ProbeOutcome::Closed,
|
||||
Err(err) => return Err(err),
|
||||
},
|
||||
None => ProbeOutcome::NoReplyExpected,
|
||||
};
|
||||
|
||||
results.push(ProbeStepResult {
|
||||
label,
|
||||
sent: payload,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn configure_socket_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
timeout: Duration,
|
||||
) -> Result<(), ProbeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_probe_frames(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
) -> Result<Vec<String>, ProbeError> {
|
||||
let first_frame = read_probe_frame(websocket)?;
|
||||
let mut frames = vec![first_frame];
|
||||
|
||||
let Some(original_timeout) = get_plain_read_timeout(websocket)? else {
|
||||
return Ok(frames);
|
||||
};
|
||||
|
||||
set_plain_read_timeout(websocket, Some(Duration::from_millis(1)))?;
|
||||
|
||||
loop {
|
||||
match read_probe_frame(websocket) {
|
||||
Ok(frame) => frames.push(frame),
|
||||
Err(ProbeError::Timeout) | Err(ProbeError::Closed) => break,
|
||||
Err(err) => {
|
||||
set_plain_read_timeout(websocket, original_timeout)?;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_plain_read_timeout(websocket, original_timeout)?;
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn get_plain_read_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
) -> Result<Option<Option<Duration>>, ProbeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => Ok(Some(stream.read_timeout()?)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_plain_read_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), ProbeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => {
|
||||
stream.set_read_timeout(timeout)?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_probe_frame(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
) -> Result<String, ProbeError> {
|
||||
loop {
|
||||
match websocket.read() {
|
||||
Ok(Message::Text(text)) => return Ok(text.to_string()),
|
||||
Ok(Message::Close(_)) => return Err(ProbeError::Closed),
|
||||
Ok(Message::Ping(payload)) => {
|
||||
websocket
|
||||
.send(Message::Pong(payload))
|
||||
.map_err(|err| map_websocket_error(err, "browser websocket pong"))?;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(map_websocket_error(err, "browser websocket read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_websocket_error(err: tungstenite::Error, operation: &str) -> ProbeError {
|
||||
match err {
|
||||
tungstenite::Error::ConnectionClosed
|
||||
| tungstenite::Error::AlreadyClosed
|
||||
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)
|
||||
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing) => {
|
||||
ProbeError::Closed
|
||||
}
|
||||
tungstenite::Error::Io(io_err)
|
||||
if matches!(
|
||||
io_err.kind(),
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
ProbeError::Timeout
|
||||
}
|
||||
tungstenite::Error::Io(io_err)
|
||||
if matches!(
|
||||
io_err.kind(),
|
||||
std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
) =>
|
||||
{
|
||||
ProbeError::Closed
|
||||
}
|
||||
tungstenite::Error::Io(io_err) => ProbeError::Io(io_err),
|
||||
other => ProbeError::Protocol(format!("{operation} failed: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::pipe::{Action, PipeError};
|
||||
|
||||
const CALLBACK_DELIMITER: &str = "@_@";
|
||||
const CALLBACK_PREFIX: &str = "sgclaw_cb_";
|
||||
const JS_AREA_HIDE: &str = "hide";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CallbackCorrelation {
|
||||
pub request_id: String,
|
||||
pub callback_name: String,
|
||||
pub source_url: String,
|
||||
pub target_url: String,
|
||||
pub action_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncodedWsRequest {
|
||||
pub payload: String,
|
||||
pub callback: Option<CallbackCorrelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedCallback {
|
||||
pub source_url: String,
|
||||
pub target_url: String,
|
||||
pub callback_name: String,
|
||||
pub action_url: String,
|
||||
pub response_text: String,
|
||||
}
|
||||
|
||||
pub fn encode_v1_action(
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
match action {
|
||||
Action::Navigate => encode_navigate(params, request_url, request_id),
|
||||
Action::Click => encode_click(params, request_url),
|
||||
Action::Type => encode_type(params, request_url),
|
||||
Action::GetText => encode_get_text(params, request_url, request_id),
|
||||
Action::Eval => encode_eval(params, request_url, request_id),
|
||||
_ => Err(PipeError::Protocol(format!(
|
||||
"unsupported browser ws action: {}",
|
||||
action.as_str()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_callback_frame(frame: &str) -> Result<DecodedCallback, PipeError> {
|
||||
let payload: Value = serde_json::from_str(frame)?;
|
||||
let array = payload.as_array().ok_or_else(|| {
|
||||
PipeError::Protocol("callback frame must be a JSON array".to_string())
|
||||
})?;
|
||||
if array.len() != 3 {
|
||||
return Err(PipeError::Protocol(
|
||||
"callback frame must contain [requesturl, function, payload]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let function_name = array[1].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("callback frame function name must be a string".to_string())
|
||||
})?;
|
||||
if function_name != "callBackJsToCpp" {
|
||||
return Err(PipeError::Protocol(
|
||||
"callback frame must target callBackJsToCpp".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let param = array[2].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("callback payload must be a string".to_string())
|
||||
})?;
|
||||
let mut parts = param.splitn(5, CALLBACK_DELIMITER);
|
||||
let source_url = parts.next().unwrap_or_default();
|
||||
let target_url = parts.next().unwrap_or_default();
|
||||
let callback_name = parts.next().unwrap_or_default();
|
||||
let action_url = parts.next().unwrap_or_default();
|
||||
let response_text = parts.next().unwrap_or_default();
|
||||
|
||||
if source_url.is_empty()
|
||||
|| target_url.is_empty()
|
||||
|| callback_name.is_empty()
|
||||
|| action_url.is_empty()
|
||||
|| response_text.is_empty() && !param.ends_with(CALLBACK_DELIMITER)
|
||||
{
|
||||
return Err(PipeError::Protocol(
|
||||
"malformed callback payload".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(DecodedCallback {
|
||||
source_url: source_url.to_string(),
|
||||
target_url: target_url.to_string(),
|
||||
callback_name: callback_name.to_string(),
|
||||
action_url: action_url.to_string(),
|
||||
response_text: response_text.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_navigate(
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
let url = required_string(params, "url")?;
|
||||
let callback = callback_metadata(
|
||||
request_id,
|
||||
request_url,
|
||||
&url,
|
||||
"sgHideBrowserCallAfterLoaded",
|
||||
)?;
|
||||
let callback_call = format!(
|
||||
"callBackJsToCpp(\"{request_url}@_@{url}@_@{callback_name}@_@sgHideBrowserCallAfterLoaded@_@\")",
|
||||
callback_name = callback.callback_name,
|
||||
);
|
||||
Ok(EncodedWsRequest {
|
||||
payload: serde_json::to_string(&json!([
|
||||
request_url,
|
||||
"sgHideBrowserCallAfterLoaded",
|
||||
url,
|
||||
callback_call,
|
||||
]))?,
|
||||
callback: Some(callback),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_click(params: &Value, request_url: &str) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let script = format!(
|
||||
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}el.click();}})();"
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, None)
|
||||
}
|
||||
|
||||
fn encode_type(params: &Value, request_url: &str) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let text = required_string(params, "text")?;
|
||||
let script = format!(
|
||||
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}el.value={text:?};el.dispatchEvent(new Event(\"input\",{{bubbles:true}}));el.dispatchEvent(new Event(\"change\",{{bubbles:true}}));}})();"
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, None)
|
||||
}
|
||||
|
||||
fn encode_get_text(
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let selector = required_string(params, "selector")?;
|
||||
let callback = callback_metadata(
|
||||
request_id,
|
||||
request_url,
|
||||
&target_url,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
)?;
|
||||
let script = format!(
|
||||
"(function(){{const el=document.querySelector({selector:?});if(!el){{throw new Error(\"selector not found: {selector}\");}}const text=el.innerText ?? el.textContent ?? \"\";callBackJsToCpp(\"{request_url}@_@{target_url}@_@{callback_name}@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text));}})();",
|
||||
callback_name = callback.callback_name
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, Some(callback))
|
||||
}
|
||||
|
||||
fn encode_eval(
|
||||
params: &Value,
|
||||
request_url: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
let target_url = target_url(params, request_url)?;
|
||||
let source_script = required_string(params, "script")?;
|
||||
let callback = callback_metadata(
|
||||
request_id,
|
||||
request_url,
|
||||
&target_url,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
)?;
|
||||
let script = format!(
|
||||
"(function(){{const result=(function(){{{source_script}}})();callBackJsToCpp(\"{request_url}@_@{target_url}@_@{callback_name}@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result));}})();",
|
||||
callback_name = callback.callback_name
|
||||
);
|
||||
encode_js_in_area(request_url, &target_url, &script, Some(callback))
|
||||
}
|
||||
|
||||
fn encode_js_in_area(
|
||||
request_url: &str,
|
||||
target_url: &str,
|
||||
script: &str,
|
||||
callback: Option<CallbackCorrelation>,
|
||||
) -> Result<EncodedWsRequest, PipeError> {
|
||||
Ok(EncodedWsRequest {
|
||||
payload: serde_json::to_string(&json!([
|
||||
request_url,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
target_url,
|
||||
script,
|
||||
JS_AREA_HIDE,
|
||||
]))?,
|
||||
callback,
|
||||
})
|
||||
}
|
||||
|
||||
fn callback_metadata(
|
||||
request_id: Option<&str>,
|
||||
request_url: &str,
|
||||
target_url: &str,
|
||||
action_url: &str,
|
||||
) -> Result<CallbackCorrelation, PipeError> {
|
||||
let request_id = request_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol("request_id is required".to_string()))?;
|
||||
Ok(CallbackCorrelation {
|
||||
request_id: request_id.to_string(),
|
||||
callback_name: format!("{CALLBACK_PREFIX}{request_id}"),
|
||||
source_url: request_url.to_string(),
|
||||
target_url: target_url.to_string(),
|
||||
action_url: action_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn target_url(params: &Value, request_url: &str) -> Result<String, PipeError> {
|
||||
Ok(optional_string(params, "target_url")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| request_url.to_string()))
|
||||
}
|
||||
|
||||
fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
|
||||
optional_string(params, key)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn optional_string(params: &Value, key: &str) -> Option<String> {
|
||||
params.get(key)?.as_str().map(ToString::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_callback_frame, encode_v1_action};
|
||||
use crate::pipe::Action;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
fn get_text_callback_uses_documented_browser_opcode() {
|
||||
let request = encode_v1_action(
|
||||
&Action::GetText,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#content"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req42"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(payload[4], json!("hide"));
|
||||
assert_eq!(
|
||||
request.callback.unwrap().action_url,
|
||||
"sgBrowserExcuteJsCodeByArea"
|
||||
);
|
||||
assert!(payload[3].as_str().unwrap().contains(
|
||||
"callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_callback_uses_documented_browser_opcode() {
|
||||
let request = encode_v1_action(
|
||||
&Action::Eval,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req-eval"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(
|
||||
request.callback.unwrap().action_url,
|
||||
"sgBrowserExcuteJsCodeByArea"
|
||||
);
|
||||
assert!(payload[3].as_str().unwrap().contains(
|
||||
"callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req-eval@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_documented_callback_payload() {
|
||||
let callback = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
assert_eq!(callback.response_text, "天气");
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, CommandOutput};
|
||||
|
||||
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||||
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||||
const DISABLE_POST_EXPORT_OPEN_ENV: &str = "SGCLAW_DISABLE_POST_EXPORT_OPEN";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PostExportOpen {
|
||||
Opened,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
|
||||
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
|
||||
}
|
||||
|
||||
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
|
||||
where
|
||||
F: FnOnce(&Path) -> Result<(), String>,
|
||||
{
|
||||
if !output_path.exists() {
|
||||
return PostExportOpen::Failed(format!(
|
||||
"导出的 Excel 文件不存在:{}",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match opener(output_path) {
|
||||
Ok(()) => PostExportOpen::Opened,
|
||||
Err(reason) => PostExportOpen::Failed(reason),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_local_dashboard(
|
||||
browser_backend: &dyn BrowserBackend,
|
||||
output_path: &Path,
|
||||
presentation_url: &str,
|
||||
) -> PostExportOpen {
|
||||
if !output_path.exists() {
|
||||
return PostExportOpen::Failed(format!(
|
||||
"生成的大屏文件不存在:{}",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
if presentation_url.trim().is_empty() {
|
||||
return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string());
|
||||
}
|
||||
|
||||
let params = json!({
|
||||
"url": presentation_url,
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": LOCAL_DASHBOARD_SOURCE,
|
||||
"kind": LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN,
|
||||
"output_path": output_path.to_string_lossy(),
|
||||
"presentation_url": presentation_url,
|
||||
}
|
||||
});
|
||||
|
||||
match browser_backend.invoke(Action::Navigate, params, LOCAL_DASHBOARD_EXPECTED_DOMAIN) {
|
||||
Ok(output) if output.success => PostExportOpen::Opened,
|
||||
Ok(output) => PostExportOpen::Failed(command_output_reason(&output)),
|
||||
Err(err) => PostExportOpen::Failed(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "start", "", &output_path.display().to_string()])
|
||||
.output()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
Err(format!(
|
||||
"启动 Excel 默认程序失败:exit status {}",
|
||||
output.status
|
||||
))
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:{stderr}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = Command::new("open")
|
||||
.arg(output_path)
|
||||
.status()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = Command::new("xdg-open")
|
||||
.arg(output_path)
|
||||
.status()
|
||||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn command_output_reason(output: &CommandOutput) -> String {
|
||||
output
|
||||
.data
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| output.data.get("message").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| output.data.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::pipe::{ExecutionSurfaceMetadata, PipeError, Timing};
|
||||
|
||||
fn temp_file_path(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-artifact-open-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("temp root should exist");
|
||||
root.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
|
||||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||||
let seen = Mutex::new(None::<PathBuf>);
|
||||
|
||||
let result = open_exported_xlsx_with(&output_path, |path| {
|
||||
*seen.lock().unwrap() = Some(path.to_path_buf());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(seen.lock().unwrap().clone().unwrap(), output_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exported_xlsx_with_reports_launcher_failure() {
|
||||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||||
|
||||
let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeBrowserBackend {
|
||||
responses: Mutex<VecDeque<Result<CommandOutput, PipeError>>>,
|
||||
invocations: Mutex<Vec<(Action, Value, String)>>,
|
||||
}
|
||||
|
||||
impl FakeBrowserBackend {
|
||||
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
invocations: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for FakeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.invocations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((action, params, expected_domain.to_string()));
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_local_dashboard_uses_exact_approved_marker_payload() {
|
||||
let output_path = temp_file_path("zhihu-hotlist-screen.html");
|
||||
std::fs::write(&output_path, "<html></html>").expect("dashboard fixture should be writable");
|
||||
let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/"));
|
||||
let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
})]);
|
||||
|
||||
let result = open_local_dashboard(&backend, &output_path, &presentation_url);
|
||||
let invocations = backend.invocations.lock().unwrap().clone();
|
||||
|
||||
assert!(matches!(result, PostExportOpen::Opened));
|
||||
assert_eq!(invocations.len(), 1);
|
||||
assert_eq!(invocations[0].0, Action::Navigate);
|
||||
assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string());
|
||||
assert_eq!(invocations[0].1["url"], json!(presentation_url));
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["source"],
|
||||
json!(LOCAL_DASHBOARD_SOURCE)
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["kind"],
|
||||
json!(LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN)
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["output_path"],
|
||||
json!(output_path.to_string_lossy().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
invocations[0].1["sgclaw_local_dashboard_open"]["presentation_url"],
|
||||
invocations[0].1["url"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
@@ -9,65 +8,31 @@ use serde_json::{json, Value};
|
||||
use zeroclaw::skills::{Skill, SkillTool};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::Action;
|
||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
||||
|
||||
pub struct BrowserScriptInvocation<'a> {
|
||||
pub tool: &'a SkillTool,
|
||||
pub skill_root: &'a Path,
|
||||
}
|
||||
|
||||
pub struct BrowserScriptSkillTool {
|
||||
pub struct BrowserScriptSkillTool<T: Transport> {
|
||||
tool_name: String,
|
||||
tool_description: String,
|
||||
tool: SkillTool,
|
||||
skill_root: PathBuf,
|
||||
script_path: PathBuf,
|
||||
args: HashMap<String, String>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
}
|
||||
|
||||
impl BrowserScriptInvocation<'_> {
|
||||
fn script_path(&self) -> PathBuf {
|
||||
self.skill_root.join(&self.tool.command)
|
||||
}
|
||||
|
||||
fn canonical_script_path(&self) -> anyhow::Result<PathBuf> {
|
||||
let script_path = self.script_path();
|
||||
let canonical_skill_root = self
|
||||
.skill_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| self.skill_root.to_path_buf());
|
||||
let canonical_script_path = script_path.canonicalize().map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to resolve browser script {}: {err}",
|
||||
script_path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical_script_path.starts_with(&canonical_skill_root) {
|
||||
anyhow::bail!(
|
||||
"browser script path escapes skill root: {}",
|
||||
canonical_script_path.display()
|
||||
);
|
||||
}
|
||||
Ok(canonical_script_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserScriptSkillTool {
|
||||
impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
pub fn new(
|
||||
skill_name: &str,
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let invocation = BrowserScriptInvocation { tool, skill_root };
|
||||
invocation.canonical_script_path()?;
|
||||
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||
|
||||
Ok(Self {
|
||||
tool_name: format!("{}.{}", skill_name, tool.name),
|
||||
tool_description: tool.description.clone(),
|
||||
tool: tool.clone(),
|
||||
skill_root: skill_root.to_path_buf(),
|
||||
script_path,
|
||||
args: tool.args.clone(),
|
||||
browser_tool,
|
||||
})
|
||||
@@ -105,7 +70,7 @@ impl BrowserScriptSkillTool {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BrowserScriptSkillTool {
|
||||
impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
@@ -119,74 +84,35 @@ impl Tool for BrowserScriptSkillTool {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
execute_browser_script_impl(
|
||||
&self.tool,
|
||||
&self.skill_root,
|
||||
self.browser_tool.clone(),
|
||||
args,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_browser_script_skill_tools(
|
||||
skills: &[Skill],
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
|
||||
if !browser_tool.supports_eval() {
|
||||
return Ok(tools);
|
||||
}
|
||||
|
||||
for skill in skills {
|
||||
let Some(location) = skill.location.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(skill_root) = location.parent() else {
|
||||
continue;
|
||||
let tool = SkillTool {
|
||||
name: self.tool_name.clone(),
|
||||
description: self.tool_description.clone(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: self.script_path.to_string_lossy().into_owned(),
|
||||
args: self.args.clone(),
|
||||
};
|
||||
|
||||
for tool in &skill.tools {
|
||||
if tool.kind != "browser_script" {
|
||||
continue;
|
||||
}
|
||||
tools.push(Box::new(BrowserScriptSkillTool::new(
|
||||
&skill.name,
|
||||
tool,
|
||||
skill_root,
|
||||
browser_tool.clone(),
|
||||
)?));
|
||||
}
|
||||
execute_browser_script_tool(&tool, &self.skill_root, self.browser_tool.clone(), args).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_browser_script_tool<T: Transport + 'static>(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
if tool.kind != "browser_script" {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"browser script tool kind must be browser_script, got {}",
|
||||
tool.kind
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
pub async fn execute_browser_script_tool(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
execute_browser_script_impl(tool, skill_root, browser_tool, args)
|
||||
}
|
||||
|
||||
fn execute_browser_script_impl(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let invocation = BrowserScriptInvocation { tool, skill_root };
|
||||
let script_path = invocation.canonical_script_path()?;
|
||||
|
||||
let script_path = resolve_browser_script_path(skill_root, &tool.command)?;
|
||||
let mut args = match args {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
return Ok(failed_tool_result(format!(
|
||||
"expected object arguments, got {other}"
|
||||
)))
|
||||
}
|
||||
other => return Ok(failed_tool_result(format!("expected object arguments, got {other}"))),
|
||||
};
|
||||
|
||||
let raw_expected_domain = match args.remove("expected_domain") {
|
||||
@@ -255,6 +181,36 @@ fn execute_browser_script_impl(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
||||
skills: &[Skill],
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
|
||||
for skill in skills {
|
||||
let Some(location) = skill.location.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(skill_root) = location.parent() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for tool in &skill.tools {
|
||||
if tool.kind != "browser_script" {
|
||||
continue;
|
||||
}
|
||||
tools.push(Box::new(BrowserScriptSkillTool::new(
|
||||
&skill.name,
|
||||
tool,
|
||||
skill_root,
|
||||
browser_tool.clone(),
|
||||
)?));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
fn wrap_browser_script(script_body: &str, args: &Value) -> String {
|
||||
format!(
|
||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||
@@ -263,6 +219,32 @@ fn wrap_browser_script(script_body: &str, args: &Value) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_browser_script_path(skill_root: &Path, command: &str) -> anyhow::Result<PathBuf> {
|
||||
let script_path = PathBuf::from(command);
|
||||
let script_path = if script_path.is_absolute() {
|
||||
script_path
|
||||
} else {
|
||||
skill_root.join(script_path)
|
||||
};
|
||||
let canonical_skill_root = skill_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| skill_root.to_path_buf());
|
||||
let canonical_script_path = script_path.canonicalize().map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to resolve browser script {}: {err}",
|
||||
script_path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical_script_path.starts_with(&canonical_skill_root) {
|
||||
anyhow::bail!(
|
||||
"browser script path escapes skill root: {}",
|
||||
canonical_script_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(canonical_script_path)
|
||||
}
|
||||
|
||||
fn stringify_tool_payload(payload: &Value) -> anyhow::Result<String> {
|
||||
Ok(match payload {
|
||||
Value::String(value) => value.clone(),
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Map, Value};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, ExecutionSurfaceMetadata};
|
||||
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
|
||||
|
||||
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
@@ -20,14 +17,14 @@ const MAX_DATA_ARRAY_ITEMS: usize = 12;
|
||||
const MAX_DATA_OBJECT_FIELDS: usize = 24;
|
||||
const MAX_DATA_RECURSION_DEPTH: usize = 4;
|
||||
|
||||
pub struct ZeroClawBrowserTool {
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
pub struct ZeroClawBrowserTool<T: Transport> {
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl ZeroClawBrowserTool {
|
||||
pub fn new(browser_tool: Arc<dyn BrowserBackend>) -> Self {
|
||||
impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
BROWSER_ACTION_TOOL_NAME,
|
||||
@@ -35,7 +32,7 @@ impl ZeroClawBrowserTool {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_superrpa(browser_tool: Arc<dyn BrowserBackend>) -> Self {
|
||||
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
SUPERRPA_BROWSER_TOOL_NAME,
|
||||
@@ -44,7 +41,7 @@ impl ZeroClawBrowserTool {
|
||||
}
|
||||
|
||||
fn named(
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
) -> Self {
|
||||
@@ -61,7 +58,7 @@ impl ZeroClawBrowserTool {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ZeroClawBrowserTool {
|
||||
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
fn name(&self) -> &str {
|
||||
self.tool_name
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::runtime::RuntimeProfile;
|
||||
|
||||
const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
const STAGED_SKILLS_DIR_NAME: &str = "skill_staging";
|
||||
|
||||
pub fn build_zeroclaw_config(
|
||||
workspace_root: &Path,
|
||||
@@ -88,41 +87,15 @@ pub fn zeroclaw_default_skills_dir(workspace_root: &Path) -> PathBuf {
|
||||
zeroclaw_workspace_dir(workspace_root).join(SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> Vec<PathBuf> {
|
||||
resolve_skills_dir_paths(workspace_root, &settings.skills_dir)
|
||||
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> PathBuf {
|
||||
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
|
||||
}
|
||||
|
||||
pub fn resolve_skills_dir_from_sgclaw_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Vec<PathBuf> {
|
||||
resolve_skills_dir_paths(workspace_root, &settings.skills_dir)
|
||||
}
|
||||
|
||||
pub fn resolve_scene_skills_dir_from_sgclaw_settings(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Vec<PathBuf> {
|
||||
resolve_skills_dir_from_sgclaw_settings(workspace_root, settings)
|
||||
.into_iter()
|
||||
.flat_map(|dir| {
|
||||
let scene_dir = resolve_scene_skills_dir_path(dir.clone());
|
||||
if scene_dir != dir {
|
||||
vec![dir, scene_dir]
|
||||
} else {
|
||||
vec![dir]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn resolve_scene_skills_dir_path(skills_dir: PathBuf) -> PathBuf {
|
||||
let staged_skills_dir = skills_dir.join(STAGED_SKILLS_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
if staged_skills_dir.is_dir() {
|
||||
staged_skills_dir
|
||||
} else {
|
||||
skills_dir
|
||||
}
|
||||
) -> PathBuf {
|
||||
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
|
||||
}
|
||||
|
||||
fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
|
||||
@@ -138,13 +111,8 @@ fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_skills_dir_paths(workspace_root: &Path, configured_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
if configured_dirs.is_empty() {
|
||||
vec![zeroclaw_default_skills_dir(workspace_root)]
|
||||
} else {
|
||||
configured_dirs
|
||||
.iter()
|
||||
.map(|d| normalize_configured_skills_dir(d))
|
||||
.collect()
|
||||
}
|
||||
fn resolve_skills_dir_path(workspace_root: &Path, configured_dir: Option<&Path>) -> PathBuf {
|
||||
configured_dir
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
272
src/compat/deterministic_submit.rs
Normal file
272
src/compat/deterministic_submit.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::compat::direct_skill_runtime::DirectSubmitOutcome;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeterministicExecutionPlan {
|
||||
pub instruction: String,
|
||||
pub tool_name: String,
|
||||
pub expected_domain: String,
|
||||
pub org_label: String,
|
||||
pub org_code: String,
|
||||
pub period_mode: String,
|
||||
pub period_mode_code: String,
|
||||
pub period_value: String,
|
||||
pub period_payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DeterministicSubmitDecision {
|
||||
NotDeterministic,
|
||||
Prompt { summary: String },
|
||||
Execute(DeterministicExecutionPlan),
|
||||
}
|
||||
|
||||
const DETERMINISTIC_SUFFIX: &str = "。。。";
|
||||
const LINELLOSS_HOST: &str = "20.76.57.61";
|
||||
const LINELLOSS_TOOL: &str = "tq-lineloss-report.collect_lineloss";
|
||||
|
||||
pub fn decide_deterministic_submit(
|
||||
raw_instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> DeterministicSubmitDecision {
|
||||
let Some(instruction) = strip_exact_deterministic_suffix(raw_instruction) else {
|
||||
return DeterministicSubmitDecision::NotDeterministic;
|
||||
};
|
||||
|
||||
let normalized_instruction = instruction.trim();
|
||||
if normalized_instruction.is_empty() {
|
||||
return unsupported_scene_prompt();
|
||||
}
|
||||
|
||||
if !matches_lineloss_scene(normalized_instruction) {
|
||||
return unsupported_scene_prompt();
|
||||
}
|
||||
|
||||
let resolved_org = match crate::compat::tq_lineloss::org_resolver::resolve_org_from_instruction(
|
||||
normalized_instruction,
|
||||
) {
|
||||
Ok(Some(resolved_org)) => resolved_org,
|
||||
Ok(None) => {
|
||||
return DeterministicSubmitDecision::Prompt {
|
||||
summary: crate::compat::tq_lineloss::contracts::missing_company_prompt(),
|
||||
};
|
||||
}
|
||||
Err(summary) => {
|
||||
return DeterministicSubmitDecision::Prompt { summary };
|
||||
}
|
||||
};
|
||||
|
||||
let resolved_period = match crate::compat::tq_lineloss::period_resolver::resolve_period(
|
||||
normalized_instruction,
|
||||
) {
|
||||
Ok(resolved_period) => resolved_period,
|
||||
Err(summary) => {
|
||||
return DeterministicSubmitDecision::Prompt { summary };
|
||||
}
|
||||
};
|
||||
|
||||
if page_context_conflicts_with_lineloss(page_url, page_title) {
|
||||
return DeterministicSubmitDecision::Prompt {
|
||||
summary:
|
||||
"已命中台区线损报表技能,但当前页面与台区线损场景不匹配,请切换到线损页面后重试。"
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
DeterministicSubmitDecision::Execute(DeterministicExecutionPlan {
|
||||
instruction: normalized_instruction.to_string(),
|
||||
tool_name: LINELLOSS_TOOL.to_string(),
|
||||
expected_domain: LINELLOSS_HOST.to_string(),
|
||||
org_label: resolved_org.label,
|
||||
org_code: resolved_org.code,
|
||||
period_mode: period_mode_name(&resolved_period.mode).to_string(),
|
||||
period_mode_code: resolved_period.mode_code,
|
||||
period_value: resolved_period.value,
|
||||
period_payload: serde_json::to_string(&resolved_period.payload)
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute_deterministic_submit<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
plan: &DeterministicExecutionPlan,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||
let mut args = Map::new();
|
||||
args.insert(
|
||||
"expected_domain".to_string(),
|
||||
Value::String(plan.expected_domain.clone()),
|
||||
);
|
||||
args.insert(
|
||||
"org_label".to_string(),
|
||||
Value::String(plan.org_label.clone()),
|
||||
);
|
||||
args.insert(
|
||||
"org_code".to_string(),
|
||||
Value::String(plan.org_code.clone()),
|
||||
);
|
||||
args.insert(
|
||||
"period_mode".to_string(),
|
||||
Value::String(plan.period_mode.clone()),
|
||||
);
|
||||
args.insert(
|
||||
"period_mode_code".to_string(),
|
||||
Value::String(plan.period_mode_code.clone()),
|
||||
);
|
||||
args.insert(
|
||||
"period_value".to_string(),
|
||||
Value::String(plan.period_value.clone()),
|
||||
);
|
||||
args.insert(
|
||||
"period_payload".to_string(),
|
||||
Value::String(plan.period_payload.clone()),
|
||||
);
|
||||
|
||||
let output = crate::compat::direct_skill_runtime::execute_browser_script_skill_raw_output(
|
||||
browser_tool,
|
||||
&plan.tool_name,
|
||||
workspace_root,
|
||||
settings,
|
||||
args,
|
||||
)?;
|
||||
|
||||
Ok(summarize_lineloss_output(&output))
|
||||
}
|
||||
|
||||
fn summarize_lineloss_output(output: &str) -> DirectSubmitOutcome {
|
||||
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
|
||||
return DirectSubmitOutcome {
|
||||
success: true,
|
||||
summary: output.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
let artifact = payload
|
||||
.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.unwrap_or(&payload);
|
||||
|
||||
summarize_lineloss_artifact(artifact)
|
||||
}
|
||||
|
||||
fn summarize_lineloss_artifact(artifact: &Value) -> DirectSubmitOutcome {
|
||||
let Some(artifact) = artifact.as_object() else {
|
||||
return DirectSubmitOutcome {
|
||||
success: true,
|
||||
summary: artifact.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
|
||||
return DirectSubmitOutcome {
|
||||
success: true,
|
||||
summary: Value::Object(artifact.clone()).to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let status = artifact
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("ok");
|
||||
let success = matches!(status, "ok" | "partial" | "empty");
|
||||
let report_name = artifact
|
||||
.get("report_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tq-lineloss-report");
|
||||
let org_label = artifact
|
||||
.get("org")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|org| org.get("label"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let period_value = artifact
|
||||
.get("period")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|period| period.get("value"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let rows = artifact
|
||||
.get("counts")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|counts| counts.get("rows"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.or_else(|| artifact.get("rows").and_then(Value::as_array).map(Vec::len))
|
||||
.unwrap_or(0);
|
||||
let reasons = artifact
|
||||
.get("reasons")
|
||||
.and_then(Value::as_array)
|
||||
.map(|reasons| {
|
||||
reasons
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut parts = vec![report_name.to_string()];
|
||||
if !org_label.is_empty() {
|
||||
parts.push(org_label.to_string());
|
||||
}
|
||||
if !period_value.is_empty() {
|
||||
parts.push(period_value.to_string());
|
||||
}
|
||||
parts.push(format!("status={status}"));
|
||||
parts.push(format!("rows={rows}"));
|
||||
if !reasons.is_empty() {
|
||||
parts.push(format!("reasons={}", reasons.join(",")));
|
||||
}
|
||||
|
||||
DirectSubmitOutcome {
|
||||
success,
|
||||
summary: parts.join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_exact_deterministic_suffix(raw_instruction: &str) -> Option<&str> {
|
||||
let without_suffix = raw_instruction.strip_suffix(DETERMINISTIC_SUFFIX)?;
|
||||
if without_suffix.ends_with('。') {
|
||||
return None;
|
||||
}
|
||||
Some(without_suffix)
|
||||
}
|
||||
|
||||
fn matches_lineloss_scene(instruction: &str) -> bool {
|
||||
instruction.contains("线损") || instruction.contains("月累计") || instruction.contains("周累计")
|
||||
}
|
||||
|
||||
fn page_context_conflicts_with_lineloss(page_url: Option<&str>, page_title: Option<&str>) -> bool {
|
||||
let url = page_url.unwrap_or_default().to_ascii_lowercase();
|
||||
let title = page_title.unwrap_or_default();
|
||||
let has_context = !url.is_empty() || !title.is_empty();
|
||||
if !has_context {
|
||||
return false;
|
||||
}
|
||||
|
||||
let url_matches = url.contains(LINELLOSS_HOST) || url.contains("lineloss");
|
||||
let title_matches = title.contains("线损");
|
||||
|
||||
!(url_matches || title_matches)
|
||||
}
|
||||
|
||||
fn period_mode_name(mode: &crate::compat::tq_lineloss::contracts::PeriodMode) -> &'static str {
|
||||
match mode {
|
||||
crate::compat::tq_lineloss::contracts::PeriodMode::Month => "month",
|
||||
crate::compat::tq_lineloss::contracts::PeriodMode::Week => "week",
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_scene_prompt() -> DeterministicSubmitDecision {
|
||||
DeterministicSubmitDecision::Prompt {
|
||||
summary: "确定性提交当前只支持台区线损月/周累计线损率报表场景,请补充台区线损请求。"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
371
src/compat/direct_skill_runtime.rs
Normal file
371
src/compat/direct_skill_runtime.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use std::path::Path;
|
||||
|
||||
use reqwest::Url;
|
||||
use serde_json::{Map, Value};
|
||||
use zeroclaw::skills::{load_skills_from_directory, SkillTool};
|
||||
|
||||
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirectSubmitOutcome {
|
||||
pub success: bool,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
pub fn execute_direct_submit_skill<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<DirectSubmitOutcome, PipeError> {
|
||||
let configured_tool = settings
|
||||
.direct_submit_skill
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| PipeError::Protocol("direct submit skill is not configured".to_string()))?;
|
||||
let expected_domain = derive_expected_domain(task_context)?;
|
||||
let period = derive_period(instruction)?;
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert("expected_domain".to_string(), Value::String(expected_domain));
|
||||
args.insert("period".to_string(), Value::String(period));
|
||||
|
||||
let output = execute_browser_script_skill_raw_output(
|
||||
browser_tool,
|
||||
configured_tool,
|
||||
workspace_root,
|
||||
settings,
|
||||
args,
|
||||
)?;
|
||||
|
||||
Ok(interpret_direct_submit_output(&output))
|
||||
}
|
||||
|
||||
pub fn execute_browser_script_skill_raw_output<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
configured_tool: &str,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<String, PipeError> {
|
||||
let (skill_name, tool_name) = parse_configured_tool_name(configured_tool)?;
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||
let skills = load_skills_from_directory(&skills_dir, true);
|
||||
let skill = skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == skill_name)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"direct submit skill {skill_name} was not found in {}",
|
||||
skills_dir.display()
|
||||
))
|
||||
})?;
|
||||
let tool = skill
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == tool_name)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"direct submit tool {configured_tool} was not found"
|
||||
))
|
||||
})?;
|
||||
|
||||
let skill_root = skill
|
||||
.location
|
||||
.as_deref()
|
||||
.and_then(Path::parent)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"direct submit skill {skill_name} is missing a resolvable location"
|
||||
))
|
||||
})?;
|
||||
|
||||
execute_browser_script_tool_output(browser_tool, configured_tool, tool, skill_root, args)
|
||||
}
|
||||
|
||||
fn execute_browser_script_tool_output<T: Transport + 'static>(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
configured_tool: &str,
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<String, PipeError> {
|
||||
if tool.kind != "browser_script" {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"direct submit tool {configured_tool} must be browser_script, got {}",
|
||||
tool.kind
|
||||
)));
|
||||
}
|
||||
|
||||
let mut tool = tool.clone();
|
||||
tool.args.remove("expected_domain");
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
let result = runtime
|
||||
.block_on(execute_browser_script_tool(
|
||||
&tool,
|
||||
skill_root,
|
||||
browser_tool,
|
||||
Value::Object(args),
|
||||
))
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
|
||||
if result.success {
|
||||
Ok(result.output)
|
||||
} else {
|
||||
Err(PipeError::Protocol(
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "direct submit skill execution failed".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret_direct_submit_output(output: &str) -> DirectSubmitOutcome {
|
||||
let Some(payload) = serde_json::from_str::<Value>(output).ok() else {
|
||||
return DirectSubmitOutcome {
|
||||
success: true,
|
||||
summary: output.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
let Some(artifact) = payload.as_object() else {
|
||||
return DirectSubmitOutcome {
|
||||
success: true,
|
||||
summary: output.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
if artifact.get("type").and_then(Value::as_str) != Some("report-artifact") {
|
||||
return DirectSubmitOutcome {
|
||||
success: true,
|
||||
summary: output.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let status = artifact
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("ok");
|
||||
let success = matches!(status, "ok" | "partial" | "empty");
|
||||
let report_name = artifact
|
||||
.get("report_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("report-artifact");
|
||||
let period = artifact
|
||||
.get("period")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let detail_rows = count_rows(artifact.get("counts"), artifact.get("rows"), "detail_rows");
|
||||
let summary_rows = count_summary_rows(artifact.get("counts"), artifact.get("sections"));
|
||||
let partial_reasons = artifact
|
||||
.get("partial_reasons")
|
||||
.and_then(Value::as_array)
|
||||
.map(|reasons| {
|
||||
reasons
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut parts = vec![report_name.to_string()];
|
||||
if !period.trim().is_empty() {
|
||||
parts.push(period.to_string());
|
||||
}
|
||||
parts.push(format!("status={status}"));
|
||||
parts.push(format!("detail_rows={detail_rows}"));
|
||||
parts.push(format!("summary_rows={summary_rows}"));
|
||||
if !partial_reasons.is_empty() {
|
||||
parts.push(format!("partial_reasons={}", partial_reasons.join(",")));
|
||||
}
|
||||
|
||||
DirectSubmitOutcome {
|
||||
success,
|
||||
summary: parts.join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
fn count_rows(counts: Option<&Value>, rows: Option<&Value>, key: &str) -> usize {
|
||||
counts
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|counts| counts.get(key))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|count| count as usize)
|
||||
.or_else(|| rows.and_then(Value::as_array).map(Vec::len))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn count_summary_rows(counts: Option<&Value>, sections: Option<&Value>) -> usize {
|
||||
counts
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|counts| counts.get("summary_rows"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|count| count as usize)
|
||||
.or_else(|| {
|
||||
sections
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|sections| {
|
||||
sections.iter().find_map(|section| {
|
||||
section
|
||||
.as_object()
|
||||
.and_then(|section| section.get("rows"))
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn parse_configured_tool_name(configured_tool: &str) -> Result<(&str, &str), PipeError> {
|
||||
let (skill_name, tool_name) = configured_tool.split_once('.').ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"direct submit skill must use skill.tool format, got {configured_tool}"
|
||||
))
|
||||
})?;
|
||||
let skill_name = skill_name.trim();
|
||||
let tool_name = tool_name.trim();
|
||||
if skill_name.is_empty() || tool_name.is_empty() {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"direct submit skill must use skill.tool format, got {configured_tool}"
|
||||
)));
|
||||
}
|
||||
Ok((skill_name, tool_name))
|
||||
}
|
||||
|
||||
fn derive_expected_domain(task_context: &CompatTaskContext) -> Result<String, PipeError> {
|
||||
let page_url = task_context
|
||||
.page_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(
|
||||
"direct submit skill requires page_url so expected_domain can be derived"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Url::parse(page_url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"direct submit skill could not derive expected_domain from page_url {page_url:?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_period(instruction: &str) -> Result<String, PipeError> {
|
||||
let chars = instruction.chars().collect::<Vec<_>>();
|
||||
if chars.len() < 7 {
|
||||
return Err(PipeError::Protocol(
|
||||
"direct submit skill requires an explicit YYYY-MM period in the instruction"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for start in 0..=chars.len() - 7 {
|
||||
let candidate = chars[start..start + 7].iter().collect::<String>();
|
||||
if is_year_month(&candidate) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Err(PipeError::Protocol(
|
||||
"direct submit skill requires an explicit YYYY-MM period in the instruction"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn is_year_month(candidate: &str) -> bool {
|
||||
let bytes = candidate.as_bytes();
|
||||
bytes.len() == 7
|
||||
&& bytes[0..4].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[5..7].iter().all(u8::is_ascii_digit)
|
||||
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
count_rows, count_summary_rows, derive_period, interpret_direct_submit_output,
|
||||
is_year_month, parse_configured_tool_name,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_configured_tool_name_requires_skill_and_tool() {
|
||||
assert_eq!(
|
||||
parse_configured_tool_name("fault-details-report.collect_fault_details")
|
||||
.unwrap(),
|
||||
("fault-details-report", "collect_fault_details")
|
||||
);
|
||||
assert!(parse_configured_tool_name("fault-details-report").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_period_requires_explicit_year_month() {
|
||||
assert_eq!(derive_period("收集 2026-03 故障明细").unwrap(), "2026-03");
|
||||
assert!(derive_period("收集三月故障明细").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_month_validation_rejects_invalid_month() {
|
||||
assert!(is_year_month("2026-12"));
|
||||
assert!(!is_year_month("2026-00"));
|
||||
assert!(!is_year_month("2026-13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_direct_submit_output_maps_report_artifact_statuses() {
|
||||
let partial = interpret_direct_submit_output(
|
||||
&json!({
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"period": "2026-03",
|
||||
"counts": { "detail_rows": 1, "summary_rows": 1 },
|
||||
"status": "partial",
|
||||
"partial_reasons": ["report_log_failed"]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
assert!(partial.success);
|
||||
assert!(partial.summary.contains("status=partial"));
|
||||
assert!(partial.summary.contains("report_log_failed"));
|
||||
|
||||
let blocked = interpret_direct_submit_output(
|
||||
&json!({
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"status": "blocked",
|
||||
"partial_reasons": ["selected_range_unavailable"]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
assert!(!blocked.success);
|
||||
assert!(blocked.summary.contains("status=blocked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_count_helpers_fall_back_to_payload_shapes() {
|
||||
assert_eq!(
|
||||
count_rows(None, Some(&json!([{ "qxdbh": "QX-1" }, { "qxdbh": "QX-2" }])), "detail_rows"),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
count_summary_rows(None, Some(&json!([{ "name": "summary-sheet", "rows": [{ "index": 1 }] }]))),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
pub mod artifact_open;
|
||||
pub mod browser_script_skill_tool;
|
||||
pub mod browser_tool_adapter;
|
||||
pub mod config_adapter;
|
||||
pub mod cron_adapter;
|
||||
pub mod deterministic_submit;
|
||||
pub mod direct_skill_runtime;
|
||||
pub mod event_bridge;
|
||||
pub mod memory_adapter;
|
||||
pub mod openxml_office_tool;
|
||||
pub mod orchestration;
|
||||
pub mod runtime;
|
||||
pub mod screen_html_export_tool;
|
||||
pub mod tq_lineloss;
|
||||
pub mod workflow_executor;
|
||||
|
||||
@@ -4,12 +4,12 @@ use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::write::FileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||
@@ -131,9 +131,7 @@ impl Tool for OpenXmlOfficeTool {
|
||||
write_payload_json(&payload_path, &normalized_rows)?;
|
||||
write_request_json(&request_path, &template_path, &payload_path, &output_path)?;
|
||||
|
||||
let rendered = run_openxml_cli(&request_path).or_else(|_| {
|
||||
render_locally(&template_path, &payload_path, &output_path)
|
||||
})?;
|
||||
let rendered = run_openxml_cli(&request_path)?;
|
||||
let artifact_path = rendered["data"]["artifact"]["path"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
@@ -285,18 +283,8 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
|
||||
.parent()
|
||||
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
|
||||
let binary_name = if cfg!(windows) {
|
||||
"openxml-cli.exe"
|
||||
} else {
|
||||
"openxml-cli"
|
||||
};
|
||||
let binary_path = manifest_path
|
||||
.parent()
|
||||
.map(|path| path.join("target").join("debug").join(binary_name))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli binary path"))?;
|
||||
|
||||
let output = if binary_path.exists() {
|
||||
Command::new(&binary_path)
|
||||
let output = if let Some(binary_path) = resolve_openxml_cli_binary(&manifest_path) {
|
||||
Command::new(binary_path)
|
||||
.args([
|
||||
"template",
|
||||
"render",
|
||||
@@ -335,85 +323,32 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
|
||||
Ok(serde_json::from_str(&stdout)?)
|
||||
}
|
||||
|
||||
fn render_locally(template_path: &Path, payload_path: &Path, output_path: &Path) -> anyhow::Result<Value> {
|
||||
let payload: Value = serde_json::from_slice(&fs::read(payload_path)?)?;
|
||||
let variables = payload["variables"]
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow::anyhow!("payload.variables must be an object"))?;
|
||||
|
||||
let worksheet = render_template_xml(&worksheet_xml_from_xlsx(template_path)?, variables);
|
||||
write_rendered_xlsx(template_path, output_path, "xl/worksheets/sheet1.xml", &worksheet)?;
|
||||
|
||||
Ok(json!({
|
||||
"data": {
|
||||
"artifact": {
|
||||
"path": output_path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
}))
|
||||
fn resolve_openxml_cli_binary(manifest_path: &Path) -> Option<PathBuf> {
|
||||
let cli_dir = manifest_path.parent()?;
|
||||
openxml_cli_candidate_paths(cli_dir)
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn worksheet_xml_from_xlsx(path: &Path) -> anyhow::Result<String> {
|
||||
let file = fs::File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
let mut sheet = archive.by_name("xl/worksheets/sheet1.xml")?;
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut sheet, &mut xml)?;
|
||||
Ok(xml)
|
||||
fn openxml_cli_candidate_paths(cli_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
for profile in ["release", "debug"] {
|
||||
paths.push(
|
||||
cli_dir
|
||||
.join("target")
|
||||
.join(profile)
|
||||
.join(openxml_cli_binary_name()),
|
||||
);
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn render_template_xml(
|
||||
template: &str,
|
||||
variables: &serde_json::Map<String, Value>,
|
||||
) -> String {
|
||||
let mut rendered = template.to_string();
|
||||
for (key, value) in variables {
|
||||
let placeholder = format!("{{{{{key}}}}}");
|
||||
let replacement = value.as_str().unwrap_or_default();
|
||||
rendered = rendered.replace(&placeholder, &xml_escape(replacement));
|
||||
fn openxml_cli_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"openxml-cli.exe"
|
||||
} else {
|
||||
"openxml-cli"
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn write_rendered_xlsx(
|
||||
template_path: &Path,
|
||||
output_path: &Path,
|
||||
replaced_entry: &str,
|
||||
replaced_body: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if output_path.exists() {
|
||||
fs::remove_file(output_path)?;
|
||||
}
|
||||
|
||||
let input = fs::File::open(template_path)?;
|
||||
let mut archive = zip::ZipArchive::new(input)?;
|
||||
let output = fs::File::create(output_path)?;
|
||||
let mut writer = ZipWriter::new(output);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index)?;
|
||||
let name = entry.name().to_string();
|
||||
writer.start_file(name.as_str(), options)?;
|
||||
if name == replaced_entry {
|
||||
writer.write_all(replaced_body.as_bytes())?;
|
||||
} else {
|
||||
std::io::copy(&mut entry, &mut writer)?;
|
||||
}
|
||||
}
|
||||
|
||||
writer.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
@@ -427,61 +362,108 @@ fn value_to_string(value: &Value) -> String {
|
||||
}
|
||||
|
||||
fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
|
||||
write_zip_file(&path, &[Content {
|
||||
path: "[Content_Types].xml",
|
||||
body: content_types_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "_rels/.rels",
|
||||
body: root_rels_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "docProps/app.xml",
|
||||
body: app_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "docProps/core.xml",
|
||||
body: core_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "xl/workbook.xml",
|
||||
body: workbook_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "xl/_rels/workbook.xml.rels",
|
||||
body: workbook_rels_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "xl/worksheets/sheet1.xml",
|
||||
body: worksheet_xml(row_count),
|
||||
}])?;
|
||||
Ok(())
|
||||
}
|
||||
let build_root = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("template path has no parent"))?
|
||||
.join("template-build");
|
||||
fs::create_dir_all(build_root.join("_rels"))?;
|
||||
fs::create_dir_all(build_root.join("docProps"))?;
|
||||
fs::create_dir_all(build_root.join("xl/_rels"))?;
|
||||
fs::create_dir_all(build_root.join("xl/worksheets"))?;
|
||||
|
||||
struct Content<'a> {
|
||||
path: &'a str,
|
||||
body: String,
|
||||
}
|
||||
fs::write(build_root.join("[Content_Types].xml"), content_types_xml())?;
|
||||
fs::write(build_root.join("_rels/.rels"), root_rels_xml())?;
|
||||
fs::write(build_root.join("docProps/app.xml"), app_xml())?;
|
||||
fs::write(build_root.join("docProps/core.xml"), core_xml())?;
|
||||
fs::write(build_root.join("xl/workbook.xml"), workbook_xml())?;
|
||||
fs::write(
|
||||
build_root.join("xl/_rels/workbook.xml.rels"),
|
||||
workbook_rels_xml(),
|
||||
)?;
|
||||
fs::write(
|
||||
build_root.join("xl/worksheets/sheet1.xml"),
|
||||
worksheet_xml(row_count),
|
||||
)?;
|
||||
|
||||
fn write_zip_file(path: &Path, entries: &[Content<'_>]) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
let file = fs::File::create(path)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
for entry in entries {
|
||||
zip.start_file(entry.path, options)?;
|
||||
zip.write_all(entry.body.as_bytes())?;
|
||||
}
|
||||
zip.finish()?;
|
||||
zip_directory(&build_root, path)?;
|
||||
|
||||
let _ = fs::remove_dir_all(&build_root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{openxml_cli_binary_name, openxml_cli_candidate_paths, zip_entry_name};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn openxml_cli_candidates_prefer_release_before_debug() {
|
||||
let paths = openxml_cli_candidate_paths(Path::new("E:\\coding\\codex\\openxml_cli"));
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
Path::new("E:\\coding\\codex\\openxml_cli")
|
||||
.join("target")
|
||||
.join("release")
|
||||
.join(openxml_cli_binary_name())
|
||||
);
|
||||
assert_eq!(
|
||||
paths[1],
|
||||
Path::new("E:\\coding\\codex\\openxml_cli")
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join(openxml_cli_binary_name())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_entry_name_normalizes_windows_separators() {
|
||||
let rel = Path::new("xl\\worksheets\\sheet1.xml");
|
||||
assert_eq!(zip_entry_name(rel), "xl/worksheets/sheet1.xml");
|
||||
}
|
||||
}
|
||||
|
||||
fn zip_directory(source_root: &Path, zip_path: &Path) -> anyhow::Result<()> {
|
||||
let file = fs::File::create(zip_path)?;
|
||||
let mut writer = ZipWriter::new(file);
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
add_directory_to_zip(&mut writer, source_root, source_root, options)?;
|
||||
writer.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_directory_to_zip<W: Write + std::io::Seek>(
|
||||
writer: &mut ZipWriter<W>,
|
||||
source_root: &Path,
|
||||
current_dir: &Path,
|
||||
options: FileOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
add_directory_to_zip(writer, source_root, &path, options)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = path.strip_prefix(source_root)?;
|
||||
writer.start_file(zip_entry_name(relative_path), options)?;
|
||||
let mut input = fs::File::open(&path)?;
|
||||
let mut buffer = Vec::new();
|
||||
input.read_to_end(&mut buffer)?;
|
||||
writer.write_all(&buffer)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zip_entry_name(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn worksheet_xml(row_count: usize) -> String {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||
@@ -28,71 +27,6 @@ pub fn should_use_primary_orchestration(
|
||||
crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) && needs_export
|
||||
}
|
||||
|
||||
pub fn execute_task_with_browser_backend(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let route = crate::compat::workflow_executor::detect_route(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
);
|
||||
if let Some(route) = route.clone() {
|
||||
if crate::compat::workflow_executor::prefers_direct_execution(&route) {
|
||||
return crate::compat::workflow_executor::execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend.clone(),
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
let primary_result = crate::compat::runtime::execute_task_with_browser_backend(
|
||||
transport,
|
||||
browser_backend.clone(),
|
||||
instruction,
|
||||
task_context,
|
||||
workspace_root,
|
||||
settings,
|
||||
);
|
||||
|
||||
match (route, primary_result) {
|
||||
(Some(route), Ok(summary))
|
||||
if crate::compat::workflow_executor::should_fallback_after_summary(
|
||||
&summary, &route,
|
||||
) =>
|
||||
{
|
||||
crate::compat::workflow_executor::execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
settings,
|
||||
)
|
||||
}
|
||||
(_, Ok(summary)) => Ok(summary),
|
||||
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
settings,
|
||||
),
|
||||
(None, Err(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
@@ -101,6 +35,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||
let route = crate::compat::workflow_executor::detect_route(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
@@ -112,10 +47,10 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -138,10 +73,10 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
settings,
|
||||
)
|
||||
}
|
||||
(_, Ok(summary)) => Ok(summary),
|
||||
@@ -149,10 +84,10 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
settings,
|
||||
),
|
||||
(None, Err(err)) => Err(err),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{stream, StreamExt};
|
||||
@@ -9,14 +8,12 @@ use zeroclaw::config::Config as ZeroClawConfig;
|
||||
use zeroclaw::providers::traits::{ProviderCapabilities, StreamEvent, StreamOptions, StreamResult};
|
||||
use zeroclaw::providers::{self, ChatMessage, ChatRequest, ChatResponse, Provider};
|
||||
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::compat::browser_script_skill_tool::build_browser_script_skill_tools;
|
||||
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
|
||||
use crate::compat::config_adapter::{
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
|
||||
};
|
||||
use crate::compat::event_bridge::log_entry_for_turn_event;
|
||||
use crate::compat::workflow_executor::parse_generated_article_draft;
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
|
||||
@@ -50,32 +47,6 @@ pub fn execute_task<T: Transport + 'static>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_task_with_browser_backend(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings);
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||
let provider = build_provider(&config)?;
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
|
||||
runtime.block_on(execute_task_with_provider(
|
||||
transport,
|
||||
browser_backend,
|
||||
provider,
|
||||
instruction,
|
||||
task_context,
|
||||
config,
|
||||
skills_dir,
|
||||
settings.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
@@ -92,7 +63,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
|
||||
runtime.block_on(execute_task_with_provider(
|
||||
transport,
|
||||
Arc::new(PipeBrowserBackend::from_inner(browser_tool)),
|
||||
browser_tool,
|
||||
provider,
|
||||
instruction,
|
||||
task_context,
|
||||
@@ -102,55 +73,34 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn generate_zhihu_article_draft(
|
||||
instruction: &str,
|
||||
topic: &str,
|
||||
_task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<crate::compat::workflow_executor::ArticleDraft, PipeError> {
|
||||
let mut generation_settings = settings.clone();
|
||||
generation_settings.runtime_profile = crate::runtime::RuntimeProfile::GeneralAssistant;
|
||||
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, &generation_settings);
|
||||
let provider = build_provider(&config)?;
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
let generation_prompt = format!(
|
||||
"为知乎文章生成可直接发布的草稿。用户原始请求:{instruction}\n\n主题:{topic}\n\n请严格只输出以下格式,不要添加解释、前言、代码块或其他内容:\n标题:<简洁具体的中文标题>\n正文:<适合知乎发布的中文正文,使用自然段>"
|
||||
);
|
||||
|
||||
let generated = runtime.block_on(async move {
|
||||
provider
|
||||
.chat_with_system(
|
||||
Some("You write concise Chinese Zhihu article drafts. Return only the requested title/body format."),
|
||||
&generation_prompt,
|
||||
config.default_model.as_deref().unwrap_or("deepseek-chat"),
|
||||
config.default_temperature,
|
||||
)
|
||||
.await
|
||||
.map_err(map_anyhow_to_pipe_error)
|
||||
})?;
|
||||
|
||||
parse_generated_article_draft(&generated).ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"generated Zhihu article draft did not match 标题/正文 format: {generated}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute_task_with_provider(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
provider: Box<dyn Provider>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
config: ZeroClawConfig,
|
||||
skills_dir: Vec<PathBuf>,
|
||||
skills_dir: PathBuf,
|
||||
settings: SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let engine = RuntimeEngine::new(settings.runtime_profile);
|
||||
let browser_surface_present = engine.browser_surface_enabled();
|
||||
if let Some(preview) = crate::agent::planner::build_execution_preview(
|
||||
settings.planner_mode,
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
let mut message = preview.summary;
|
||||
if !preview.steps.is_empty() {
|
||||
message.push('\n');
|
||||
message.push_str(&preview.steps.join("\n"));
|
||||
}
|
||||
transport.send(&crate::pipe::AgentMessage::LogEntry {
|
||||
level: "plan".to_string(),
|
||||
message,
|
||||
})?;
|
||||
}
|
||||
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
||||
let loaded_skill_versions = loaded_skills
|
||||
.iter()
|
||||
@@ -166,13 +116,11 @@ pub async fn execute_task_with_provider(
|
||||
message: format!("loaded skills: {}", loaded_skill_labels.join(", ")),
|
||||
})?;
|
||||
}
|
||||
let browser_tool_for_scripts = browser_backend.clone();
|
||||
let browser_tool_for_superrpa = browser_backend.clone();
|
||||
let browser_tool_for_browser_action = browser_backend;
|
||||
let browser_tool_for_scripts = browser_tool.clone();
|
||||
let mut tools: Vec<Box<dyn zeroclaw::tools::Tool>> = if browser_surface_present {
|
||||
vec![
|
||||
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool_for_superrpa)),
|
||||
Box::new(ZeroClawBrowserTool::new(browser_tool_for_browser_action)),
|
||||
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())),
|
||||
Box::new(ZeroClawBrowserTool::new(browser_tool)),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -360,22 +308,3 @@ fn to_chat_message(message: &ConversationMessage) -> Option<ChatMessage> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_source_no_longer_references_legacy_planner_preview() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let source = fs::read_to_string(manifest_dir.join("src/compat/runtime.rs")).unwrap();
|
||||
let preview_prefix = ["if let Some(preview) = crate::agent::", "planner::build_execution_preview("].concat();
|
||||
let plan_level_expr = ["level: ", "\"plan\".to_string(),"].concat();
|
||||
|
||||
assert!(!source
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with(&preview_prefix)));
|
||||
assert!(!source.lines().any(|line| line.trim() == plan_level_expr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
|
||||
const DEFAULT_SCREEN_TITLE: &str = "知乎热榜主题分类分析大屏";
|
||||
const TEMPLATE: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../skill_lib/skills/zhihu-hotlist-screen/assets/zhihu-hotlist-echarts.html"
|
||||
"/resources/zhihu-hotlist-echarts.html"
|
||||
));
|
||||
const PAYLOAD_START_MARKER: &str = " const defaultPayload = ";
|
||||
const PAYLOAD_END_MARKER: &str = "\n\n const themeMeta = {";
|
||||
@@ -150,7 +150,11 @@ impl Tool for ScreenHtmlExportTool {
|
||||
};
|
||||
|
||||
let rendered = render_template(&payload)?;
|
||||
let output_path = resolve_output_path(&self.workspace_root, parsed.output_path.as_deref());
|
||||
let output_path = parsed
|
||||
.output_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| default_output_path(&self.workspace_root));
|
||||
write_output_html(&output_path, &rendered)?;
|
||||
|
||||
let presentation_url = file_url_for_path(&output_path);
|
||||
@@ -371,21 +375,6 @@ fn default_output_path(workspace_root: &Path) -> PathBuf {
|
||||
.join(format!("zhihu-hotlist-screen-{nanos}.html"))
|
||||
}
|
||||
|
||||
fn resolve_output_path(workspace_root: &Path, output_path: Option<&str>) -> PathBuf {
|
||||
output_path
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace_root.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| default_output_path(workspace_root))
|
||||
}
|
||||
|
||||
fn default_snapshot_id() -> String {
|
||||
format!("zhihu-hotlist-screen-{}", now_ms())
|
||||
}
|
||||
@@ -402,67 +391,3 @@ fn file_url_for_path(path: &Path) -> String {
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or_else(|_| format!("file://{}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::pipe::Action;
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-screen-html-{}", now_ms()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn screen_html_export_resolves_relative_output_path_to_absolute_file_url() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let tool = ScreenHtmlExportTool::new(workspace_root.clone());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"snapshot_id": "snapshot-relative-path",
|
||||
"generated_at_ms": 1774713600000u64,
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": "../out/zhihu-hotlist-screen-relative.html"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output).unwrap();
|
||||
let output_path = PathBuf::from(payload["output_path"].as_str().unwrap());
|
||||
let presentation_url = payload["presentation"]["url"].as_str().unwrap();
|
||||
let expected_output_path = workspace_root.join("../out/zhihu-hotlist-screen-relative.html");
|
||||
let expected_presentation_url = Url::from_file_path(&expected_output_path)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let policy = MacPolicy::load_from_path(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("rules.json"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output_path.is_absolute());
|
||||
assert_eq!(output_path, expected_output_path);
|
||||
assert!(output_path.exists());
|
||||
assert_eq!(presentation_url, expected_presentation_url);
|
||||
assert!(presentation_url.starts_with("file:///"));
|
||||
policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&Action::Navigate,
|
||||
"__sgclaw_local_dashboard__",
|
||||
presentation_url,
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
50
src/compat/tq_lineloss/contracts.rs
Normal file
50
src/compat/tq_lineloss/contracts.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOrg {
|
||||
pub label: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PeriodMode {
|
||||
Month,
|
||||
Week,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedPeriod {
|
||||
pub mode: PeriodMode,
|
||||
pub mode_code: String,
|
||||
pub value: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
pub fn missing_company_prompt() -> String {
|
||||
"已命中台区线损报表技能,但缺少供电单位,请补充如“兰州公司”或“城关供电分公司”。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn ambiguous_company_prompt() -> String {
|
||||
"已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。".to_string()
|
||||
}
|
||||
|
||||
pub fn missing_period_mode_prompt() -> String {
|
||||
"已命中台区线损报表技能,但未识别到月/周类型,请补充“月累计”或“周累计”。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn missing_period_prompt() -> String {
|
||||
"已命中台区线损报表技能,但缺少统计周期,请补充如“2026-03”或“2026年第12周”。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn contradictory_period_mode_prompt() -> String {
|
||||
"已命中台区线损报表技能,但月/周类型存在冲突,请只保留“月累计”或“周累计”之一。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn missing_week_year_prompt() -> String {
|
||||
"已命中台区线损报表技能,但周累计缺少年份,请补充如“2026年第12周”。"
|
||||
.to_string()
|
||||
}
|
||||
4
src/compat/tq_lineloss/mod.rs
Normal file
4
src/compat/tq_lineloss/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod contracts;
|
||||
pub mod org_resolver;
|
||||
pub mod org_units;
|
||||
pub mod period_resolver;
|
||||
71
src/compat/tq_lineloss/org_resolver.rs
Normal file
71
src/compat/tq_lineloss/org_resolver.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use super::contracts::{ambiguous_company_prompt, ResolvedOrg};
|
||||
use super::org_units::{OrgUnit, ORG_UNITS};
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value.chars().filter(|ch| !ch.is_whitespace()).collect()
|
||||
}
|
||||
|
||||
fn candidate_names(unit: &'static OrgUnit) -> impl Iterator<Item = &'static str> {
|
||||
std::iter::once(unit.label).chain(unit.aliases.iter().copied())
|
||||
}
|
||||
|
||||
fn to_resolved_org(unit: &OrgUnit) -> ResolvedOrg {
|
||||
ResolvedOrg {
|
||||
label: unit.label.to_string(),
|
||||
code: unit.code.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_org(input: &str) -> Result<ResolvedOrg, String> {
|
||||
let normalized = normalize(input);
|
||||
if normalized.is_empty() {
|
||||
return Err(super::contracts::missing_company_prompt());
|
||||
}
|
||||
|
||||
let exact_matches: Vec<&OrgUnit> = ORG_UNITS
|
||||
.iter()
|
||||
.filter(|unit| candidate_names(unit).any(|name| normalize(name) == normalized))
|
||||
.collect();
|
||||
if exact_matches.len() == 1 {
|
||||
return Ok(to_resolved_org(exact_matches[0]));
|
||||
}
|
||||
if exact_matches.len() > 1 {
|
||||
return Err(ambiguous_company_prompt());
|
||||
}
|
||||
|
||||
let fuzzy_matches: Vec<&OrgUnit> = ORG_UNITS
|
||||
.iter()
|
||||
.filter(|unit| {
|
||||
candidate_names(unit).any(|name| {
|
||||
let normalized_name = normalize(name);
|
||||
normalized_name.contains(&normalized) || normalized.contains(&normalized_name)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if fuzzy_matches.len() == 1 {
|
||||
return Ok(to_resolved_org(fuzzy_matches[0]));
|
||||
}
|
||||
if fuzzy_matches.len() > 1 {
|
||||
return Err(ambiguous_company_prompt());
|
||||
}
|
||||
|
||||
Err(super::contracts::missing_company_prompt())
|
||||
}
|
||||
|
||||
pub fn resolve_org_from_instruction(instruction: &str) -> Result<Option<ResolvedOrg>, String> {
|
||||
let normalized_instruction = normalize(instruction);
|
||||
let direct_matches: Vec<&OrgUnit> = ORG_UNITS
|
||||
.iter()
|
||||
.filter(|unit| {
|
||||
candidate_names(unit).any(|name| normalized_instruction.contains(&normalize(name)))
|
||||
})
|
||||
.collect();
|
||||
if direct_matches.len() == 1 {
|
||||
return Ok(Some(to_resolved_org(direct_matches[0])));
|
||||
}
|
||||
if direct_matches.len() > 1 {
|
||||
return Err(ambiguous_company_prompt());
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
33
src/compat/tq_lineloss/org_units.rs
Normal file
33
src/compat/tq_lineloss/org_units.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
pub(crate) struct OrgUnit {
|
||||
pub(crate) label: &'static str,
|
||||
pub(crate) code: &'static str,
|
||||
pub(crate) aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub(crate) const ORG_UNITS: &[OrgUnit] = &[
|
||||
OrgUnit {
|
||||
label: "国网兰州供电公司",
|
||||
code: "62401",
|
||||
aliases: &["国网兰州供电公司", "兰州供电公司", "兰州公司"],
|
||||
},
|
||||
OrgUnit {
|
||||
label: "国网天水供电公司",
|
||||
code: "62403",
|
||||
aliases: &["国网天水供电公司", "天水供电公司", "天水公司"],
|
||||
},
|
||||
OrgUnit {
|
||||
label: "城关供电分公司",
|
||||
code: "6240108",
|
||||
aliases: &["城关供电分公司", "城关分公司"],
|
||||
},
|
||||
OrgUnit {
|
||||
label: "国网榆中县供电公司",
|
||||
code: "6240121",
|
||||
aliases: &["国网榆中县供电公司", "榆中县供电公司", "榆中县公司"],
|
||||
},
|
||||
OrgUnit {
|
||||
label: "榆中城关供电所",
|
||||
code: "624012108",
|
||||
aliases: &["榆中城关供电所"],
|
||||
},
|
||||
];
|
||||
244
src/compat/tq_lineloss/period_resolver.rs
Normal file
244
src/compat/tq_lineloss/period_resolver.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||
use serde_json::json;
|
||||
|
||||
use super::contracts::{
|
||||
contradictory_period_mode_prompt, missing_period_mode_prompt, missing_period_prompt,
|
||||
missing_week_year_prompt, PeriodMode, ResolvedPeriod,
|
||||
};
|
||||
|
||||
pub fn resolve_period(input: &str) -> Result<ResolvedPeriod, String> {
|
||||
let has_month = input.contains("月累计");
|
||||
let has_week = input.contains("周累计");
|
||||
|
||||
match (has_month, has_week) {
|
||||
(true, true) => return Err(contradictory_period_mode_prompt()),
|
||||
(false, false) => return Err(missing_period_mode_prompt()),
|
||||
(true, false) => resolve_month_period(input),
|
||||
(false, true) => resolve_week_period(input),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_month_period(input: &str) -> Result<ResolvedPeriod, String> {
|
||||
if let Some(value) = extract_year_month_dash(input) {
|
||||
return Ok(ResolvedPeriod {
|
||||
mode: PeriodMode::Month,
|
||||
mode_code: "1".to_string(),
|
||||
value: value.clone(),
|
||||
payload: json!({ "fdate": value }),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(value) = extract_year_month_cn(input) {
|
||||
return Ok(ResolvedPeriod {
|
||||
mode: PeriodMode::Month,
|
||||
mode_code: "1".to_string(),
|
||||
value: value.clone(),
|
||||
payload: json!({ "fdate": value }),
|
||||
});
|
||||
}
|
||||
|
||||
if contains_explicit_month_period_hint(input) {
|
||||
return Err(missing_period_prompt());
|
||||
}
|
||||
|
||||
Ok(default_month_period())
|
||||
}
|
||||
|
||||
fn resolve_week_period(input: &str) -> Result<ResolvedPeriod, String> {
|
||||
if input.contains('第') && input.contains('周') && !input.contains('年') {
|
||||
return Err(missing_week_year_prompt());
|
||||
}
|
||||
|
||||
if let Some((year, week)) = extract_year_week(input) {
|
||||
let Some(week_start) = week_start_date(year, week) else {
|
||||
return Err(missing_period_prompt());
|
||||
};
|
||||
let week_end = week_start + Duration::days(6);
|
||||
|
||||
return Ok(ResolvedPeriod {
|
||||
mode: PeriodMode::Week,
|
||||
mode_code: "2".to_string(),
|
||||
value: format!("{year}-W{week:02}"),
|
||||
payload: json!({
|
||||
"tjzq": "week",
|
||||
"level": "00",
|
||||
"weekSfdate": week_start.format("%Y-%m-%d").to_string(),
|
||||
"weekEfdate": week_end.format("%Y-%m-%d").to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if contains_explicit_week_period_hint(input) {
|
||||
return Err(missing_period_prompt());
|
||||
}
|
||||
|
||||
Ok(default_week_period())
|
||||
}
|
||||
|
||||
fn default_month_period() -> ResolvedPeriod {
|
||||
let today = Local::now().date_naive();
|
||||
let (year, month) = if today.month() == 1 {
|
||||
(today.year() - 1, 12)
|
||||
} else {
|
||||
(today.year(), today.month() - 1)
|
||||
};
|
||||
let value = format!("{year}-{month:02}");
|
||||
|
||||
ResolvedPeriod {
|
||||
mode: PeriodMode::Month,
|
||||
mode_code: "1".to_string(),
|
||||
value: value.clone(),
|
||||
payload: json!({ "fdate": value }),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_week_period() -> ResolvedPeriod {
|
||||
let today = Local::now().date_naive();
|
||||
let month_start = today.with_day(1).expect("current month should have day 1");
|
||||
let start = month_start.format("%Y-%m-%d").to_string();
|
||||
let end = today.format("%Y-%m-%d").to_string();
|
||||
|
||||
ResolvedPeriod {
|
||||
mode: PeriodMode::Week,
|
||||
mode_code: "2".to_string(),
|
||||
value: format!("{start}至{end}"),
|
||||
payload: json!({
|
||||
"tjzq": "week",
|
||||
"level": "00",
|
||||
"weekSfdate": start,
|
||||
"weekEfdate": end,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_explicit_month_period_hint(input: &str) -> bool {
|
||||
let trimmed = input.replace("月累计", "");
|
||||
trimmed.contains('年')
|
||||
|| trimmed.contains('月')
|
||||
|| trimmed.contains('-')
|
||||
|| trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn contains_explicit_week_period_hint(input: &str) -> bool {
|
||||
let trimmed = input.replace("周累计", "");
|
||||
trimmed.contains('年')
|
||||
|| trimmed.contains('第')
|
||||
|| trimmed.contains('周')
|
||||
|| trimmed.contains('-')
|
||||
|| trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn extract_year_month_dash(input: &str) -> Option<String> {
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
for window in chars.windows(7) {
|
||||
let candidate: String = window.iter().collect();
|
||||
if is_year_month_dash(&candidate) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_year_month_dash(candidate: &str) -> bool {
|
||||
let bytes = candidate.as_bytes();
|
||||
bytes.len() == 7
|
||||
&& bytes[0..4].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[5..7].iter().all(u8::is_ascii_digit)
|
||||
&& matches!((bytes[5] - b'0') * 10 + (bytes[6] - b'0'), 1..=12)
|
||||
}
|
||||
|
||||
fn extract_year_month_cn(input: &str) -> Option<String> {
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
for index in 0..chars.len() {
|
||||
if index + 6 >= chars.len() {
|
||||
break;
|
||||
}
|
||||
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
if chars[index + 4] != '年' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut month_digits = String::new();
|
||||
let mut cursor = index + 5;
|
||||
while cursor < chars.len() && chars[cursor].is_ascii_digit() && month_digits.len() < 2 {
|
||||
month_digits.push(chars[cursor]);
|
||||
cursor += 1;
|
||||
}
|
||||
if month_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '月' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let month: u32 = month_digits.parse().ok()?;
|
||||
if !(1..=12).contains(&month) {
|
||||
continue;
|
||||
}
|
||||
let year: String = chars[index..index + 4].iter().collect();
|
||||
return Some(format!("{year}-{month:02}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_year_week(input: &str) -> Option<(i32, u32)> {
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
for index in 0..chars.len() {
|
||||
if index + 7 >= chars.len() {
|
||||
break;
|
||||
}
|
||||
if !chars[index..index + 4].iter().all(|ch| ch.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
if chars[index + 4] != '年' || chars[index + 5] != '第' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut week_digits = String::new();
|
||||
let mut cursor = index + 6;
|
||||
while cursor < chars.len() && chars[cursor].is_ascii_digit() && week_digits.len() < 2 {
|
||||
week_digits.push(chars[cursor]);
|
||||
cursor += 1;
|
||||
}
|
||||
if week_digits.is_empty() || cursor >= chars.len() || chars[cursor] != '周' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let year: i32 = chars[index..index + 4].iter().collect::<String>().parse().ok()?;
|
||||
let week: u32 = week_digits.parse().ok()?;
|
||||
if !(1..=53).contains(&week) {
|
||||
continue;
|
||||
}
|
||||
return Some((year, week));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn week_start_date(year: i32, week: u32) -> Option<NaiveDate> {
|
||||
let jan4 = NaiveDate::from_ymd_opt(year, 1, 4)?;
|
||||
let iso_week1_monday = jan4 - Duration::days(jan4.weekday().num_days_from_monday() as i64);
|
||||
let candidate = iso_week1_monday + Duration::weeks((week - 1) as i64);
|
||||
let iso = candidate.iso_week();
|
||||
(iso.year() == year && iso.week() == week).then_some(candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_period;
|
||||
use crate::compat::tq_lineloss::contracts::PeriodMode;
|
||||
|
||||
#[test]
|
||||
fn resolves_dash_month() {
|
||||
let resolved = resolve_period("月累计 2026-03").unwrap();
|
||||
assert_eq!(resolved.mode, PeriodMode::Month);
|
||||
assert_eq!(resolved.payload["fdate"], "2026-03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_week_range() {
|
||||
let resolved = resolve_period("周累计 2026年第12周").unwrap();
|
||||
assert_eq!(resolved.mode, PeriodMode::Week);
|
||||
assert_eq!(resolved.payload["weekSfdate"], "2026-03-16");
|
||||
assert_eq!(resolved.payload["weekEfdate"], "2026-03-22");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::de;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::runtime::RuntimeProfile;
|
||||
@@ -11,6 +10,10 @@ pub use zeroclaw::config::SkillsPromptInjectionMode as SkillsPromptMode;
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
|
||||
const DEFAULT_PROVIDER_ID: &str = "deepseek";
|
||||
const DIRECT_SUBMIT_PROVIDER_ID: &str = "direct-submit";
|
||||
const DIRECT_SUBMIT_BASE_URL: &str = "http://127.0.0.1/direct-submit";
|
||||
const DIRECT_SUBMIT_MODEL: &str = "direct-submit-placeholder-model";
|
||||
const DIRECT_SUBMIT_API_KEY: &str = "direct-submit-placeholder-key";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlannerMode {
|
||||
@@ -67,6 +70,19 @@ impl ProviderSettings {
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_submit_placeholder() -> Self {
|
||||
Self {
|
||||
id: DIRECT_SUBMIT_PROVIDER_ID.to_string(),
|
||||
provider: DIRECT_SUBMIT_PROVIDER_ID.to_string(),
|
||||
api_key: DIRECT_SUBMIT_API_KEY.to_string(),
|
||||
base_url: Some(DIRECT_SUBMIT_BASE_URL.to_string()),
|
||||
model: DIRECT_SUBMIT_MODEL.to_string(),
|
||||
api_path: None,
|
||||
wire_api: None,
|
||||
requires_openai_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_raw(raw: RawProviderSettings) -> Result<Self, ConfigError> {
|
||||
let id = raw.id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
@@ -106,7 +122,7 @@ pub struct DeepSeekSettings {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub skills_dir: Vec<PathBuf>,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl DeepSeekSettings {
|
||||
@@ -125,7 +141,8 @@ pub struct SgClawSettings {
|
||||
pub provider_api_key: String,
|
||||
pub provider_base_url: String,
|
||||
pub provider_model: String,
|
||||
pub skills_dir: Vec<PathBuf>,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
pub direct_submit_skill: Option<String>,
|
||||
pub skills_prompt_mode: SkillsPromptMode,
|
||||
pub runtime_profile: RuntimeProfile,
|
||||
pub planner_mode: PlannerMode,
|
||||
@@ -133,8 +150,6 @@ pub struct SgClawSettings {
|
||||
pub active_provider: String,
|
||||
pub browser_backend: BrowserBackend,
|
||||
pub office_backend: OfficeBackend,
|
||||
pub browser_ws_url: Option<String>,
|
||||
pub service_ws_listen_addr: Option<String>,
|
||||
}
|
||||
|
||||
impl SgClawSettings {
|
||||
@@ -156,7 +171,7 @@ impl SgClawSettings {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
skills_dir: Vec<PathBuf>,
|
||||
skills_dir: Option<PathBuf>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::new(
|
||||
api_key,
|
||||
@@ -166,12 +181,11 @@ impl SgClawSettings {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,7 +213,8 @@ impl SgClawSettings {
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -207,8 +222,6 @@ impl SgClawSettings {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -284,7 +297,8 @@ impl SgClawSettings {
|
||||
config.api_key,
|
||||
config.base_url,
|
||||
config.model,
|
||||
resolve_configured_skills_dirs(config.skills_dir, config_dir),
|
||||
resolve_configured_skills_dir(config.skills_dir, config_dir),
|
||||
config.direct_submit_skill,
|
||||
skills_prompt_mode,
|
||||
runtime_profile,
|
||||
planner_mode,
|
||||
@@ -292,8 +306,6 @@ impl SgClawSettings {
|
||||
config.active_provider,
|
||||
browser_backend,
|
||||
office_backend,
|
||||
config.browser_ws_url,
|
||||
config.service_ws_listen_addr,
|
||||
)
|
||||
.map_err(|err| err.with_path(path))
|
||||
}
|
||||
@@ -302,7 +314,8 @@ impl SgClawSettings {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
skills_dir: Vec<PathBuf>,
|
||||
skills_dir: Option<PathBuf>,
|
||||
direct_submit_skill: Option<String>,
|
||||
skills_prompt_mode: Option<SkillsPromptMode>,
|
||||
runtime_profile: Option<RuntimeProfile>,
|
||||
planner_mode: Option<PlannerMode>,
|
||||
@@ -310,13 +323,16 @@ impl SgClawSettings {
|
||||
active_provider: Option<String>,
|
||||
browser_backend: Option<BrowserBackend>,
|
||||
office_backend: Option<OfficeBackend>,
|
||||
browser_ws_url: Option<String>,
|
||||
service_ws_listen_addr: Option<String>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
|
||||
let providers = if providers.is_empty() {
|
||||
vec![ProviderSettings::from_legacy_deepseek(
|
||||
api_key, base_url, model,
|
||||
)?]
|
||||
if direct_submit_skill.is_some() {
|
||||
vec![ProviderSettings::direct_submit_placeholder()]
|
||||
} else {
|
||||
vec![ProviderSettings::from_legacy_deepseek(
|
||||
api_key, base_url, model,
|
||||
)?]
|
||||
}
|
||||
} else {
|
||||
providers
|
||||
};
|
||||
@@ -340,6 +356,7 @@ impl SgClawSettings {
|
||||
.unwrap_or_default(),
|
||||
provider_model: active_provider_settings.model.clone(),
|
||||
skills_dir,
|
||||
direct_submit_skill,
|
||||
skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact),
|
||||
runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached),
|
||||
planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst),
|
||||
@@ -347,8 +364,6 @@ impl SgClawSettings {
|
||||
active_provider,
|
||||
browser_backend: browser_backend.unwrap_or(BrowserBackend::SuperRpa),
|
||||
office_backend: office_backend.unwrap_or(OfficeBackend::OpenXml),
|
||||
browser_ws_url: normalize_optional_value(browser_ws_url),
|
||||
service_ws_listen_addr: normalize_optional_value(service_ws_listen_addr),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -433,18 +448,18 @@ fn parse_office_backend(raw: &str) -> Result<OfficeBackend, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_configured_skills_dirs(raw: Vec<String>, config_dir: &Path) -> Vec<PathBuf> {
|
||||
raw.into_iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| {
|
||||
let path = PathBuf::from(s.trim());
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
config_dir.join(path)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
fn resolve_configured_skills_dir(raw: Option<String>, config_dir: &Path) -> Option<PathBuf> {
|
||||
let trimmed = raw
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let path = PathBuf::from(trimmed);
|
||||
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
Some(config_dir.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
|
||||
@@ -460,6 +475,29 @@ fn normalize_optional_value(raw: Option<String>) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn normalize_direct_submit_skill(raw: Option<String>) -> Result<Option<String>, ConfigError> {
|
||||
let value = normalize_optional_value(raw);
|
||||
let Some(value) = value.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some((skill_name, tool_name)) = value.split_once('.') else {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"directSubmitSkill",
|
||||
format!("must use skill.tool format, got {value}"),
|
||||
));
|
||||
};
|
||||
|
||||
if skill_name.trim().is_empty() || tool_name.trim().is_empty() {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"directSubmitSkill",
|
||||
format!("must use skill.tool format, got {value}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(value.to_string()))
|
||||
}
|
||||
|
||||
fn normalize_base_url(raw: String) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -486,49 +524,6 @@ fn normalize_enum_token(raw: &str) -> String {
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn deserialize_skills_dirs<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
struct StringOrVec;
|
||||
|
||||
impl<'de> de::Visitor<'de> for StringOrVec {
|
||||
type Value = Vec<String>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string or array of strings")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<Vec<String>, E> {
|
||||
if value.trim().is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
Ok(vec![value.to_string()])
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
|
||||
let mut dirs = Vec::new();
|
||||
while let Some(value) = seq.next_element::<String>()? {
|
||||
if !value.trim().is_empty() {
|
||||
dirs.push(value);
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<Vec<String>, E> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<Vec<String>, E> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(StringOrVec)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawSgClawSettings {
|
||||
#[serde(rename = "apiKey", default)]
|
||||
@@ -537,8 +532,10 @@ struct RawSgClawSettings {
|
||||
base_url: String,
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
#[serde(rename = "skillsDir", alias = "skills_dir", default, deserialize_with = "deserialize_skills_dirs")]
|
||||
skills_dir: Vec<String>,
|
||||
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
|
||||
skills_dir: Option<String>,
|
||||
#[serde(rename = "directSubmitSkill", alias = "direct_submit_skill", default)]
|
||||
direct_submit_skill: Option<String>,
|
||||
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
|
||||
skills_prompt_mode: Option<String>,
|
||||
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
|
||||
@@ -551,10 +548,6 @@ struct RawSgClawSettings {
|
||||
browser_backend: Option<String>,
|
||||
#[serde(rename = "officeBackend", alias = "office_backend", default)]
|
||||
office_backend: Option<String>,
|
||||
#[serde(rename = "browserWsUrl", alias = "browser_ws_url", default)]
|
||||
browser_ws_url: Option<String>,
|
||||
#[serde(rename = "serviceWsListenAddr", alias = "service_ws_listen_addr", default)]
|
||||
service_ws_listen_addr: Option<String>,
|
||||
#[serde(default)]
|
||||
providers: Vec<RawProviderSettings>,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
pub mod agent;
|
||||
pub mod browser;
|
||||
pub mod compat;
|
||||
pub mod config;
|
||||
pub mod llm;
|
||||
pub mod pipe;
|
||||
pub mod runtime;
|
||||
pub mod security;
|
||||
pub mod service;
|
||||
|
||||
pub use browser::ws_probe::{parse_probe_args, run_probe_script, ProbeError, ProbeOutcome};
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,8 +10,6 @@ use crate::pipe::protocol::{
|
||||
use crate::pipe::{PipeError, Transport};
|
||||
use crate::security::{sign_command, MacPolicy};
|
||||
|
||||
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandOutput {
|
||||
pub seq: u64,
|
||||
@@ -57,46 +55,17 @@ impl<T: Transport> BrowserPipeTool<T> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_timeout(&self) -> Duration {
|
||||
self.response_timeout
|
||||
}
|
||||
|
||||
pub fn mac_policy(&self) -> &MacPolicy {
|
||||
&self.mac_policy
|
||||
}
|
||||
|
||||
pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
self.mac_policy.privileged_surface_metadata()
|
||||
}
|
||||
|
||||
pub fn supports_eval(&self) -> bool {
|
||||
self.mac_policy.supports_pipe_action(&Action::Eval)
|
||||
}
|
||||
|
||||
pub fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
if let Some((presentation_url, output_path)) = approved_local_dashboard_request(
|
||||
&action,
|
||||
¶ms,
|
||||
expected_domain,
|
||||
) {
|
||||
self.mac_policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&action,
|
||||
expected_domain,
|
||||
&presentation_url,
|
||||
&output_path,
|
||||
)
|
||||
.map_err(PipeError::Security)?;
|
||||
} else {
|
||||
self.mac_policy
|
||||
.validate(&action, expected_domain)
|
||||
.map_err(PipeError::Security)?;
|
||||
}
|
||||
self.mac_policy.validate(&action, expected_domain)?;
|
||||
|
||||
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let hmac = sign_command(&self.session_key, seq, &action, ¶ms, expected_domain)?;
|
||||
@@ -146,21 +115,6 @@ impl<T: Transport> BrowserPipeTool<T> {
|
||||
"received duplicate init after handshake".to_string(),
|
||||
));
|
||||
}
|
||||
BrowserMessage::Connect => {
|
||||
return Err(PipeError::UnexpectedMessage(
|
||||
"received connect while waiting for response".to_string(),
|
||||
));
|
||||
}
|
||||
BrowserMessage::Start => {
|
||||
return Err(PipeError::UnexpectedMessage(
|
||||
"received start while waiting for response".to_string(),
|
||||
));
|
||||
}
|
||||
BrowserMessage::Stop => {
|
||||
return Err(PipeError::UnexpectedMessage(
|
||||
"received stop while waiting for response".to_string(),
|
||||
));
|
||||
}
|
||||
BrowserMessage::SubmitTask { .. } => {
|
||||
return Err(PipeError::UnexpectedMessage(
|
||||
"received submit_task while waiting for response".to_string(),
|
||||
@@ -170,31 +124,3 @@ impl<T: Transport> BrowserPipeTool<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn approved_local_dashboard_request(
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
expected_domain: &str,
|
||||
) -> Option<(String, String)> {
|
||||
if action != &Action::Navigate || expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let presentation_url = params.get("url")?.as_str()?.trim();
|
||||
let marker = params.get("sgclaw_local_dashboard_open")?.as_object()?;
|
||||
let source = marker.get("source")?.as_str()?.trim();
|
||||
let kind = marker.get("kind")?.as_str()?.trim();
|
||||
let output_path = marker.get("output_path")?.as_str()?.trim();
|
||||
let marker_presentation_url = marker.get("presentation_url")?.as_str()?.trim();
|
||||
|
||||
if source != "compat.workflow_executor"
|
||||
|| kind != "zhihu_hotlist_screen"
|
||||
|| output_path.is_empty()
|
||||
|| presentation_url.is_empty()
|
||||
|| marker_presentation_url != presentation_url
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((presentation_url.to_string(), output_path.to_string()))
|
||||
}
|
||||
|
||||
@@ -55,9 +55,6 @@ pub enum BrowserMessage {
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
},
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask {
|
||||
instruction: String,
|
||||
#[serde(default)]
|
||||
@@ -122,9 +119,6 @@ pub enum AgentMessage {
|
||||
agent_id: String,
|
||||
supported_actions: Vec<Action>,
|
||||
},
|
||||
StatusChanged {
|
||||
state: String,
|
||||
},
|
||||
LogEntry {
|
||||
level: String,
|
||||
message: String,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
|
||||
@@ -12,9 +12,8 @@ use zeroclaw::tools::{self, ReadSkillTool};
|
||||
use zeroclaw::SecurityPolicy;
|
||||
|
||||
use crate::compat::memory_adapter::build_memory;
|
||||
use crate::compat::config_adapter::resolve_scene_skills_dir_path;
|
||||
use crate::pipe::PipeError;
|
||||
use crate::runtime::{match_scene_instruction, DispatchMode, RuntimeProfile, ToolPolicy};
|
||||
use crate::runtime::{RuntimeProfile, ToolPolicy};
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
@@ -26,7 +25,6 @@ const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\
|
||||
const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path.";
|
||||
const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object.";
|
||||
const ZHIHU_WRITE_PUBLISH_PROMPT: &str = "Zhihu article publish contract:\n- This task may publish a Zhihu article.\n- You must not click publish without explicit human confirmation in the current session.\n- If the user asked to publish but no explicit confirmation phrase is present yet, ask for confirmation concisely and stop after the confirmation request.\n- Do not keep exploring tools after you have determined that publish confirmation is missing.\n- If the user only asked to write or draft, stay in draft mode and do not treat it as publish mode.\n- Do not repeat the same sentence or section in your final answer.";
|
||||
const REPAIR_CITY_DISPATCH_EXECUTION_PROMPT: &str = "95598 repair city dispatch execution contract:\n- Treat this as a browser workflow, not a text-only task.\n- You must call `95598-repair-city-dispatch.collect_repair_orders` first when the tool is available.\n- Use generic browser probing only after the scene-specific collection tool fails or is unavailable.\n- Collect the live repair order queue before summarizing or reporting status.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeEngine {
|
||||
@@ -61,7 +59,7 @@ impl RuntimeEngine {
|
||||
&self,
|
||||
provider: Box<dyn Provider>,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dirs: &[PathBuf],
|
||||
skills_dir: &Path,
|
||||
mut tools: Vec<Box<dyn zeroclaw::tools::Tool>>,
|
||||
browser_surface_present: bool,
|
||||
instruction: &str,
|
||||
@@ -73,7 +71,7 @@ impl RuntimeEngine {
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
|
||||
let skills = self.load_skills_for_surface(config, skills_dirs, browser_surface_present);
|
||||
let skills = load_runtime_skills(config, skills_dir);
|
||||
let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime(
|
||||
Arc::new(config.clone()),
|
||||
&security,
|
||||
@@ -92,21 +90,15 @@ impl RuntimeEngine {
|
||||
);
|
||||
runtime_tools.append(&mut tools);
|
||||
|
||||
let default_skills_dir = config.workspace_dir.join("skills");
|
||||
let has_custom_skills_dir = skills_dirs.iter().any(|d| *d != default_skills_dir);
|
||||
if matches!(
|
||||
config.skills.prompt_injection_mode,
|
||||
SkillsPromptInjectionMode::Compact
|
||||
) && has_custom_skills_dir
|
||||
) && skills_dir != config.workspace_dir.join("skills")
|
||||
{
|
||||
let first_custom = skills_dirs
|
||||
.iter()
|
||||
.find(|d| **d != default_skills_dir)
|
||||
.cloned();
|
||||
runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME);
|
||||
runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir(
|
||||
config.workspace_dir.clone(),
|
||||
first_custom,
|
||||
Some(skills_dir.to_path_buf()),
|
||||
config.skills.allow_scripts,
|
||||
config.skills.open_skills_enabled,
|
||||
config.skills.open_skills_dir.clone(),
|
||||
@@ -132,7 +124,7 @@ impl RuntimeEngine {
|
||||
.skills_prompt_mode(config.skills.prompt_injection_mode)
|
||||
.allowed_tools(self.allowed_tools_for_config(
|
||||
config,
|
||||
skills_dirs,
|
||||
skills_dir,
|
||||
browser_surface_present,
|
||||
instruction,
|
||||
))
|
||||
@@ -153,9 +145,6 @@ impl RuntimeEngine {
|
||||
}
|
||||
|
||||
let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()];
|
||||
if let Some(scene_contract) = build_scene_execution_contract(trimmed_instruction) {
|
||||
sections.push(scene_contract);
|
||||
}
|
||||
if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) {
|
||||
sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string());
|
||||
}
|
||||
@@ -178,9 +167,27 @@ impl RuntimeEngine {
|
||||
pub fn loaded_skills(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dirs: &[PathBuf],
|
||||
skills_dir: &Path,
|
||||
) -> Vec<zeroclaw::skills::Skill> {
|
||||
self.load_skills_for_surface(config, skills_dirs, self.browser_surface_enabled())
|
||||
let mut skills = load_runtime_skills(config, skills_dir);
|
||||
skills.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then(left.version.cmp(&right.version))
|
||||
});
|
||||
skills.dedup_by(|left, right| left.name == right.name && left.version == right.version);
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn loaded_skill_names(&self, config: &ZeroClawConfig, skills_dir: &Path) -> Vec<String> {
|
||||
let mut names = self
|
||||
.loaded_skills(config, skills_dir)
|
||||
.into_iter()
|
||||
.map(|skill| skill.name)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
|
||||
@@ -194,12 +201,11 @@ impl RuntimeEngine {
|
||||
fn allowed_tools_for_config(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dirs: &[PathBuf],
|
||||
skills_dir: &Path,
|
||||
browser_surface_present: bool,
|
||||
instruction: &str,
|
||||
) -> Option<Vec<String>> {
|
||||
let mut allowed_tools = self.tool_policy.allowed_tools.clone();
|
||||
let skills = self.load_skills_for_surface(config, skills_dirs, browser_surface_present);
|
||||
if !browser_surface_present {
|
||||
allowed_tools.retain(|tool| {
|
||||
tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME
|
||||
@@ -221,7 +227,9 @@ impl RuntimeEngine {
|
||||
allowed_tools.push("file_read".to_string());
|
||||
}
|
||||
if browser_surface_present {
|
||||
allowed_tools.extend(browser_script_tool_names(&skills));
|
||||
allowed_tools.extend(browser_script_tool_names(&load_runtime_skills(
|
||||
config, skills_dir,
|
||||
)));
|
||||
}
|
||||
allowed_tools.dedup();
|
||||
|
||||
@@ -233,28 +241,6 @@ impl RuntimeEngine {
|
||||
Some(allowed_tools)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_skills_for_surface(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dirs: &[PathBuf],
|
||||
browser_surface_present: bool,
|
||||
) -> Vec<zeroclaw::skills::Skill> {
|
||||
let mut skills = load_runtime_skills(config, skills_dirs);
|
||||
if !browser_surface_present {
|
||||
skills.iter_mut().for_each(|skill| {
|
||||
skill.tools.retain(|tool| tool.kind != "browser_script");
|
||||
});
|
||||
skills.retain(|skill| !skill.tools.is_empty());
|
||||
}
|
||||
skills.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then(left.version.cmp(&right.version))
|
||||
});
|
||||
skills.dedup_by(|left, right| left.name == right.name && left.version == right.version);
|
||||
skills
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_script_tool_names(skills: &[zeroclaw::skills::Skill]) -> Vec<String> {
|
||||
@@ -276,17 +262,6 @@ fn task_needs_local_file_read(instruction: &str) -> bool {
|
||||
normalized.contains("/home/") || normalized.contains("./") || normalized.contains("../")
|
||||
}
|
||||
|
||||
fn build_scene_execution_contract(instruction: &str) -> Option<String> {
|
||||
let scene = match_scene_instruction(instruction)?;
|
||||
if scene.id == "95598-repair-city-dispatch"
|
||||
&& matches!(scene.dispatch_mode, DispatchMode::AgentBrowser)
|
||||
{
|
||||
Some(REPAIR_CITY_DISPATCH_EXECUTION_PROMPT.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zhihu_hotlist_task(
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
@@ -301,16 +276,13 @@ pub fn is_zhihu_hotlist_task(
|
||||
|| normalized_url.contains("zhihu.com")
|
||||
|| normalized_title.contains("zhihu")
|
||||
|| page_title.unwrap_or_default().contains("知乎");
|
||||
let hotlist_in_instruction = normalized_instruction.contains("hotlist")
|
||||
|| instruction.contains("热榜");
|
||||
let hotlist_in_context = normalized_url.contains("/hot")
|
||||
let is_hotlist = normalized_instruction.contains("hotlist")
|
||||
|| instruction.contains("热榜")
|
||||
|| normalized_url.contains("/hot")
|
||||
|| normalized_title.contains("hotlist")
|
||||
|| page_title.unwrap_or_default().contains("热榜");
|
||||
|
||||
// "热榜"/"hotlist" directly in the instruction implies Zhihu (the only
|
||||
// hotlist feature sgClaw supports). Context-only signals (URL/title)
|
||||
// still require the "知乎" qualifier to avoid false positives.
|
||||
(is_zhihu && (hotlist_in_instruction || hotlist_in_context)) || hotlist_in_instruction
|
||||
is_zhihu && is_hotlist
|
||||
}
|
||||
|
||||
fn task_needs_office_export(instruction: &str) -> bool {
|
||||
@@ -374,17 +346,12 @@ pub fn is_zhihu_write_task(
|
||||
is_zhihu && is_write
|
||||
}
|
||||
|
||||
fn load_runtime_skills(config: &ZeroClawConfig, skills_dirs: &[PathBuf]) -> Vec<zeroclaw::skills::Skill> {
|
||||
fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zeroclaw::skills::Skill> {
|
||||
let default_skills_dir = config.workspace_dir.join("skills");
|
||||
|
||||
// When using only the default workspace skills directory, use the
|
||||
// config-aware loader which respects open_skills configuration.
|
||||
if skills_dirs.len() == 1 && skills_dirs[0] == default_skills_dir {
|
||||
if skills_dir == default_skills_dir {
|
||||
return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||
}
|
||||
|
||||
// Start with workspace skills, then filter out those from the default dir
|
||||
// so they don't duplicate skills loaded from the configured directories.
|
||||
let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||
skills.retain(|skill| {
|
||||
skill
|
||||
@@ -393,24 +360,10 @@ fn load_runtime_skills(config: &ZeroClawConfig, skills_dirs: &[PathBuf]) -> Vec<
|
||||
.map(|location| !location.starts_with(&default_skills_dir))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
for dir in skills_dirs {
|
||||
if *dir == default_skills_dir {
|
||||
continue;
|
||||
}
|
||||
skills.extend(zeroclaw::skills::load_skills_from_directory(
|
||||
dir,
|
||||
config.skills.allow_scripts,
|
||||
));
|
||||
|
||||
let scene_skills_dir = resolve_scene_skills_dir_path(dir.clone());
|
||||
if scene_skills_dir != *dir {
|
||||
skills.extend(zeroclaw::skills::load_skills_from_directory(
|
||||
&scene_skills_dir,
|
||||
config.skills.allow_scripts,
|
||||
));
|
||||
}
|
||||
}
|
||||
skills.extend(zeroclaw::skills::load_skills_from_directory(
|
||||
skills_dir,
|
||||
config.skills.allow_scripts,
|
||||
));
|
||||
skills
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
mod engine;
|
||||
mod profile;
|
||||
mod scene_registry;
|
||||
mod tool_policy;
|
||||
|
||||
pub use engine::{
|
||||
is_zhihu_hotlist_task, is_zhihu_write_task, task_requests_zhihu_article_publish, RuntimeEngine,
|
||||
};
|
||||
pub use profile::RuntimeProfile;
|
||||
pub use scene_registry::{
|
||||
load_first_slice_scene_registry, load_scene_registry_from_root, match_scene_instruction,
|
||||
match_scene_instruction_in_registry, DispatchMode, SceneRegistryEntry,
|
||||
};
|
||||
pub use tool_policy::ToolPolicy;
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const STAGED_SCENE_ROOT: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DispatchMode {
|
||||
DirectBrowser,
|
||||
AgentBrowser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SceneRegistryEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub inputs: Vec<String>,
|
||||
pub outputs: Vec<String>,
|
||||
pub skill_package: String,
|
||||
pub skill_tool: String,
|
||||
pub skill_artifact_type: String,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
pub expected_domain: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub default_args: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SceneMetadata {
|
||||
id: String,
|
||||
name: String,
|
||||
summary: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
inputs: Vec<String>,
|
||||
#[serde(default)]
|
||||
outputs: Vec<String>,
|
||||
skill: SceneSkillMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SceneSkillMetadata {
|
||||
package: String,
|
||||
tool: String,
|
||||
artifact_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct SceneMatchScore {
|
||||
matched_terms: usize,
|
||||
longest_term: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct SceneMatchResult {
|
||||
score: SceneMatchScore,
|
||||
has_strong_phrase_hit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SceneRuntimePolicy {
|
||||
scene_id: &'static str,
|
||||
dispatch_mode: DispatchMode,
|
||||
expected_domain: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
const FIRST_SLICE_POLICIES: [SceneRuntimePolicy; 2] = [
|
||||
SceneRuntimePolicy {
|
||||
scene_id: "fault-details-report",
|
||||
dispatch_mode: DispatchMode::DirectBrowser,
|
||||
expected_domain: "sgcc.example.invalid",
|
||||
aliases: &["故障明细", "故障明细报表", "导出故障明细"],
|
||||
},
|
||||
SceneRuntimePolicy {
|
||||
scene_id: "95598-repair-city-dispatch",
|
||||
dispatch_mode: DispatchMode::AgentBrowser,
|
||||
expected_domain: "95598.example.invalid",
|
||||
aliases: &["95598抢修市指", "市指抢修监测", "95598抢修队列", "95598抢修市指监测"],
|
||||
},
|
||||
];
|
||||
|
||||
pub fn load_first_slice_scene_registry() -> Vec<SceneRegistryEntry> {
|
||||
load_scene_registry_from_root(Path::new(STAGED_SCENE_ROOT))
|
||||
}
|
||||
|
||||
pub fn load_scene_registry_from_root(root: &Path) -> Vec<SceneRegistryEntry> {
|
||||
let mut registry = Vec::new();
|
||||
for policy in FIRST_SLICE_POLICIES {
|
||||
if let Some(entry) = load_scene_entry(root, &policy) {
|
||||
registry.push(entry);
|
||||
}
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
pub fn match_scene_instruction(instruction: &str) -> Option<SceneRegistryEntry> {
|
||||
let registry = load_first_slice_scene_registry();
|
||||
match_scene_instruction_in_registry(®istry, instruction)
|
||||
}
|
||||
|
||||
pub fn match_scene_instruction_in_registry(
|
||||
registry: &[SceneRegistryEntry],
|
||||
instruction: &str,
|
||||
) -> Option<SceneRegistryEntry> {
|
||||
let normalized_instruction = normalize_for_match(instruction);
|
||||
if normalized_instruction.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut best_match: Option<(SceneMatchResult, &SceneRegistryEntry)> = None;
|
||||
let mut ambiguous = false;
|
||||
let mut strong_phrase_hits = 0;
|
||||
|
||||
for entry in registry {
|
||||
let Some(result) = score_scene_instruction(entry, &normalized_instruction) else {
|
||||
continue;
|
||||
};
|
||||
if result.has_strong_phrase_hit {
|
||||
strong_phrase_hits += 1;
|
||||
}
|
||||
|
||||
match &best_match {
|
||||
None => {
|
||||
best_match = Some((result, entry));
|
||||
ambiguous = false;
|
||||
}
|
||||
Some((current_result, _)) if result.score > current_result.score => {
|
||||
best_match = Some((result, entry));
|
||||
ambiguous = false;
|
||||
}
|
||||
Some((current_result, current_entry)) if result.score == current_result.score => {
|
||||
if result.has_strong_phrase_hit && current_result.has_strong_phrase_hit {
|
||||
if current_entry.id != entry.id {
|
||||
ambiguous = true;
|
||||
}
|
||||
} else if result.has_strong_phrase_hit && !current_result.has_strong_phrase_hit {
|
||||
best_match = Some((result, entry));
|
||||
ambiguous = false;
|
||||
} else if current_result.has_strong_phrase_hit && !result.has_strong_phrase_hit {
|
||||
ambiguous = false;
|
||||
} else if current_entry.id != entry.id {
|
||||
ambiguous = true;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if ambiguous || strong_phrase_hits > 1 {
|
||||
None
|
||||
} else {
|
||||
best_match.map(|(_, entry)| entry.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_scene_entry(root: &Path, policy: &SceneRuntimePolicy) -> Option<SceneRegistryEntry> {
|
||||
let scene_path = scene_json_path(root, policy.scene_id);
|
||||
let contents = fs::read_to_string(scene_path).ok()?;
|
||||
let metadata: SceneMetadata = serde_json::from_str(&contents).ok()?;
|
||||
if metadata.id != policy.scene_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SceneRegistryEntry {
|
||||
id: policy.scene_id.to_string(),
|
||||
name: metadata.name,
|
||||
summary: metadata.summary,
|
||||
tags: metadata.tags,
|
||||
inputs: metadata.inputs,
|
||||
outputs: metadata.outputs,
|
||||
skill_package: metadata.skill.package,
|
||||
skill_tool: metadata.skill.tool,
|
||||
skill_artifact_type: metadata.skill.artifact_type,
|
||||
dispatch_mode: policy.dispatch_mode,
|
||||
expected_domain: policy.expected_domain.to_string(),
|
||||
aliases: policy.aliases.iter().map(|alias| (*alias).to_string()).collect(),
|
||||
default_args: Map::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scene_json_path(root: &Path, scene_id: &str) -> PathBuf {
|
||||
root.join("scenes").join(scene_id).join("scene.json")
|
||||
}
|
||||
|
||||
fn score_scene_instruction(
|
||||
entry: &SceneRegistryEntry,
|
||||
normalized_instruction: &str,
|
||||
) -> Option<SceneMatchResult> {
|
||||
let mut matched_terms = 0;
|
||||
let mut longest_term = 0;
|
||||
let mut has_strong_phrase_hit = false;
|
||||
|
||||
for term in candidate_match_terms(entry) {
|
||||
let normalized_term = normalize_for_match(&term);
|
||||
if normalized_term.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if normalized_instruction.contains(&normalized_term) {
|
||||
matched_terms += 1;
|
||||
longest_term = longest_term.max(normalized_term.len());
|
||||
if normalized_term.len() >= 6 {
|
||||
has_strong_phrase_hit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched_terms == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(SceneMatchResult {
|
||||
score: SceneMatchScore {
|
||||
matched_terms,
|
||||
longest_term,
|
||||
},
|
||||
has_strong_phrase_hit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_match_terms(entry: &SceneRegistryEntry) -> Vec<String> {
|
||||
let mut terms = Vec::new();
|
||||
terms.push(entry.id.clone());
|
||||
terms.push(entry.name.clone());
|
||||
terms.push(entry.summary.clone());
|
||||
terms.extend(entry.tags.iter().cloned());
|
||||
terms.extend(entry.aliases.iter().cloned());
|
||||
terms
|
||||
}
|
||||
|
||||
fn normalize_for_match(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_' && *ch != ':')
|
||||
.flat_map(|ch| ch.to_lowercase())
|
||||
.collect()
|
||||
}
|
||||
@@ -25,8 +25,6 @@ pub struct PipeActionRules {
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||||
|
||||
impl MacPolicy {
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SecurityError> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
@@ -41,20 +39,6 @@ impl MacPolicy {
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
pub fn supports_pipe_action(&self, action: &Action) -> bool {
|
||||
let action_name = action.as_str();
|
||||
!self
|
||||
.pipe_actions
|
||||
.blocked
|
||||
.iter()
|
||||
.any(|blocked| blocked == action_name)
|
||||
&& self
|
||||
.pipe_actions
|
||||
.allowed
|
||||
.iter()
|
||||
.any(|allowed| allowed == action_name)
|
||||
}
|
||||
|
||||
pub fn validate(&self, action: &Action, expected_domain: &str) -> Result<(), SecurityError> {
|
||||
let action_name = action.as_str();
|
||||
if self
|
||||
@@ -93,64 +77,6 @@ impl MacPolicy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_local_dashboard_presentation(
|
||||
&self,
|
||||
action: &Action,
|
||||
expected_domain: &str,
|
||||
presentation_url: &str,
|
||||
output_path: &str,
|
||||
) -> Result<(), SecurityError> {
|
||||
let action_name = action.as_str();
|
||||
if self
|
||||
.pipe_actions
|
||||
.blocked
|
||||
.iter()
|
||||
.any(|blocked| blocked == action_name)
|
||||
{
|
||||
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
|
||||
}
|
||||
|
||||
if !self
|
||||
.pipe_actions
|
||||
.allowed
|
||||
.iter()
|
||||
.any(|allowed| allowed == action_name)
|
||||
{
|
||||
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
|
||||
}
|
||||
|
||||
if action != &Action::Navigate {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard open only supports navigate".to_string(),
|
||||
));
|
||||
}
|
||||
if expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard expected_domain is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
if !presentation_url.starts_with("file:///") {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard presentation_url must be file:///".to_string(),
|
||||
));
|
||||
}
|
||||
if !output_path.to_ascii_lowercase().ends_with(".html") {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard output_path must point to .html".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let normalized_output = normalize_local_dashboard_path(output_path);
|
||||
let normalized_presentation = normalize_local_dashboard_file_url(presentation_url)?;
|
||||
if normalized_output != normalized_presentation {
|
||||
return Err(SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard presentation_url does not match output_path".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn privileged_surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
let mut metadata = ExecutionSurfaceMetadata::privileged_browser_pipe("mac_policy");
|
||||
metadata.allowed_domains = self.domains.allowed.clone();
|
||||
@@ -190,19 +116,3 @@ fn normalize_domain(raw: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn normalize_local_dashboard_path(raw: &str) -> String {
|
||||
raw.trim().replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn normalize_local_dashboard_file_url(raw: &str) -> Result<String, SecurityError> {
|
||||
let path = raw
|
||||
.trim()
|
||||
.strip_prefix("file:///")
|
||||
.ok_or_else(|| {
|
||||
SecurityError::InvalidLocalDashboard(
|
||||
"local dashboard presentation_url must be file:///".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(normalize_local_dashboard_path(path))
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ pub enum SecurityError {
|
||||
ActionNotAllowed(String),
|
||||
#[error("domain is not allowed: {0}")]
|
||||
DomainNotAllowed(String),
|
||||
#[error("invalid local dashboard request: {0}")]
|
||||
InvalidLocalDashboard(String),
|
||||
#[error("invalid rules: {0}")]
|
||||
InvalidRules(String),
|
||||
#[error("hmac error: {0}")]
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
mod protocol;
|
||||
pub(crate) mod server;
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tungstenite::accept;
|
||||
|
||||
use crate::agent::AgentRuntimeContext;
|
||||
use crate::pipe::PipeError;
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
const DEFAULT_BROWSER_WS_URL: &str = "ws://127.0.0.1:12345";
|
||||
const DEFAULT_SERVICE_WS_LISTEN_ADDR: &str = "127.0.0.1:42321";
|
||||
|
||||
pub use protocol::{ClientMessage, ServiceMessage};
|
||||
pub use server::{serve_client, ServiceEventSink, ServiceSession};
|
||||
|
||||
pub(crate) mod browser_ws_client {
|
||||
pub(crate) use super::server::{initial_request_url_for_submit_task, ServiceWsClient};
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ServiceStartupConfig {
|
||||
pub browser_ws_url: Option<String>,
|
||||
pub service_ws_listen_addr: Option<String>,
|
||||
}
|
||||
|
||||
pub fn load_startup_config(
|
||||
runtime_context: &AgentRuntimeContext,
|
||||
) -> Result<ServiceStartupConfig, PipeError> {
|
||||
let settings = runtime_context
|
||||
.load_sgclaw_settings()?
|
||||
.ok_or_else(|| PipeError::Protocol("missing environment variable: DEEPSEEK_API_KEY".to_string()))?;
|
||||
|
||||
Ok(ServiceStartupConfig {
|
||||
browser_ws_url: Some(
|
||||
settings
|
||||
.browser_ws_url
|
||||
.unwrap_or_else(|| DEFAULT_BROWSER_WS_URL.to_string()),
|
||||
),
|
||||
service_ws_listen_addr: Some(
|
||||
settings
|
||||
.service_ws_listen_addr
|
||||
.unwrap_or_else(|| DEFAULT_SERVICE_WS_LISTEN_ADDR.to_string()),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), PipeError> {
|
||||
let runtime_context = AgentRuntimeContext::from_process_args(std::env::args_os())?;
|
||||
let startup = load_startup_config(&runtime_context)?;
|
||||
let service_ws_listen_addr = startup
|
||||
.service_ws_listen_addr
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_SERVICE_WS_LISTEN_ADDR);
|
||||
let browser_ws_url = startup
|
||||
.browser_ws_url
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_BROWSER_WS_URL);
|
||||
let listener = TcpListener::bind(service_ws_listen_addr)
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to bind service listener {service_ws_listen_addr}: {err}")))?;
|
||||
let mac_policy = load_service_mac_policy()?;
|
||||
let session = ServiceSession::new();
|
||||
|
||||
eprintln!(
|
||||
"sg_claw ready: service_ws_listen_addr={}, browser_ws_url={}",
|
||||
service_ws_listen_addr,
|
||||
browser_ws_url,
|
||||
);
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let websocket = accept(stream)
|
||||
.map_err(|err| PipeError::Protocol(format!("service websocket accept failed: {err}")))?;
|
||||
let sink = Arc::new(ServiceEventSink::from_websocket(websocket));
|
||||
match session.try_attach_client() {
|
||||
Ok(()) => {
|
||||
let result = serve_client(
|
||||
&runtime_context,
|
||||
&session,
|
||||
sink.clone(),
|
||||
browser_ws_url,
|
||||
&mac_policy,
|
||||
);
|
||||
session.detach_client();
|
||||
match result {
|
||||
Ok(()) | Err(PipeError::PipeClosed) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
sink.send_service_message(message)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_service_mac_policy() -> Result<MacPolicy, PipeError> {
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let candidate = current_exe
|
||||
.parent()
|
||||
.map(|dir| dir.join("resources").join("rules.json"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("resources").join("rules.json"));
|
||||
let path = if candidate.exists() {
|
||||
candidate
|
||||
} else {
|
||||
std::env::current_dir()?.join("resources").join("rules.json")
|
||||
};
|
||||
MacPolicy::load_from_path(&path).map_err(PipeError::from)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agent::SubmitTaskRequest;
|
||||
use crate::pipe::ConversationMessage;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Connect,
|
||||
Start,
|
||||
Stop,
|
||||
SubmitTask {
|
||||
instruction: String,
|
||||
#[serde(default)]
|
||||
conversation_id: String,
|
||||
#[serde(default)]
|
||||
messages: Vec<ConversationMessage>,
|
||||
#[serde(default)]
|
||||
page_url: String,
|
||||
#[serde(default)]
|
||||
page_title: String,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
impl ClientMessage {
|
||||
pub fn into_submit_task_request(self) -> Option<SubmitTaskRequest> {
|
||||
match self {
|
||||
ClientMessage::SubmitTask {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
} => Some(SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id: normalize_optional_field(conversation_id),
|
||||
messages,
|
||||
page_url: normalize_optional_field(page_url),
|
||||
page_title: normalize_optional_field(page_title),
|
||||
}),
|
||||
ClientMessage::Connect | ClientMessage::Start | ClientMessage::Stop | ClientMessage::Ping => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServiceMessage {
|
||||
StatusChanged { state: String },
|
||||
LogEntry { level: String, message: String },
|
||||
TaskComplete { success: bool, summary: String },
|
||||
Busy { message: String },
|
||||
}
|
||||
|
||||
fn normalize_optional_field(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
@@ -1,923 +0,0 @@
|
||||
use std::net::TcpStream;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(test)]
|
||||
use reqwest::blocking::Client;
|
||||
#[cfg(test)]
|
||||
use serde_json::{json, Map};
|
||||
#[cfg(test)]
|
||||
use serde_json::Value;
|
||||
use tungstenite::stream::MaybeTlsStream;
|
||||
use tungstenite::{connect, Message, WebSocket};
|
||||
|
||||
use crate::agent::{
|
||||
run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
|
||||
};
|
||||
use crate::browser::callback_host::LiveBrowserCallbackHost;
|
||||
use crate::browser::ws_backend::WsClient;
|
||||
#[cfg(test)]
|
||||
use crate::browser::bridge_contract::{
|
||||
BridgeBrowserActionError, BridgeBrowserActionReply, BridgeBrowserActionRequest,
|
||||
BridgeBrowserActionSuccess,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::browser::bridge_transport::BridgeActionTransport;
|
||||
use crate::browser::{BrowserBackend, BrowserCallbackBackend};
|
||||
#[cfg(test)]
|
||||
use crate::browser::BridgeBrowserBackend;
|
||||
use crate::pipe::{AgentMessage, BrowserMessage, PipeError, Transport};
|
||||
#[cfg(test)]
|
||||
use crate::pipe::Timing;
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
use super::{ClientMessage, ServiceMessage};
|
||||
|
||||
const BROWSER_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
#[cfg(test)]
|
||||
const DEFAULT_BRIDGE_BASE_URL: &str = "http://localhost:23323";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ServiceSession {
|
||||
attached: Mutex<bool>,
|
||||
task_running: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl ServiceSession {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
attached: Mutex::new(false),
|
||||
task_running: Mutex::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_attach_client(&self) -> Result<(), ServiceMessage> {
|
||||
let mut attached = self.attached.lock().unwrap();
|
||||
if *attached {
|
||||
return Err(ServiceMessage::Busy {
|
||||
message: "service already has an attached client".to_string(),
|
||||
});
|
||||
}
|
||||
*attached = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn detach_client(&self) {
|
||||
let mut attached = self.attached.lock().unwrap();
|
||||
*attached = false;
|
||||
|
||||
let mut task_running = self.task_running.lock().unwrap();
|
||||
*task_running = false;
|
||||
}
|
||||
|
||||
pub fn try_start_task(&self) -> Result<(), ServiceMessage> {
|
||||
let attached = self.attached.lock().unwrap();
|
||||
if !*attached {
|
||||
return Err(ServiceMessage::Busy {
|
||||
message: "service has no attached client".to_string(),
|
||||
});
|
||||
}
|
||||
drop(attached);
|
||||
|
||||
let mut task_running = self.task_running.lock().unwrap();
|
||||
if *task_running {
|
||||
return Err(ServiceMessage::Busy {
|
||||
message: "service already has a running task".to_string(),
|
||||
});
|
||||
}
|
||||
*task_running = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish_task(&self) {
|
||||
let mut task_running = self.task_running.lock().unwrap();
|
||||
*task_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceEventSink {
|
||||
sent: Mutex<Vec<ServiceMessage>>,
|
||||
writer: Option<Mutex<WebSocket<TcpStream>>>,
|
||||
}
|
||||
|
||||
impl Default for ServiceEventSink {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sent: Mutex::new(Vec::new()),
|
||||
writer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceEventSink {
|
||||
pub fn from_websocket(websocket: WebSocket<TcpStream>) -> Self {
|
||||
Self {
|
||||
sent: Mutex::new(Vec::new()),
|
||||
writer: Some(Mutex::new(websocket)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_service_message(&self, message: ServiceMessage) -> Result<(), PipeError> {
|
||||
self.sent.lock().unwrap().push(message.clone());
|
||||
if let Some(writer) = &self.writer {
|
||||
let payload = serde_json::to_string(&message)?;
|
||||
writer
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("service websocket writer lock poisoned".to_string()))?
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|err| PipeError::Protocol(format!("service websocket send failed: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn recv_client_message(&self) -> Result<Option<ClientMessage>, PipeError> {
|
||||
let Some(writer) = &self.writer else {
|
||||
return Err(PipeError::Protocol(
|
||||
"service sink has no websocket reader".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
loop {
|
||||
let mut websocket = writer
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("service websocket writer lock poisoned".to_string()))?;
|
||||
match websocket.read() {
|
||||
Ok(Message::Text(text)) => return Ok(Some(serde_json::from_str(&text)?)),
|
||||
Ok(Message::Close(_)) => return Ok(None),
|
||||
Ok(Message::Ping(payload)) => {
|
||||
websocket
|
||||
.send(Message::Pong(payload))
|
||||
.map_err(|err| PipeError::Protocol(format!("service websocket pong failed: {err}")))?;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::ConnectionClosed) | Err(tungstenite::Error::AlreadyClosed) => {
|
||||
return Ok(None)
|
||||
}
|
||||
Err(err) => return Err(map_service_websocket_error(err, "read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sent_messages(&self) -> Vec<ServiceMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEventSink for ServiceEventSink {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
||||
let mapped = match message {
|
||||
AgentMessage::StatusChanged { state } => ServiceMessage::StatusChanged {
|
||||
state: state.clone(),
|
||||
},
|
||||
AgentMessage::LogEntry { level, message } => ServiceMessage::LogEntry {
|
||||
level: level.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
AgentMessage::TaskComplete { success, summary } => ServiceMessage::TaskComplete {
|
||||
success: *success,
|
||||
summary: summary.clone(),
|
||||
},
|
||||
_ => {
|
||||
return Err(PipeError::Protocol(
|
||||
"unsupported agent message for service sink".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
self.send_service_message(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_service_websocket_error(err: tungstenite::Error, operation: &str) -> PipeError {
|
||||
match err {
|
||||
tungstenite::Error::ConnectionClosed
|
||||
| tungstenite::Error::AlreadyClosed
|
||||
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::ResetWithoutClosingHandshake)
|
||||
| tungstenite::Error::Protocol(tungstenite::error::ProtocolError::SendAfterClosing) => {
|
||||
PipeError::PipeClosed
|
||||
}
|
||||
tungstenite::Error::Io(io_err)
|
||||
if matches!(
|
||||
io_err.kind(),
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
PipeError::Timeout
|
||||
}
|
||||
tungstenite::Error::Io(io_err)
|
||||
if matches!(
|
||||
io_err.kind(),
|
||||
std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
) =>
|
||||
{
|
||||
PipeError::PipeClosed
|
||||
}
|
||||
tungstenite::Error::Io(io_err) => {
|
||||
PipeError::Protocol(format!("service websocket {operation} failed: {io_err}"))
|
||||
}
|
||||
other => PipeError::Protocol(format!("service websocket {operation} failed: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_status_changed(sink: &ServiceEventSink, state: &str) -> Result<(), PipeError> {
|
||||
sink.send(&AgentMessage::StatusChanged {
|
||||
state: state.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve_client(
|
||||
context: &AgentRuntimeContext,
|
||||
session: &ServiceSession,
|
||||
sink: Arc<ServiceEventSink>,
|
||||
browser_ws_url: &str,
|
||||
mac_policy: &MacPolicy,
|
||||
) -> Result<(), PipeError> {
|
||||
// Cache the browser callback host across tasks so the helper page tab is
|
||||
// opened only once per client session instead of once per task.
|
||||
let mut cached_host: Option<Arc<LiveBrowserCallbackHost>> = None;
|
||||
|
||||
loop {
|
||||
let Some(message) = sink.recv_client_message()? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match message {
|
||||
ClientMessage::Connect => send_status_changed(sink.as_ref(), "connected")?,
|
||||
ClientMessage::Start => send_status_changed(sink.as_ref(), "started")?,
|
||||
ClientMessage::Stop => send_status_changed(sink.as_ref(), "stopped")?,
|
||||
ClientMessage::SubmitTask {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url,
|
||||
page_title,
|
||||
} => {
|
||||
let request = ClientMessage::SubmitTask {
|
||||
instruction,
|
||||
conversation_id,
|
||||
messages,
|
||||
page_url: page_url.clone(),
|
||||
page_title,
|
||||
}
|
||||
.into_submit_task_request()
|
||||
.expect("submit task request");
|
||||
if let Err(message) = session.try_start_task() {
|
||||
sink.send_service_message(message)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if request.instruction.trim().is_empty() {
|
||||
let result = sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
session.finish_task();
|
||||
result?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lazily create and cache the browser callback host. On first
|
||||
// task it opens the helper page; subsequent tasks reuse it.
|
||||
if cached_host.is_none() {
|
||||
let bootstrap_url = initial_request_url_for_submit_task(&request);
|
||||
match LiveBrowserCallbackHost::start_with_browser_ws_url(
|
||||
browser_ws_url,
|
||||
&bootstrap_url,
|
||||
Duration::from_secs(15),
|
||||
BROWSER_RESPONSE_TIMEOUT,
|
||||
) {
|
||||
Ok(host) => {
|
||||
cached_host = Some(Arc::new(host));
|
||||
}
|
||||
Err(err) => {
|
||||
session.finish_task();
|
||||
eprintln!("task execution failed: {err}");
|
||||
sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: format!("任务执行失败: {err}"),
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cached_host.as_ref().unwrap().reset_pending_state();
|
||||
}
|
||||
|
||||
let host = cached_host.as_ref().unwrap();
|
||||
let browser_backend: Arc<dyn BrowserBackend> =
|
||||
Arc::new(BrowserCallbackBackend::new(
|
||||
host.clone(),
|
||||
mac_policy.clone(),
|
||||
host.helper_url().to_string(),
|
||||
));
|
||||
|
||||
let result = run_submit_task_with_browser_backend(
|
||||
&NoopTransport,
|
||||
sink.as_ref(),
|
||||
browser_backend,
|
||||
context,
|
||||
request,
|
||||
);
|
||||
session.finish_task();
|
||||
match result {
|
||||
Ok(()) => {}
|
||||
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
|
||||
Err(err) => {
|
||||
eprintln!("task execution failed: {err}");
|
||||
sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: format!("任务执行失败: {err}"),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::Ping => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn initial_request_url_for_submit_task(request: &crate::agent::SubmitTaskRequest) -> String {
|
||||
request
|
||||
.page_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| derive_request_url_from_instruction(&request.instruction))
|
||||
.unwrap_or_else(|| "about:blank".to_string())
|
||||
}
|
||||
|
||||
fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
|
||||
if crate::compat::workflow_executor::detect_route(instruction, None, None)
|
||||
.is_some_and(|route| {
|
||||
matches!(
|
||||
route,
|
||||
crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuHotlistScreen
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleEntry
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleAutoPublishGenerated
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some("https://www.zhihu.com".to_string());
|
||||
}
|
||||
|
||||
if crate::compat::workflow_executor::detect_route(instruction, None, None)
|
||||
.is_some_and(|route| {
|
||||
matches!(
|
||||
route,
|
||||
crate::compat::workflow_executor::WorkflowRoute::ZhihuArticleDraft
|
||||
| crate::compat::workflow_executor::WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some("https://zhuanlan.zhihu.com".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) struct ServiceWsClient {
|
||||
websocket: Mutex<WebSocket<MaybeTlsStream<TcpStream>>>,
|
||||
}
|
||||
|
||||
impl ServiceWsClient {
|
||||
pub(crate) fn connect(browser_ws_url: &str) -> Result<Self, PipeError> {
|
||||
let (mut websocket, _) = connect(browser_ws_url)
|
||||
.map_err(|err| PipeError::Protocol(format!("browser websocket connect failed: {err}")))?;
|
||||
configure_browser_ws_timeouts(&mut websocket, BROWSER_RESPONSE_TIMEOUT)?;
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
r#"{"type":"register","role":"web"}"#.to_string().into(),
|
||||
))
|
||||
.map_err(|err| map_service_websocket_error(err, "register"))?;
|
||||
Ok(Self {
|
||||
websocket: Mutex::new(websocket),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WsClient for ServiceWsClient {
|
||||
fn send_text(&self, payload: &str) -> Result<(), PipeError> {
|
||||
self.websocket
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("browser websocket lock poisoned".to_string()))?
|
||||
.send(Message::Text(payload.to_string().into()))
|
||||
.map_err(|err| map_service_websocket_error(err, "send"))
|
||||
}
|
||||
|
||||
fn recv_text_timeout(&self, timeout: Duration) -> Result<String, PipeError> {
|
||||
let mut websocket = self
|
||||
.websocket
|
||||
.lock()
|
||||
.map_err(|_| PipeError::Protocol("browser websocket lock poisoned".to_string()))?;
|
||||
set_plain_browser_ws_read_timeout(&mut websocket, Some(timeout))?;
|
||||
loop {
|
||||
match websocket.read() {
|
||||
Ok(Message::Text(text)) => return Ok(text.to_string()),
|
||||
Ok(Message::Close(_)) => return Err(PipeError::PipeClosed),
|
||||
Ok(Message::Ping(payload)) => {
|
||||
websocket
|
||||
.send(Message::Pong(payload))
|
||||
.map_err(|err| map_service_websocket_error(err, "pong"))?;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(map_service_websocket_error(err, "read")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_browser_ws_timeouts(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
timeout: Duration,
|
||||
) -> Result<(), PipeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_plain_browser_ws_read_timeout(
|
||||
websocket: &mut WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), PipeError> {
|
||||
match websocket.get_mut() {
|
||||
MaybeTlsStream::Plain(stream) => {
|
||||
stream.set_read_timeout(timeout)?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopTransport;
|
||||
|
||||
impl Transport for NoopTransport {
|
||||
fn send(&self, _message: &AgentMessage) -> Result<(), PipeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recv_timeout(&self, _timeout: Duration) -> Result<BrowserMessage, PipeError> {
|
||||
Err(PipeError::Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct ServiceBridgeTransport {
|
||||
bridge_base_url: String,
|
||||
response_timeout: Duration,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ServiceBridgeTransport {
|
||||
fn with_timeout(bridge_base_url: String, response_timeout: Duration) -> Self {
|
||||
Self {
|
||||
bridge_base_url,
|
||||
response_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_url(&self, action: &str) -> String {
|
||||
format!("{}/{}", self.bridge_base_url.trim_end_matches('/'), action)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl BridgeActionTransport for ServiceBridgeTransport {
|
||||
fn execute(
|
||||
&self,
|
||||
request: BridgeBrowserActionRequest,
|
||||
) -> Result<BridgeBrowserActionReply, PipeError> {
|
||||
let url = self.endpoint_url(&request.action);
|
||||
let response_timeout = self.response_timeout;
|
||||
std::thread::spawn(move || execute_bridge_http_request(url, response_timeout, request))
|
||||
.join()
|
||||
.map_err(|_| PipeError::Protocol("browser bridge worker thread panicked".to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn execute_bridge_http_request(
|
||||
url: String,
|
||||
response_timeout: Duration,
|
||||
request: BridgeBrowserActionRequest,
|
||||
) -> Result<BridgeBrowserActionReply, PipeError> {
|
||||
let BridgeBrowserActionRequest {
|
||||
action,
|
||||
params,
|
||||
expected_domain,
|
||||
} = request;
|
||||
let payload = json!([action, params, expected_domain]);
|
||||
let client = Client::builder()
|
||||
.timeout(response_timeout)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
let response = client
|
||||
.post(url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.map_err(map_bridge_http_error)?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"browser bridge request failed: HTTP {status}"
|
||||
)));
|
||||
}
|
||||
let value: Value = response.json().map_err(|err| {
|
||||
PipeError::Protocol(format!("browser bridge response decode failed: {err}"))
|
||||
})?;
|
||||
normalize_bridge_action_reply(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bridge_base_url_from_browser_ws_url(browser_ws_url: &str) -> String {
|
||||
let trimmed = browser_ws_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return DEFAULT_BRIDGE_BASE_URL.to_string();
|
||||
}
|
||||
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
return trimmed.trim_end_matches('/').to_string();
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
.strip_prefix("ws://")
|
||||
.map(|rest| format!("http://{rest}"))
|
||||
.or_else(|| trimmed.strip_prefix("wss://").map(|rest| format!("https://{rest}")))
|
||||
.unwrap_or_else(|| trimmed.to_string());
|
||||
|
||||
let Ok(parsed) = reqwest::Url::parse(&normalized) else {
|
||||
return DEFAULT_BRIDGE_BASE_URL.to_string();
|
||||
};
|
||||
|
||||
let host = parsed.host_str().unwrap_or("localhost");
|
||||
let is_default_browser_ws = parsed.scheme() == "http"
|
||||
&& parsed.port_or_known_default() == Some(12345)
|
||||
&& matches!(host, "127.0.0.1" | "localhost");
|
||||
if is_default_browser_ws {
|
||||
return DEFAULT_BRIDGE_BASE_URL.to_string();
|
||||
}
|
||||
|
||||
let mut base = format!("{}://{}", parsed.scheme(), host);
|
||||
if let Some(port) = parsed.port() {
|
||||
base.push(':');
|
||||
base.push_str(&port.to_string());
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn map_bridge_http_error(err: reqwest::Error) -> PipeError {
|
||||
if err.is_timeout() {
|
||||
PipeError::Timeout
|
||||
} else {
|
||||
PipeError::Protocol(format!("browser bridge request failed: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn normalize_bridge_action_reply(value: Value) -> Result<BridgeBrowserActionReply, PipeError> {
|
||||
if let Ok(reply) = serde_json::from_value::<BridgeBrowserActionReply>(value.clone()) {
|
||||
return Ok(reply);
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::Number(number) if number.as_i64() == Some(0) => Ok(bridge_success_reply(serde_json::json!({}))),
|
||||
Value::String(text) if text.trim() == "0" => Ok(bridge_success_reply(serde_json::json!({}))),
|
||||
Value::Object(object) => normalize_bridge_action_reply_object(object),
|
||||
other => Err(PipeError::Protocol(format!(
|
||||
"invalid browser bridge reply: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn normalize_bridge_action_reply_object(
|
||||
object: Map<String, Value>,
|
||||
) -> Result<BridgeBrowserActionReply, PipeError> {
|
||||
if let Some(success) = object.get("success").and_then(Value::as_bool) {
|
||||
return Ok(if success {
|
||||
bridge_success_reply(success_data_from_object(&object))
|
||||
} else {
|
||||
bridge_error_reply(error_message_from_object(&object), error_details_from_object(&object))
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(code) = object.get("code").and_then(Value::as_i64) {
|
||||
return Ok(if code == 0 {
|
||||
bridge_success_reply(success_data_from_object(&object))
|
||||
} else {
|
||||
bridge_error_reply(
|
||||
error_message_from_object(&object),
|
||||
object_to_value(object.clone()),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if object.contains_key("data") || object.contains_key("result") || object.contains_key("text") {
|
||||
return Ok(bridge_success_reply(success_data_from_object(&object)));
|
||||
}
|
||||
|
||||
Err(PipeError::Protocol(format!(
|
||||
"invalid browser bridge reply: {}",
|
||||
object_to_value(object)
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bridge_success_reply(data: Value) -> BridgeBrowserActionReply {
|
||||
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 0,
|
||||
exec_ms: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bridge_error_reply(message: String, details: Value) -> BridgeBrowserActionReply {
|
||||
BridgeBrowserActionReply::Error(BridgeBrowserActionError { message, details })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn success_data_from_object(object: &Map<String, Value>) -> Value {
|
||||
object
|
||||
.get("data")
|
||||
.cloned()
|
||||
.or_else(|| object.get("result").cloned())
|
||||
.or_else(|| object.get("text").cloned().map(|text| json!({ "text": text })))
|
||||
.unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn error_message_from_object(object: &Map<String, Value>) -> String {
|
||||
object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| object.get("error").and_then(Value::as_str))
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "browser bridge action failed".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn error_details_from_object(object: &Map<String, Value>) -> Value {
|
||||
object
|
||||
.get("details")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| object_to_value(object.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn object_to_value(object: Map<String, Value>) -> Value {
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_http_json_body(stream: &mut impl std::io::Read) -> Value {
|
||||
let mut buffer = Vec::new();
|
||||
let mut headers_end = None;
|
||||
|
||||
while headers_end.is_none() {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let bytes = std::io::Read::read(stream, &mut chunk).unwrap();
|
||||
assert!(bytes > 0, "unexpected EOF while reading headers");
|
||||
buffer.extend_from_slice(&chunk[..bytes]);
|
||||
headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
|
||||
}
|
||||
|
||||
let headers_end = headers_end.unwrap() + 4;
|
||||
let headers = String::from_utf8(buffer[..headers_end].to_vec()).unwrap();
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
while buffer.len() < headers_end + content_length {
|
||||
let mut chunk = vec![0_u8; content_length];
|
||||
let bytes = std::io::Read::read(stream, &mut chunk).unwrap();
|
||||
assert!(bytes > 0, "unexpected EOF while reading body");
|
||||
buffer.extend_from_slice(&chunk[..bytes]);
|
||||
}
|
||||
|
||||
serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn write_http_json_response(stream: &mut impl std::io::Write, status: &str, body: &Value) {
|
||||
let payload = body.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
payload.len(),
|
||||
payload
|
||||
);
|
||||
std::io::Write::write_all(stream, response.as_bytes()).unwrap();
|
||||
std::io::Write::flush(stream).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{mpsc, Arc};
|
||||
use std::thread;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::agent::SubmitTaskRequest;
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::Action;
|
||||
|
||||
fn service_test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com", "www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_request_url_prefers_submit_task_page_url() {
|
||||
let request = SubmitTaskRequest {
|
||||
instruction: "打开知乎热榜".to_string(),
|
||||
page_url: Some(" https://www.zhihu.com/ ".to_string()),
|
||||
..SubmitTaskRequest::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
initial_request_url_for_submit_task(&request),
|
||||
"https://www.zhihu.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_request_url_falls_back_to_zhihu_origin_for_hotlist_routes() {
|
||||
let request = SubmitTaskRequest {
|
||||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
initial_request_url_for_submit_task(&request),
|
||||
"https://www.zhihu.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_request_url_falls_back_to_zhihu_origin_for_generated_article_publish_routes() {
|
||||
let request = SubmitTaskRequest {
|
||||
instruction: "在知乎自动发表一篇名称为人工智能技能大全".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
initial_request_url_for_submit_task(&request),
|
||||
"https://www.zhihu.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_base_url_defaults_local_browser_ws_endpoint_to_http_bridge() {
|
||||
assert_eq!(
|
||||
bridge_base_url_from_browser_ws_url("ws://127.0.0.1:12345"),
|
||||
"http://localhost:23323"
|
||||
);
|
||||
assert_eq!(
|
||||
bridge_base_url_from_browser_ws_url("ws://localhost:12345"),
|
||||
"http://localhost:23323"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_base_url_maps_non_default_ws_endpoint_to_http_origin() {
|
||||
assert_eq!(
|
||||
bridge_base_url_from_browser_ws_url("ws://127.0.0.1:40123"),
|
||||
"http://127.0.0.1:40123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_bridge_transport_posts_semantic_request_and_maps_success_reply() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{address}");
|
||||
let (request_tx, request_rx) = mpsc::channel();
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = read_http_json_body(&mut stream);
|
||||
request_tx.send(request).unwrap();
|
||||
write_http_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&json!({
|
||||
"success": true,
|
||||
"data": { "text": "天气" }
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
let backend = BridgeBrowserBackend::new(
|
||||
Arc::new(ServiceBridgeTransport::with_timeout(
|
||||
base_url,
|
||||
Duration::from_secs(1),
|
||||
)),
|
||||
service_test_policy(),
|
||||
);
|
||||
|
||||
let output = backend
|
||||
.invoke(Action::GetText, json!({ "selector": "body" }), "www.zhihu.com")
|
||||
.expect("bridge transport should normalize success reply");
|
||||
|
||||
let request = request_rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
server.join().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
json!([
|
||||
"getText",
|
||||
{ "selector": "body" },
|
||||
"www.zhihu.com"
|
||||
])
|
||||
);
|
||||
assert!(output.success);
|
||||
assert_eq!(output.data, json!({ "text": "天气" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_bridge_transport_maps_bridge_error_reply_to_pipe_error() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{address}");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let _request = read_http_json_body(&mut stream);
|
||||
write_http_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
&json!({
|
||||
"success": false,
|
||||
"message": "selector not found",
|
||||
"details": { "selector": "#missing" }
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
let backend = BridgeBrowserBackend::new(
|
||||
Arc::new(ServiceBridgeTransport::with_timeout(
|
||||
base_url,
|
||||
Duration::from_secs(1),
|
||||
)),
|
||||
service_test_policy(),
|
||||
);
|
||||
|
||||
let error = backend
|
||||
.invoke(Action::GetText, json!({ "selector": "#missing" }), "www.zhihu.com")
|
||||
.expect_err("bridge transport should surface semantic bridge failures");
|
||||
|
||||
server.join().unwrap();
|
||||
assert!(matches!(
|
||||
error,
|
||||
PipeError::Protocol(message) if message == "bridge action failed: selector not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_bridge_transport_maps_http_timeout_to_timeout() {
|
||||
let transport = ServiceBridgeTransport::with_timeout(
|
||||
"http://127.0.0.1:1".to_string(),
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
|
||||
let error = transport
|
||||
.execute(BridgeBrowserActionRequest::new(
|
||||
"navigate",
|
||||
json!({ "url": "https://www.zhihu.com/hot" }),
|
||||
"www.zhihu.com",
|
||||
))
|
||||
.expect_err("unreachable bridge should surface a transport error");
|
||||
|
||||
assert!(matches!(error, PipeError::Protocol(_) | PipeError::Timeout));
|
||||
}
|
||||
}
|
||||
@@ -1,300 +1,735 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::agent::{
|
||||
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||||
};
|
||||
use sgclaw::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
|
||||
use sgclaw::compat::runtime::CompatTaskContext;
|
||||
use sgclaw::config::SgClawSettings;
|
||||
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use tungstenite::{accept, Message};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
struct FakeProvider {
|
||||
calls: Vec<ToolFunctionCall>,
|
||||
}
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-agent-runtime-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn write_config(
|
||||
root: &PathBuf,
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
skills_dir: Option<&str>,
|
||||
) -> PathBuf {
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
let mut payload = json!({
|
||||
"apiKey": api_key,
|
||||
"baseUrl": base_url,
|
||||
"model": model,
|
||||
"runtimeProfile": "BrowserAttached"
|
||||
});
|
||||
if let Some(skills_dir) = skills_dir {
|
||||
payload["skillsDir"] = json!(skills_dir);
|
||||
impl LlmProvider for FakeProvider {
|
||||
fn chat(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_tools: &[ToolDefinition],
|
||||
) -> Result<Vec<ToolFunctionCall>, LlmError> {
|
||||
Ok(self.calls.clone())
|
||||
}
|
||||
fs::write(&config_path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
|
||||
config_path
|
||||
}
|
||||
|
||||
fn real_skill_lib_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.find_map(|ancestor| {
|
||||
let candidate = ancestor.join("skill_lib");
|
||||
candidate.is_dir().then_some(candidate)
|
||||
})
|
||||
.expect("workspace should have sgClaw skill_lib ancestor")
|
||||
fn provider_path_test_policy() -> MacPolicy {
|
||||
policy_for_domains(&["www.baidu.com"])
|
||||
}
|
||||
|
||||
fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let frames = Arc::new(Mutex::new(Vec::new()));
|
||||
let frames_for_thread = Arc::clone(&frames);
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
let mut socket = accept(stream).unwrap();
|
||||
let mut action_count = 0_u64;
|
||||
|
||||
loop {
|
||||
let message = match socket.read() {
|
||||
Ok(message) => message,
|
||||
Err(tungstenite::Error::ConnectionClosed)
|
||||
| Err(tungstenite::Error::AlreadyClosed) => break,
|
||||
Err(err) => panic!("browser ws test server read failed: {err}"),
|
||||
};
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
Message::Ping(payload) => {
|
||||
socket.send(Message::Pong(payload)).unwrap();
|
||||
continue;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
frames_for_thread.lock().unwrap().push(payload.clone());
|
||||
|
||||
let parsed: Value = serde_json::from_str(&payload).unwrap();
|
||||
if parsed.get("type").and_then(Value::as_str) == Some("register") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values = parsed.as_array().expect("browser action frame should be an array");
|
||||
let request_url = values[0].as_str().expect("request_url should be a string");
|
||||
let action = values[1].as_str().expect("action should be a string");
|
||||
action_count += 1;
|
||||
|
||||
socket
|
||||
.send(Message::Text(
|
||||
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
socket.send(Message::Text("0".into())).unwrap();
|
||||
|
||||
let callback_frame = match action {
|
||||
"sgHideBrowserCallAfterLoaded" => {
|
||||
let target_url = values[2].as_str().expect("navigate target_url should be a string");
|
||||
json!([
|
||||
request_url,
|
||||
"callBackJsToCpp",
|
||||
format!(
|
||||
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgHideBrowserCallAfterLoaded@_@"
|
||||
)
|
||||
])
|
||||
}
|
||||
"sgBrowserExcuteJsCodeByArea" => {
|
||||
let target_url = values[2].as_str().expect("script target_url should be a string");
|
||||
let response_text = if action_count == 2 {
|
||||
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
|
||||
} else {
|
||||
r#"{"source":"https://www.zhihu.com/hot","sheet_name":"知乎热榜","columns":["rank","title","heat"],"rows":[[1,"问题一","344万"],[2,"问题二","266万"]]}"#.to_string()
|
||||
};
|
||||
json!([
|
||||
request_url,
|
||||
"callBackJsToCpp",
|
||||
format!(
|
||||
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgBrowserExcuteJsCodeByArea@_@{response_text}"
|
||||
)
|
||||
])
|
||||
}
|
||||
other => panic!("unexpected browser action {other}"),
|
||||
};
|
||||
|
||||
socket
|
||||
.send(Message::Text(callback_frame.to_string().into()))
|
||||
.unwrap();
|
||||
|
||||
if action_count >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(format!("ws://{address}"), frames, handle)
|
||||
fn direct_runtime_test_policy() -> MacPolicy {
|
||||
policy_for_domains(&["95598.sgcc.com.cn"])
|
||||
}
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
fn policy_for_domains(domains: &[&str]) -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
&serde_json::json!({
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com", "www.zhihu.com"] },
|
||||
"domains": { "allowed": domains },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
|
||||
fn build_direct_runtime_skill_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-agent-runtime-skill-root-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let skill_dir = root.join("fault-details-report");
|
||||
let script_dir = skill_dir.join("scripts");
|
||||
|
||||
fs::create_dir_all(&script_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.toml"),
|
||||
r#"
|
||||
[skill]
|
||||
name = "fault-details-report"
|
||||
description = "Collect 95598 fault detail data via browser eval."
|
||||
version = "0.1.0"
|
||||
|
||||
[[tools]]
|
||||
name = "collect_fault_details"
|
||||
description = "Collect structured fault detail rows for a specific period."
|
||||
kind = "browser_script"
|
||||
command = "scripts/collect_fault_details.js"
|
||||
|
||||
[tools.args]
|
||||
period = "YYYY-MM period to collect."
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
script_dir.join("collect_fault_details.js"),
|
||||
r#"
|
||||
return {
|
||||
fault_type: "outage",
|
||||
observed_at: `${args.period}-15 09:00`,
|
||||
affected_scope: "line-7",
|
||||
expected_domain: args.expected_domain,
|
||||
artifact_payload: "report artifact payload"
|
||||
};
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
root
|
||||
}
|
||||
|
||||
fn write_direct_submit_config(workspace_root: &std::path::Path, skill_root: &std::path::Path) -> PathBuf {
|
||||
let config_path = workspace_root.join("sgclaw_config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"providers": [],
|
||||
"skillsDir": skill_root,
|
||||
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
config_path
|
||||
}
|
||||
|
||||
fn direct_submit_runtime_context(skill_root: &std::path::Path) -> AgentRuntimeContext {
|
||||
let workspace_root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-agent-runtime-workspace-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
let config_path = write_direct_submit_config(&workspace_root, skill_root);
|
||||
AgentRuntimeContext::new(Some(config_path), workspace_root)
|
||||
}
|
||||
|
||||
fn submit_fault_details_message() -> BrowserMessage {
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://95598.sgcc.com.cn/".to_string(),
|
||||
page_title: "网上国网".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_zhihu_hotlist_export_message() -> BrowserMessage {
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_submit_mode_logs(sent: &[AgentMessage]) -> Vec<String> {
|
||||
sent.iter()
|
||||
.filter_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message } if level == "mode" => Some(message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn direct_submit_completion(sent: &[AgentMessage]) -> Option<(bool, String)> {
|
||||
sent.iter().find_map(|message| match message {
|
||||
AgentMessage::TaskComplete { success, summary } => Some((*success, summary.clone())),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn success_browser_response(seq: u64, data: serde_json::Value) -> BrowserMessage {
|
||||
BrowserMessage::Response {
|
||||
seq,
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn report_artifact_browser_response(
|
||||
seq: u64,
|
||||
status: &str,
|
||||
partial_reasons: &[&str],
|
||||
detail_rows: Vec<serde_json::Value>,
|
||||
summary_rows: Vec<serde_json::Value>,
|
||||
) -> BrowserMessage {
|
||||
success_browser_response(
|
||||
seq,
|
||||
serde_json::json!({
|
||||
"text": {
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"period": "2026-03",
|
||||
"selected_range": {
|
||||
"start": "2026-03-08 16:00:00",
|
||||
"end": "2026-03-09 16:00:00"
|
||||
},
|
||||
"columns": ["qxdbh"],
|
||||
"rows": detail_rows,
|
||||
"sections": [{
|
||||
"name": "summary-sheet",
|
||||
"columns": ["index"],
|
||||
"rows": summary_rows
|
||||
}],
|
||||
"counts": {
|
||||
"detail_rows": detail_rows.len(),
|
||||
"summary_rows": summary_rows.len()
|
||||
},
|
||||
"status": status,
|
||||
"partial_reasons": partial_reasons,
|
||||
"downstream": {
|
||||
"export": {
|
||||
"attempted": true,
|
||||
"success": status != "blocked" && status != "error",
|
||||
"path": "http://localhost/export.xlsx"
|
||||
},
|
||||
"report_log": {
|
||||
"attempted": true,
|
||||
"success": partial_reasons.is_empty(),
|
||||
"error": partial_reasons
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or("")
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_submit_runtime_executes_fault_details_skill_without_provider_path() {
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
serde_json::json!({
|
||||
"text": {
|
||||
"fault_type": "outage",
|
||||
"observed_at": "2026-03-15 09:00",
|
||||
"affected_scope": "line-7"
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"unused-key".to_string(),
|
||||
"http://127.0.0.1:9".to_string(),
|
||||
"unused-model".to_string(),
|
||||
Some(skill_root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.direct_submit_skill = Some("fault-details-report.collect_fault_details".to_string());
|
||||
|
||||
let summary = sgclaw::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||
browser_tool,
|
||||
"请采集 2026-03 的故障明细并返回结果",
|
||||
&CompatTaskContext {
|
||||
page_url: Some("https://95598.sgcc.com.cn/".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
},
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).as_path(),
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(summary.success);
|
||||
assert!(summary.summary.contains("fault_type"));
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().all(|message| !matches!(message, AgentMessage::LogEntry { level, message } if level == "info" && message.contains("DeepSeek config loaded"))));
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
seq,
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
} if *seq == 1
|
||||
&& action == &Action::Eval
|
||||
&& security.expected_domain == "95598.sgcc.com.cn"
|
||||
&& params["script"].as_str().is_some_and(|script| script.contains("2026-03"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_config(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
|
||||
let (ws_url, frames, ws_handle) = start_browser_ws_server();
|
||||
std::env::set_var("SGCLAW_BROWSER_WS_URL", &ws_url);
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
serde_json::json!({
|
||||
"text": {
|
||||
"fault_type": "outage",
|
||||
"observed_at": "2026-03-15 09:00",
|
||||
"affected_scope": "line-7",
|
||||
"artifact_payload": "report artifact payload"
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
ws_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let websocket_frames = frames.lock().unwrap().clone();
|
||||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
assert_eq!(websocket_frames.len(), 4, "{websocket_frames:?}");
|
||||
assert_eq!(websocket_frames[0], r#"{"type":"register","role":"web"}"#);
|
||||
assert!(!websocket_frames
|
||||
.iter()
|
||||
.any(|frame| frame.contains("/sgclaw/browser-helper.html")));
|
||||
assert!(!websocket_frames
|
||||
.iter()
|
||||
.any(|frame| frame.contains("\"sgBrowerserOpenPage\"")));
|
||||
|
||||
let navigate: Value = serde_json::from_str(&websocket_frames[1]).unwrap();
|
||||
assert_eq!(navigate[0], json!("https://www.zhihu.com"));
|
||||
assert_eq!(navigate[1], json!("sgHideBrowserCallAfterLoaded"));
|
||||
assert_eq!(navigate[2], json!("https://www.zhihu.com/hot"));
|
||||
|
||||
let get_text: Value = serde_json::from_str(&websocket_frames[2]).unwrap();
|
||||
assert_eq!(get_text[0], json!("https://www.zhihu.com/hot"));
|
||||
assert_eq!(get_text[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(get_text[2], json!("https://www.zhihu.com/hot"));
|
||||
|
||||
let eval: Value = serde_json::from_str(&websocket_frames[3]).unwrap();
|
||||
assert_eq!(eval[0], json!("https://www.zhihu.com/hot"));
|
||||
assert_eq!(eval[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(eval[2], json!("https://www.zhihu.com/hot"));
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("已导出并打开知乎热榜 Excel") && summary.contains(".xlsx")
|
||||
)
|
||||
}));
|
||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
|
||||
assert!(completion.0, "expected direct submit task to succeed: {sent:?}");
|
||||
assert!(
|
||||
completion.1.contains("report artifact payload"),
|
||||
"expected report artifact payload in summary: {}",
|
||||
completion.1
|
||||
);
|
||||
assert!(
|
||||
!completion.1.contains("未配置大语言模型"),
|
||||
"did not expect missing-llm summary: {}",
|
||||
completion.1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_messages_emit_status_events_without_browser_commands() {
|
||||
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let workspace_root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-invalid-direct-submit-workspace-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
let config_path = workspace_root.join("sgclaw_config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"providers": [],
|
||||
"skillsDir": skill_root,
|
||||
"directSubmitSkill": "fault-details-report"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Connect)
|
||||
.unwrap();
|
||||
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Start)
|
||||
.unwrap();
|
||||
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Stop)
|
||||
.unwrap();
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert_eq!(
|
||||
sent,
|
||||
vec![
|
||||
AgentMessage::StatusChanged {
|
||||
state: "connected".to_string(),
|
||||
},
|
||||
AgentMessage::StatusChanged {
|
||||
state: "started".to_string(),
|
||||
},
|
||||
AgentMessage::StatusChanged {
|
||||
state: "stopped".to_string(),
|
||||
},
|
||||
]
|
||||
assert!(matches!(
|
||||
sent.last(),
|
||||
Some(AgentMessage::TaskComplete { success, summary })
|
||||
if !success && summary.contains("skill.tool")
|
||||
));
|
||||
assert!(direct_submit_mode_logs(&sent).is_empty());
|
||||
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_partial_report_artifact_as_success_with_warning_summary() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||||
1,
|
||||
"partial",
|
||||
&["report_log_failed"],
|
||||
vec![serde_json::json!({ "qxdbh": "QX-1" })],
|
||||
vec![serde_json::json!({ "index": 1 })],
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
assert!(completion.0, "expected partial artifact to succeed: {sent:?}");
|
||||
assert!(completion.1.contains("fault-details-report"));
|
||||
assert!(completion.1.contains("2026-03"));
|
||||
assert!(completion.1.contains("status=partial"));
|
||||
assert!(completion.1.contains("detail_rows=1"));
|
||||
assert!(completion.1.contains("summary_rows=1"));
|
||||
assert!(completion.1.contains("report_log_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_empty_report_artifact_as_success() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||||
1,
|
||||
"empty",
|
||||
&[],
|
||||
vec![],
|
||||
vec![],
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
assert!(completion.0, "expected empty artifact to succeed: {sent:?}");
|
||||
assert!(completion.1.contains("status=empty"));
|
||||
assert!(completion.1.contains("detail_rows=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_blocked_report_artifact_as_failure() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||||
1,
|
||||
"blocked",
|
||||
&["selected_range_unavailable"],
|
||||
vec![],
|
||||
vec![],
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
assert!(!completion.0, "expected blocked artifact to fail: {sent:?}");
|
||||
assert!(completion.1.contains("status=blocked"));
|
||||
assert!(completion.1.contains("selected_range_unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_treats_error_report_artifact_as_failure() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||||
1,
|
||||
"error",
|
||||
&["detail_normalization_failed"],
|
||||
vec![],
|
||||
vec![],
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
assert!(!completion.0, "expected error artifact to fail: {sent:?}");
|
||||
assert!(completion.1.contains("status=error"));
|
||||
assert!(completion.1.contains("detail_normalization_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_task_routes_zhihu_hotlist_export_before_direct_submit() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["www.zhihu.com"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_zhihu_hotlist_export_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let mode_logs = direct_submit_mode_logs(&sent);
|
||||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
assert_eq!(mode_logs, vec!["zeroclaw_process_message_primary".to_string()]);
|
||||
assert!(
|
||||
!completion.0,
|
||||
"expected zhihu export without page context to fail before browser actions: {sent:?}"
|
||||
);
|
||||
assert!(!sent
|
||||
.iter()
|
||||
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
|
||||
assert!(
|
||||
!completion
|
||||
.1
|
||||
.contains("direct submit skill requires page_url so expected_domain can be derived"),
|
||||
"unexpected direct submit fallback: {sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_skill_mode_logs_direct_skill_primary() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let skill_root = build_direct_runtime_skill_root();
|
||||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
serde_json::json!({
|
||||
"text": {
|
||||
"fault_type": "outage",
|
||||
"observed_at": "2026-03-15 09:00",
|
||||
"affected_scope": "line-7",
|
||||
"artifact_payload": "report artifact payload"
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
direct_runtime_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
submit_fault_details_message(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let mode_logs = direct_submit_mode_logs(&sent);
|
||||
|
||||
assert_eq!(mode_logs, vec!["direct_skill_primary".to_string()]);
|
||||
assert!(
|
||||
!mode_logs.iter().any(|mode| mode == "compat_llm_primary"),
|
||||
"unexpected compat mode logs: {mode_logs:?}"
|
||||
);
|
||||
assert!(
|
||||
!mode_logs
|
||||
.iter()
|
||||
.any(|mode| mode == "zeroclaw_process_message_primary"),
|
||||
"unexpected zeroclaw mode logs: {mode_logs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_action_tool_definition_uses_expected_name() {
|
||||
let tool = browser_action_tool_definition();
|
||||
|
||||
assert_eq!(tool.name, "browser_action");
|
||||
assert_eq!(tool.parameters["required"][0], "action");
|
||||
assert_eq!(tool.parameters["required"][1], "expected_domain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_executes_provider_tool_calls_and_returns_summary() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: serde_json::json!({ "typed": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
provider_path_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let provider = FakeProvider {
|
||||
calls: vec![
|
||||
ToolFunctionCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "browser_action".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"url": "https://www.baidu.com"
|
||||
}),
|
||||
},
|
||||
ToolFunctionCall {
|
||||
id: "call-2".to_string(),
|
||||
name: "browser_action".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"action": "type",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"selector": "#kw",
|
||||
"text": "天气",
|
||||
"clear_first": true
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let summary = execute_task_with_provider(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&provider,
|
||||
"打开百度搜索天气",
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(summary, "已通过 Agent 执行任务: 打开百度搜索天气");
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate www.baidu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 1 && action == &Action::Navigate
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "type www.baidu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Type
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_agent_runtime_is_explicitly_dev_only() {
|
||||
assert!(sgclaw::agent::runtime::LEGACY_DEV_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -306,7 +741,7 @@ fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config(
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
provider_path_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use sgclaw::compat::browser_script_skill_tool::build_browser_script_skill_tools;
|
||||
use sgclaw::pipe::{Action, CommandOutput, ExecutionSurfaceKind, ExecutionSurfaceMetadata};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use zeroclaw::skills::{Skill, SkillTool};
|
||||
|
||||
fn backend_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["oa.example.com", "erp.example.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"blocked": ["eval", "executeJsInPage"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn eval_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_browser_backend_keeps_privileged_pipe_surface_metadata() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let backend = PipeBrowserBackend::new(transport, backend_policy(), vec![1, 2, 3, 4]);
|
||||
|
||||
let metadata = backend.surface_metadata();
|
||||
|
||||
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
|
||||
assert!(metadata.privileged);
|
||||
assert!(!metadata.defines_runtime_identity);
|
||||
assert_eq!(metadata.guard, "mac_policy");
|
||||
assert_eq!(
|
||||
metadata.allowed_domains,
|
||||
vec!["oa.example.com", "erp.example.com"]
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.allowed_actions,
|
||||
vec!["click", "type", "navigate", "getText"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_browser_backend_reports_eval_capability_from_mac_policy() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let backend = PipeBrowserBackend::new(transport, eval_policy(), vec![1, 2, 3, 4]);
|
||||
|
||||
assert!(backend.supports_eval());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_script_tools_are_hidden_when_backend_cannot_eval() {
|
||||
let skill_root = unique_temp_dir("sgclaw-browser-backend-capability");
|
||||
let scripts_dir = skill_root.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("extract_hotlist.js"),
|
||||
"return { rows: [[1, '标题', '10万热度']] };",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = vec![Skill {
|
||||
name: "zhihu-hotlist".to_string(),
|
||||
description: "Zhihu hotlist helpers".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: None,
|
||||
tags: vec![],
|
||||
tools: vec![SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: HashMap::new(),
|
||||
}],
|
||||
prompts: vec![],
|
||||
location: Some(skill_root.join("skill.json")),
|
||||
}];
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(FakeBrowserBackend::new(false));
|
||||
|
||||
let tools = build_browser_script_skill_tools(&skills, backend).unwrap();
|
||||
|
||||
assert!(tools.is_empty());
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeBrowserBackend {
|
||||
supports_eval: bool,
|
||||
}
|
||||
|
||||
impl FakeBrowserBackend {
|
||||
fn new(supports_eval: bool) -> Self {
|
||||
Self { supports_eval }
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for FakeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
_action: Action,
|
||||
_params: serde_json::Value,
|
||||
_expected_domain: &str,
|
||||
) -> Result<CommandOutput, sgclaw::pipe::PipeError> {
|
||||
panic!("invoke should not be called in this capability-gating test")
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.supports_eval
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
use sgclaw::browser::bridge_contract::{
|
||||
BridgeBrowserActionError, BridgeBrowserActionReply, BridgeBrowserActionRequest,
|
||||
BridgeBrowserActionSuccess,
|
||||
};
|
||||
use sgclaw::browser::bridge_transport::BridgeActionTransport;
|
||||
use sgclaw::browser::{BridgeBrowserBackend, BrowserBackend};
|
||||
use sgclaw::pipe::{Action, PipeError, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct FakeBridgeTransport {
|
||||
requests: Mutex<Vec<BridgeBrowserActionRequest>>,
|
||||
replies: Mutex<VecDeque<Result<BridgeBrowserActionReply, PipeError>>>,
|
||||
}
|
||||
|
||||
impl FakeBridgeTransport {
|
||||
fn new(replies: Vec<Result<BridgeBrowserActionReply, PipeError>>) -> Self {
|
||||
Self {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
replies: Mutex::new(replies.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn recorded_requests(&self) -> Vec<BridgeBrowserActionRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl BridgeActionTransport for FakeBridgeTransport {
|
||||
fn execute(
|
||||
&self,
|
||||
request: BridgeBrowserActionRequest,
|
||||
) -> Result<BridgeBrowserActionReply, PipeError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
self.replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or(Err(PipeError::Timeout))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_backend_maps_navigate_to_bridge_action_request() {
|
||||
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
|
||||
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 11,
|
||||
},
|
||||
}),
|
||||
)]));
|
||||
let backend = BridgeBrowserBackend::new(transport.clone(), test_policy());
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
transport.recorded_requests(),
|
||||
vec![BridgeBrowserActionRequest::new(
|
||||
"navigate",
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
)]
|
||||
);
|
||||
assert_eq!(output.seq, 1);
|
||||
assert!(output.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_backend_normalizes_successful_bridge_reply() {
|
||||
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
|
||||
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
|
||||
data: json!({ "text": "天气" }),
|
||||
aom_snapshot: vec![json!({ "role": "textbox", "name": "百度一下" })],
|
||||
timing: Timing {
|
||||
queue_ms: 4,
|
||||
exec_ms: 14,
|
||||
},
|
||||
}),
|
||||
)]));
|
||||
let backend = BridgeBrowserBackend::new(transport, test_policy());
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::GetText,
|
||||
json!({ "selector": "#content_left" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.seq, 1);
|
||||
assert!(output.success);
|
||||
assert_eq!(output.data, json!({ "text": "天气" }));
|
||||
assert_eq!(
|
||||
output.aom_snapshot,
|
||||
vec![json!({ "role": "textbox", "name": "百度一下" })]
|
||||
);
|
||||
assert_eq!(
|
||||
output.timing,
|
||||
Timing {
|
||||
queue_ms: 4,
|
||||
exec_ms: 14,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_backend_maps_bridge_failure_to_pipe_error() {
|
||||
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
|
||||
BridgeBrowserActionReply::Error(BridgeBrowserActionError {
|
||||
message: "selector not found".to_string(),
|
||||
details: json!({ "selector": "#missing" }),
|
||||
}),
|
||||
)]));
|
||||
let backend = BridgeBrowserBackend::new(transport, test_policy());
|
||||
|
||||
let error = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({ "selector": "#missing" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PipeError::Protocol(message) if message == "bridge action failed: selector not found"));
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::browser::bridge_contract::{BridgeBrowserActionRequest, BridgeLifecycleCall};
|
||||
|
||||
#[test]
|
||||
fn bridge_contract_names_match_documented_bridge_surface() {
|
||||
let lifecycle_names = [
|
||||
BridgeLifecycleCall::Connect.bridge_name(),
|
||||
BridgeLifecycleCall::Start.bridge_name(),
|
||||
BridgeLifecycleCall::Stop.bridge_name(),
|
||||
BridgeLifecycleCall::SubmitTask.bridge_name(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
lifecycle_names,
|
||||
[
|
||||
"sgclawConnect",
|
||||
"sgclawStart",
|
||||
"sgclawStop",
|
||||
"sgclawSubmitTask",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_contract_represents_browser_action_requests_without_ws_business_frames() {
|
||||
let requests = vec![
|
||||
BridgeBrowserActionRequest::new(
|
||||
"navigate",
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
),
|
||||
BridgeBrowserActionRequest::new(
|
||||
"click",
|
||||
json!({ "selector": "#submit" }),
|
||||
"www.zhihu.com",
|
||||
),
|
||||
BridgeBrowserActionRequest::new(
|
||||
"getText",
|
||||
json!({ "selector": "#content" }),
|
||||
"www.zhihu.com",
|
||||
),
|
||||
];
|
||||
|
||||
let serialized = serde_json::to_value(&requests).unwrap();
|
||||
let entries = serialized.as_array().unwrap();
|
||||
let actions = entries
|
||||
.iter()
|
||||
.map(|entry| entry["action"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!([
|
||||
{
|
||||
"action": "navigate",
|
||||
"params": { "url": "https://www.baidu.com" },
|
||||
"expected_domain": "www.baidu.com"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"params": { "selector": "#submit" },
|
||||
"expected_domain": "www.zhihu.com"
|
||||
},
|
||||
{
|
||||
"action": "getText",
|
||||
"params": { "selector": "#content" },
|
||||
"expected_domain": "www.zhihu.com"
|
||||
}
|
||||
])
|
||||
);
|
||||
assert_eq!(actions, vec!["navigate", "click", "getText"]);
|
||||
|
||||
let first = entries.first().unwrap();
|
||||
let object = first.as_object().unwrap();
|
||||
assert_eq!(object.len(), 3);
|
||||
assert!(object.contains_key("action"));
|
||||
assert!(object.contains_key("params"));
|
||||
assert!(object.contains_key("expected_domain"));
|
||||
assert_eq!(first["expected_domain"], Value::String("www.baidu.com".to_string()));
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::json;
|
||||
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use sgclaw::compat::browser_script_skill_tool::{
|
||||
execute_browser_script_tool, BrowserScriptSkillTool,
|
||||
};
|
||||
@@ -32,6 +31,174 @@ fn test_policy() -> MacPolicy {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("extract_hotlist.js"),
|
||||
"return { wrapped_args: args, source: \"packaged script\" };\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
browser_tool,
|
||||
json!({
|
||||
"expected_domain": "https://WWW.ZHIHU.COM/hot?foo=bar",
|
||||
"top_n": "10"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10\"};")
|
||||
&& params["script"].as_str().unwrap().contains("source: \"packaged script\"")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_rejects_non_browser_script_tool_kind() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-kind");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "shell".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
browser_tool,
|
||||
json!({
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"top_n": "10"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("browser script tool kind must be browser_script, got shell")
|
||||
);
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_rejects_missing_expected_domain() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-domain");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
browser_tool,
|
||||
json!({
|
||||
"expected_domain": " ",
|
||||
"top_n": "10"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("expected_domain must be a non-empty string, got \" \"")
|
||||
);
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
|
||||
@@ -70,7 +237,6 @@ return {
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
@@ -81,7 +247,7 @@ return {
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, backend)
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, browser_tool)
|
||||
.unwrap();
|
||||
|
||||
let result = tool
|
||||
@@ -116,16 +282,16 @@ return {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_helper_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
async fn browser_script_skill_tool_executes_script_directly_under_skill_root() {
|
||||
let skill_root = unique_temp_dir("sgclaw-browser-script-direct-root");
|
||||
let script_name = "extract_hotlist_direct.js";
|
||||
let script_path = skill_root.join(script_name);
|
||||
fs::write(
|
||||
scripts_dir.join("collect_fault_details.js"),
|
||||
&script_path,
|
||||
r#"
|
||||
return {
|
||||
sheet_name: "故障明细",
|
||||
rows: [[args.period, "已完成"]]
|
||||
sheet_name: "知乎热榜",
|
||||
rows: [[1, "标题", args.top_n]]
|
||||
};
|
||||
"#,
|
||||
)
|
||||
@@ -136,8 +302,8 @@ return {
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
@@ -152,32 +318,34 @@ return {
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "collect_fault_details".to_string(),
|
||||
description: "Collect fault detail rows".to_string(),
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/collect_fault_details.js".to_string(),
|
||||
command: script_name.to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, browser_tool)
|
||||
.unwrap();
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"period": "2026-04"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"top_n": "10条"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
@@ -189,49 +357,124 @@ return {
|
||||
..
|
||||
} if action == &Action::Eval
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"period\":\"2026-04\"};")
|
||||
&& params["script"].as_str().unwrap().contains("sheet_name")
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10条\"};")
|
||||
&& params["script"].as_str().unwrap().contains("rows: [[1, \"标题\", args.top_n]]")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_helper_requires_expected_domain() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-missing-domain");
|
||||
async fn execute_browser_script_tool_preserves_structured_report_artifact_payload() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-report-artifact");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("collect_fault_details.js"), "return { ok: true };\n").unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("collect_fault_details.js"),
|
||||
r#"
|
||||
return {
|
||||
type: "report-artifact",
|
||||
report_name: "fault-details-report",
|
||||
period: args.period,
|
||||
selected_range: {
|
||||
start: "2026-03-08 16:00:00",
|
||||
end: "2026-03-09 16:00:00"
|
||||
},
|
||||
columns: ["qxdbh"],
|
||||
rows: [{ qxdbh: "QX-1" }],
|
||||
sections: [{ name: "summary-sheet", columns: ["index"], rows: [{ index: 1 }] }],
|
||||
counts: { detail_rows: 1, summary_rows: 1 },
|
||||
status: "partial",
|
||||
partial_reasons: ["report_log_failed"],
|
||||
downstream: {
|
||||
export: { attempted: true, success: true, path: "http://localhost/export.xlsx" },
|
||||
report_log: { attempted: true, success: false, error: "500" }
|
||||
}
|
||||
};
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"period": "2026-03",
|
||||
"selected_range": {
|
||||
"start": "2026-03-08 16:00:00",
|
||||
"end": "2026-03-09 16:00:00"
|
||||
},
|
||||
"columns": ["qxdbh"],
|
||||
"rows": [{ "qxdbh": "QX-1" }],
|
||||
"sections": [{ "name": "summary-sheet", "columns": ["index"], "rows": [{ "index": 1 }] }],
|
||||
"counts": { "detail_rows": 1, "summary_rows": 1 },
|
||||
"status": "partial",
|
||||
"partial_reasons": ["report_log_failed"],
|
||||
"downstream": {
|
||||
"export": { "attempted": true, "success": true, "path": "http://localhost/export.xlsx" },
|
||||
"report_log": { "attempted": true, "success": false, "error": "500" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("period".to_string(), "YYYY-MM period to collect".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "collect_fault_details".to_string(),
|
||||
description: "Collect fault detail rows".to_string(),
|
||||
description: "Collect structured fault details".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/collect_fault_details.js".to_string(),
|
||||
args,
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"period": "2026-04"
|
||||
}))
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
browser_tool,
|
||||
json!({
|
||||
"expected_domain": "https://www.zhihu.com/",
|
||||
"period": "2026-03"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("missing required field expected_domain")
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"type": "report-artifact",
|
||||
"report_name": "fault-details-report",
|
||||
"period": "2026-03",
|
||||
"selected_range": {
|
||||
"start": "2026-03-08 16:00:00",
|
||||
"end": "2026-03-09 16:00:00"
|
||||
},
|
||||
"columns": ["qxdbh"],
|
||||
"rows": [{ "qxdbh": "QX-1" }],
|
||||
"sections": [{ "name": "summary-sheet", "columns": ["index"], "rows": [{ "index": 1 }] }],
|
||||
"counts": { "detail_rows": 1, "summary_rows": 1 },
|
||||
"status": "partial",
|
||||
"partial_reasons": ["report_log_failed"],
|
||||
"downstream": {
|
||||
"export": { "attempted": true, "success": true, "path": "http://localhost/export.xlsx" },
|
||||
"report_log": { "attempted": true, "success": false, "error": "500" }
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
|
||||
@@ -106,53 +106,6 @@ fn browser_tool_exposes_privileged_surface_metadata_backed_by_mac_policy() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_accepts_approved_local_dashboard_navigate_request() {
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({"navigated": true}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
}]));
|
||||
let tool = BrowserPipeTool::new(transport.clone(), test_policy(), vec![1, 2, 3, 4])
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let result = tool
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
serde_json::json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Navigate
|
||||
&& security.expected_domain == "__sgclaw_local_dashboard__"
|
||||
&& params["url"] == serde_json::json!("file:///C:/tmp/zhihu-hotlist-screen.html")
|
||||
&& params["sgclaw_local_dashboard_open"]["kind"] == serde_json::json!("zhihu_hotlist_screen")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_rules_allow_zhihu_navigation() {
|
||||
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -162,22 +115,3 @@ fn default_rules_allow_zhihu_navigation() {
|
||||
|
||||
policy.validate(&Action::Navigate, "www.zhihu.com").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_policy_rejects_non_html_local_dashboard_presentation() {
|
||||
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("rules.json");
|
||||
let policy = MacPolicy::load_from_path(rules_path).unwrap();
|
||||
|
||||
let err = policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&Action::Navigate,
|
||||
"__sgclaw_local_dashboard__",
|
||||
"file:///C:/tmp/zhihu-hotlist-screen.txt",
|
||||
"C:/tmp/zhihu-hotlist-screen.txt",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("local dashboard"));
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::browser::ws_backend::WsClient;
|
||||
use sgclaw::browser::{BrowserBackend, WsBrowserBackend};
|
||||
use sgclaw::pipe::{Action, PipeError};
|
||||
use sgclaw::security::MacPolicy;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct FakeWsClient {
|
||||
incoming: Mutex<VecDeque<Result<String, PipeError>>>,
|
||||
sent: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeWsClient {
|
||||
fn new(frames: Vec<Result<&str, PipeError>>) -> Self {
|
||||
Self {
|
||||
incoming: Mutex::new(
|
||||
frames
|
||||
.into_iter()
|
||||
.map(|frame| frame.map(str::to_string))
|
||||
.collect(),
|
||||
),
|
||||
sent: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sent_frames(&self) -> Vec<String> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl WsClient for FakeWsClient {
|
||||
fn send_text(&self, payload: &str) -> Result<(), PipeError> {
|
||||
self.sent.lock().unwrap().push(payload.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recv_text_timeout(&self, _timeout: Duration) -> Result<String, PipeError> {
|
||||
self.incoming
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or(Err(PipeError::Timeout))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_ignores_welcome_frame_before_zero_status() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![
|
||||
Ok("Welcome! You are client #1"),
|
||||
Ok("0"),
|
||||
Ok(
|
||||
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
|
||||
),
|
||||
]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client.clone(),
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let sent = client.sent_frames();
|
||||
assert_eq!(sent.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_ignores_json_welcome_frame_before_zero_status() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![
|
||||
Ok(r#"{"type":"welcome","client_id":17,"server_time":"2026-04-04T11:04:54"}"#),
|
||||
Ok("0"),
|
||||
Ok(
|
||||
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
|
||||
),
|
||||
]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client.clone(),
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let sent = client.sent_frames();
|
||||
assert_eq!(sent.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_fails_on_non_numeric_non_welcome_status_frame() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![Ok("not-a-status") ]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client,
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let error = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://www.baidu.com/current",
|
||||
"selector": "#submit"
|
||||
}),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("invalid browser status frame: not-a-status"));
|
||||
}
|
||||
#[test]
|
||||
fn ws_backend_returns_success_for_zero_without_callback() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![
|
||||
Ok("0"),
|
||||
Ok(
|
||||
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
|
||||
),
|
||||
]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client.clone(),
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.seq, 1);
|
||||
assert!(output.success);
|
||||
assert_eq!(output.data, json!({ "text": "" }));
|
||||
assert!(output.aom_snapshot.is_empty());
|
||||
|
||||
let sent = client.sent_frames();
|
||||
assert_eq!(sent.len(), 1);
|
||||
let payload: Value = serde_json::from_str(&sent[0]).unwrap();
|
||||
assert_eq!(payload[1], json!("sgHideBrowserCallAfterLoaded"));
|
||||
assert_eq!(payload[2], json!("https://www.baidu.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_fails_immediately_on_non_zero_return_code() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![Ok("7")]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client,
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let error = backend
|
||||
.invoke(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://www.baidu.com/current",
|
||||
"selector": "#submit"
|
||||
}),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("browser returned non-zero status: 7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_waits_for_callback_and_normalizes_result_payload() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![
|
||||
Ok("0"),
|
||||
Ok(
|
||||
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com/current@_@sgclaw_cb_1@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
|
||||
),
|
||||
]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client.clone(),
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::GetText,
|
||||
json!({
|
||||
"target_url": "https://www.baidu.com/current",
|
||||
"selector": "#content"
|
||||
}),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.seq, 1);
|
||||
assert!(output.success);
|
||||
assert_eq!(output.data, json!({ "text": "天气" }));
|
||||
assert!(output.aom_snapshot.is_empty());
|
||||
|
||||
let sent = client.sent_frames();
|
||||
assert_eq!(sent.len(), 1);
|
||||
let payload: Value = serde_json::from_str(&sent[0]).unwrap();
|
||||
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_times_out_while_waiting_for_callback_after_zero_status() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![Ok("0")]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client,
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_millis(1));
|
||||
|
||||
let error = backend
|
||||
.invoke(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"target_url": "https://www.baidu.com/current",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PipeError::Timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_times_out_when_navigate_callback_never_arrives() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![
|
||||
Err(PipeError::Timeout),
|
||||
Err(PipeError::Timeout),
|
||||
]));
|
||||
let backend = WsBrowserBackend::new(client.clone(), test_policy(), "https://www.zhihu.com")
|
||||
.with_response_timeout(Duration::from_millis(1));
|
||||
|
||||
let error = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.zhihu.com/hot" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PipeError::Timeout));
|
||||
let sent = client.sent_frames();
|
||||
let payload: Value = serde_json::from_str(&sent[0]).unwrap();
|
||||
assert_eq!(payload[1], json!("sgHideBrowserCallAfterLoaded"));
|
||||
assert_eq!(payload[2], json!("https://www.zhihu.com/hot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_reuses_last_navigated_url_for_followup_requests() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![
|
||||
Ok("0"),
|
||||
Ok(
|
||||
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
|
||||
),
|
||||
Ok("0"),
|
||||
Ok(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_2@_@sgBrowserExcuteJsCodeByArea@_@热榜文本"]"#,
|
||||
),
|
||||
]));
|
||||
let backend = WsBrowserBackend::new(client.clone(), test_policy(), "about:blank")
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.zhihu.com/hot" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = backend
|
||||
.invoke(
|
||||
Action::GetText,
|
||||
json!({ "selector": "body" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
assert_eq!(output.data, json!({ "text": "热榜文本" }));
|
||||
|
||||
let sent = client.sent_frames();
|
||||
assert_eq!(sent.len(), 2);
|
||||
|
||||
let navigate_payload: Value = serde_json::from_str(&sent[0]).unwrap();
|
||||
assert_eq!(navigate_payload[0], json!("about:blank"));
|
||||
assert_eq!(navigate_payload[1], json!("sgHideBrowserCallAfterLoaded"));
|
||||
assert_eq!(navigate_payload[2], json!("https://www.zhihu.com/hot"));
|
||||
|
||||
let followup_payload: Value = serde_json::from_str(&sent[1]).unwrap();
|
||||
assert_eq!(followup_payload[0], json!("https://www.zhihu.com/hot"));
|
||||
assert_eq!(followup_payload[1], json!("sgBrowserExcuteJsCodeByArea"));
|
||||
assert_eq!(followup_payload[2], json!("https://www.zhihu.com/hot"));
|
||||
assert_eq!(followup_payload[4], json!("hide"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_backend_propagates_socket_drop_after_navigate_send() {
|
||||
let client = Arc::new(FakeWsClient::new(vec![Err(PipeError::PipeClosed)]));
|
||||
let backend = WsBrowserBackend::new(
|
||||
client,
|
||||
test_policy(),
|
||||
"https://www.baidu.com/current",
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let error = backend
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PipeError::PipeClosed));
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use tungstenite::{accept, Message};
|
||||
|
||||
#[path = "../src/browser/ws_probe.rs"]
|
||||
mod ws_probe;
|
||||
|
||||
use ws_probe::{
|
||||
parse_probe_args, run_probe_script, ProbeCliConfig, ProbeOutcome, ProbeStep, ProbeStepResult,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ServerStep {
|
||||
ReceiveThenReply { expected: String, reply: String },
|
||||
ReceiveThenReplyFrames { expected: String, replies: Vec<String> },
|
||||
ReceiveThenStaySilent { expected: String },
|
||||
ReceiveThenClose { expected: String },
|
||||
CloseBeforeReceive,
|
||||
}
|
||||
|
||||
fn spawn_fake_server(script: Vec<ServerStep>) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let received = Arc::new(Mutex::new(Vec::new()));
|
||||
let received_for_thread = received.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut socket = accept(stream).unwrap();
|
||||
|
||||
for step in script {
|
||||
match step {
|
||||
ServerStep::CloseBeforeReceive => {
|
||||
socket.close(None).unwrap();
|
||||
return;
|
||||
}
|
||||
ServerStep::ReceiveThenReply { expected, reply } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
socket.send(Message::Text(reply.into())).unwrap();
|
||||
}
|
||||
ServerStep::ReceiveThenReplyFrames { expected, replies } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
for reply in replies {
|
||||
socket.send(Message::Text(reply.into())).unwrap();
|
||||
}
|
||||
}
|
||||
ServerStep::ReceiveThenStaySilent { expected } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
ServerStep::ReceiveThenClose { expected } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
socket.close(None).unwrap();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(format!("ws://{addr}"), received, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_rejects_non_ws_schemes() {
|
||||
let cases = [
|
||||
"wss://127.0.0.1:12345",
|
||||
"http://127.0.0.1:12345",
|
||||
"127.0.0.1:12345",
|
||||
];
|
||||
|
||||
for ws_url in cases {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
ws_url.to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"1500".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
];
|
||||
|
||||
let err = parse_probe_args(&args).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"probe argument error: unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
"ws://127.0.0.1:12345".to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"1500".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-hot::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_probe_args(&args).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
ProbeCliConfig {
|
||||
ws_url: "ws://127.0.0.1:12345".to_string(),
|
||||
timeout_ms: 1500,
|
||||
steps: vec![
|
||||
ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "open-hot".to_string(),
|
||||
payload:
|
||||
"[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_defaults_register_step_when_step_is_omitted() {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
"ws://127.0.0.1:12345".to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_probe_args(&args).unwrap();
|
||||
|
||||
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
||||
assert_eq!(parsed.timeout_ms, 1500);
|
||||
assert_eq!(
|
||||
parsed.steps,
|
||||
vec![ProbeStep {
|
||||
label: "register".to_string(),
|
||||
payload: r#"{"type":"register","role":"web"}"#.to_string(),
|
||||
expect_reply: true,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_defaults_timeout_when_flag_is_omitted() {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
"ws://127.0.0.1:12345".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_probe_args(&args).unwrap();
|
||||
|
||||
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
||||
assert_eq!(parsed.timeout_ms, 1500);
|
||||
assert_eq!(
|
||||
parsed.steps,
|
||||
vec![ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
expect_reply: true,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_welcome_then_silence_transcript() {
|
||||
let steps = vec![
|
||||
ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "await-followup".to_string(),
|
||||
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[0].payload.clone(),
|
||||
reply: "Welcome! You are client #1".to_string(),
|
||||
},
|
||||
ServerStep::ReceiveThenStaySilent {
|
||||
expected: steps[1].payload.clone(),
|
||||
},
|
||||
]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
received.lock().unwrap().clone(),
|
||||
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![
|
||||
ProbeStepResult {
|
||||
label: "open-agent".to_string(),
|
||||
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Received(vec!["Welcome! You are client #1".to_string()]),
|
||||
},
|
||||
ProbeStepResult {
|
||||
label: "await-followup".to_string(),
|
||||
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
outcome: ProbeOutcome::TimedOut,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_runs_ordered_frame_script_and_records_per_step_results() {
|
||||
let steps = vec![
|
||||
ProbeStep {
|
||||
label: "bootstrap-1".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "bootstrap-2".to_string(),
|
||||
payload: r#"["about:blank","sgSetAuthInfo","probe-user","probe-token"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "action".to_string(),
|
||||
payload: r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[0].payload.clone(),
|
||||
reply: "welcome".to_string(),
|
||||
},
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[1].payload.clone(),
|
||||
reply: "0".to_string(),
|
||||
},
|
||||
ServerStep::ReceiveThenStaySilent {
|
||||
expected: steps[2].payload.clone(),
|
||||
},
|
||||
]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
received.lock().unwrap().clone(),
|
||||
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].label, "bootstrap-1");
|
||||
assert_eq!(results[0].outcome, ProbeOutcome::Received(vec!["welcome".to_string()]));
|
||||
assert_eq!(results[1].label, "bootstrap-2");
|
||||
assert_eq!(results[1].outcome, ProbeOutcome::Received(vec!["0".to_string()]));
|
||||
assert_eq!(results[2].label, "action");
|
||||
assert_eq!(results[2].sent, r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#);
|
||||
assert_eq!(results[2].outcome, ProbeOutcome::TimedOut);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_multiple_frames_for_one_step_within_timeout_window() {
|
||||
let steps = vec![ProbeStep {
|
||||
label: "bootstrap".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
}];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenReplyFrames {
|
||||
expected: steps[0].payload.clone(),
|
||||
replies: vec!["welcome".to_string(), "status:ready".to_string()],
|
||||
}]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![ProbeStepResult {
|
||||
label: "bootstrap".to_string(),
|
||||
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Received(vec![
|
||||
"welcome".to_string(),
|
||||
"status:ready".to_string(),
|
||||
]),
|
||||
}]
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_steps_that_do_not_wait_for_reply_without_ambiguity() {
|
||||
let steps = vec![ProbeStep {
|
||||
label: "fire-and-forget".to_string(),
|
||||
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
expect_reply: false,
|
||||
}];
|
||||
let (ws_url, received, handle) =
|
||||
spawn_fake_server(vec![ServerStep::ReceiveThenStaySilent {
|
||||
expected: steps[0].payload.clone(),
|
||||
}]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![ProbeStepResult {
|
||||
label: "fire-and-forget".to_string(),
|
||||
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
outcome: ProbeOutcome::NoReplyExpected,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_close_when_server_closes_before_next_send() {
|
||||
let steps = vec![
|
||||
ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "follow-up".to_string(),
|
||||
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[0].payload.clone(),
|
||||
reply: "welcome".to_string(),
|
||||
},
|
||||
ServerStep::CloseBeforeReceive,
|
||||
]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![
|
||||
ProbeStepResult {
|
||||
label: "open-agent".to_string(),
|
||||
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Received(vec!["welcome".to_string()]),
|
||||
},
|
||||
ProbeStepResult {
|
||||
label: "follow-up".to_string(),
|
||||
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Closed,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_socket_close_separately_from_timeout() {
|
||||
let step = ProbeStep {
|
||||
label: "close-case".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
};
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenClose {
|
||||
expected: step.payload.clone(),
|
||||
}]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), vec![step]).unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [r#"["about:blank","sgOpenAgent"]"#]);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].label, "close-case");
|
||||
assert_eq!(results[0].outcome, ProbeOutcome::Closed);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::browser::ws_protocol::{decode_callback_frame, encode_v1_action};
|
||||
use sgclaw::pipe::Action;
|
||||
|
||||
#[test]
|
||||
fn encodes_navigate_frame_exactly_as_browser_array() {
|
||||
let request = encode_v1_action(
|
||||
&Action::Navigate,
|
||||
&json!({ "url": "https://www.baidu.com" }),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req42"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.payload,
|
||||
r#"["https://www.zhihu.com/hot","sgHideBrowserCallAfterLoaded","https://www.baidu.com","callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.baidu.com@_@sgclaw_cb_req42@_@sgHideBrowserCallAfterLoaded@_@\")"]"#
|
||||
);
|
||||
let callback = request.callback.unwrap();
|
||||
assert_eq!(callback.request_id, "req42");
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
|
||||
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.target_url, "https://www.baidu.com");
|
||||
assert_eq!(callback.action_url, "sgHideBrowserCallAfterLoaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_get_text_frame_with_documented_callback_action_url() {
|
||||
let request = encode_v1_action(
|
||||
&Action::GetText,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#content"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req42"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!([
|
||||
"https://www.zhihu.com/hot",
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
"https://www.zhihu.com/hot",
|
||||
"(function(){const el=document.querySelector(\"#content\");if(!el){throw new Error(\"selector not found: #content\");}const text=el.innerText ?? el.textContent ?? \"\";callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text));})();",
|
||||
"hide"
|
||||
])
|
||||
);
|
||||
let callback = request.callback.unwrap();
|
||||
assert_eq!(callback.request_id, "req42");
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
|
||||
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.target_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_callback_payload_from_browser_frame() {
|
||||
let callback = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.target_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
assert_eq!(callback.response_text, "天气");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_callback_frames_and_missing_request_ids() {
|
||||
let malformed = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@too-short"]"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(malformed.to_string().contains("malformed callback payload"));
|
||||
|
||||
let wrong_function = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","sgBrowerserOpenPage","0"]"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(wrong_function
|
||||
.to_string()
|
||||
.contains("callback frame must target callBackJsToCpp"));
|
||||
|
||||
let missing_request_id = encode_v1_action(
|
||||
&Action::Eval,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(missing_request_id
|
||||
.to_string()
|
||||
.contains("request_id is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_uses_documented_js_opcode_for_callback_action_url() {
|
||||
let request = encode_v1_action(
|
||||
&Action::Eval,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req-eval"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let callback = request.callback.unwrap();
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req-eval");
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
let js = payload[3].as_str().unwrap();
|
||||
assert!(js.contains("callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req-eval@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result))"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_supported_v1_action_mapping_and_rejects_unsupported_actions() {
|
||||
let cases = vec![
|
||||
(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
Some("req-nav"),
|
||||
"sgHideBrowserCallAfterLoaded",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#submit"
|
||||
}),
|
||||
None,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#kw",
|
||||
"text": "天气"
|
||||
}),
|
||||
None,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Action::GetText,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#content"
|
||||
}),
|
||||
Some("req-get-text"),
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
Some("req-eval"),
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (action, params, request_id, browser_function, expects_callback) in cases {
|
||||
let request = encode_v1_action(&action, ¶ms, "https://www.zhihu.com/hot", request_id)
|
||||
.unwrap();
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(payload[1], json!(browser_function), "action={action:?}");
|
||||
assert_eq!(request.callback.is_some(), expects_callback, "action={action:?}");
|
||||
}
|
||||
|
||||
let unsupported = encode_v1_action(
|
||||
&Action::GetHtml,
|
||||
&json!({ "selector": "body" }),
|
||||
"https://www.zhihu.com/hot",
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(unsupported.to_string().contains("unsupported browser ws action"));
|
||||
}
|
||||
@@ -17,7 +17,6 @@ impl MockTransport {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::{
|
||||
browser::{BrowserBackend, PipeBrowserBackend},
|
||||
compat::browser_tool_adapter::ZeroClawBrowserTool,
|
||||
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing},
|
||||
};
|
||||
@@ -29,7 +28,7 @@ fn test_policy() -> MacPolicy {
|
||||
|
||||
fn build_adapter(
|
||||
messages: Vec<BrowserMessage>,
|
||||
) -> (Arc<MockTransport>, ZeroClawBrowserTool) {
|
||||
) -> (Arc<MockTransport>, ZeroClawBrowserTool<MockTransport>) {
|
||||
let transport = Arc::new(MockTransport::new(messages));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
@@ -37,9 +36,8 @@ fn build_adapter(
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
(transport, ZeroClawBrowserTool::new(backend))
|
||||
(transport, ZeroClawBrowserTool::new(browser_tool))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config, build_zeroclaw_config_from_settings,
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_scene_skills_dir_path, resolve_skills_dir,
|
||||
zeroclaw_default_skills_dir, zeroclaw_workspace_dir,
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir, zeroclaw_default_skills_dir,
|
||||
zeroclaw_workspace_dir,
|
||||
};
|
||||
use sgclaw::config::{
|
||||
BrowserBackend, DeepSeekSettings, OfficeBackend, PlannerMode, SgClawSettings, SkillsPromptMode,
|
||||
@@ -47,7 +47,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://proxy.example.com/v1".to_string(),
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
|
||||
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
|
||||
@@ -66,7 +66,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skills_dir(Path::new("/var/lib/sgclaw"), &settings),
|
||||
vec![zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))]
|
||||
zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||
assert_eq!(first.api_key, "sk-first");
|
||||
assert_eq!(first.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(first.model, "deepseek-chat");
|
||||
assert!(first.skills_dir.is_empty());
|
||||
assert_eq!(first.skills_dir, None);
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
@@ -111,7 +111,7 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||
assert_eq!(second.api_key, "sk-second");
|
||||
assert_eq!(second.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(second.model, "deepseek-reasoner");
|
||||
assert_eq!(second.skills_dir, vec![root.join("skill_lib")]);
|
||||
assert_eq!(second.skills_dir, Some(root.join("skill_lib")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,12 +122,12 @@ fn resolve_skills_dir_prefers_nested_skills_subdirectory_for_configured_repo_roo
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: vec![root.join("skill_lib")],
|
||||
skills_dir: Some(root.join("skill_lib")),
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, vec![root.join("skill_lib/skills")]);
|
||||
assert_eq!(resolved, root.join("skill_lib/skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -139,41 +139,12 @@ fn resolve_skills_dir_preserves_absolute_configured_skills_directory() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: vec![external_skills.clone()],
|
||||
skills_dir: Some(external_skills.clone()),
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, vec![external_skills]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skills_dir_uses_skills_child_for_external_staged_root() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
|
||||
let staged_root = root.join("external/skill_staging");
|
||||
fs::create_dir_all(staged_root.join("skills")).unwrap();
|
||||
fs::create_dir_all(staged_root.join("scenes")).unwrap();
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: vec![staged_root.clone()],
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, vec![staged_root.join("skills")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scene_skills_dir_path_prefers_staged_skills_child_under_project_root() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-scene-skills-{}", Uuid::new_v4()));
|
||||
let top_level_skills = root.join("project/skills");
|
||||
fs::create_dir_all(top_level_skills.join("skill_staging/skills")).unwrap();
|
||||
|
||||
let resolved = resolve_scene_skills_dir_path(top_level_skills.clone());
|
||||
|
||||
assert_eq!(resolved, top_level_skills.join("skill_staging/skills"));
|
||||
assert_eq!(resolved, external_skills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -182,7 +153,7 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -190,6 +161,60 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_direct_submit_only_config_and_resolve_relative_skills_dir() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-direct-submit-only-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"providers": [],
|
||||
"skillsDir": "skill_lib",
|
||||
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.direct_submit_skill.as_deref(),
|
||||
Some("fault-details-report.collect_fault_details")
|
||||
);
|
||||
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-invalid-direct-submit-skill-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"providers": [],
|
||||
"skillsDir": "skill_lib",
|
||||
"directSubmitSkill": "fault-details-report"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.expect_err("expected invalid directSubmitSkill format");
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("directSubmitSkill"));
|
||||
assert!(message.contains("skill.tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-runtime-config-{}", Uuid::new_v4()));
|
||||
@@ -216,71 +241,17 @@ fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
|
||||
assert_eq!(settings.runtime_profile, RuntimeProfile::GeneralAssistant);
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Full);
|
||||
assert_eq!(settings.skills_dir, vec![root.join("skill_lib")]);
|
||||
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
|
||||
assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_browser_ws_url_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-browser-ws-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"browserWsUrl": "ws://127.0.0.1:12345"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.browser_ws_url.as_deref(),
|
||||
Some("ws://127.0.0.1:12345")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_service_ws_listen_addr_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-service-ws-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"serviceWsListenAddr": "127.0.0.1:42321"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.service_ws_listen_addr.as_deref(),
|
||||
Some("127.0.0.1:42321")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ async fn compat_cron_adapter_creates_lists_and_runs_due_agent_jobs() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-cron");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
@@ -16,7 +16,7 @@ async fn compat_memory_adapter_uses_workspace_local_sqlite_backend() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-memory");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::{fs::File, io::Read};
|
||||
|
||||
use serde_json::json;
|
||||
use sgclaw::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use uuid::Uuid;
|
||||
use zeroclaw::tools::Tool;
|
||||
use zip::ZipArchive;
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-openxml-office-{}", Uuid::new_v4()));
|
||||
@@ -12,6 +13,15 @@ fn temp_workspace_root() -> PathBuf {
|
||||
root
|
||||
}
|
||||
|
||||
fn read_sheet_xml(output_path: &std::path::Path) -> String {
|
||||
let file = File::open(output_path).unwrap();
|
||||
let mut archive = ZipArchive::new(file).unwrap();
|
||||
let mut entry = archive.by_name("xl/worksheets/sheet1.xml").unwrap();
|
||||
let mut xml = String::new();
|
||||
entry.read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
@@ -33,20 +43,12 @@ async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
let payload: serde_json::Value = serde_json::from_str(&result.output).unwrap();
|
||||
assert_eq!(payload["output_path"], json!(output_path.to_str().unwrap()));
|
||||
let output_json: serde_json::Value = serde_json::from_str(&result.output).unwrap();
|
||||
assert_eq!(output_json["row_count"], 2);
|
||||
assert_eq!(output_json["renderer"], "openxml_office");
|
||||
assert!(!output_json["output_path"].as_str().unwrap().is_empty());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
let xml = read_sheet_xml(&output_path);
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains("问题二"));
|
||||
@@ -75,17 +77,7 @@ async fn openxml_office_tool_accepts_reordered_columns_when_rows_are_structured(
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
let xml = read_sheet_xml(&output_path);
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains(">1<"));
|
||||
@@ -113,17 +105,7 @@ async fn openxml_office_tool_accepts_localized_hotlist_column_aliases() {
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
let xml = read_sheet_xml(&output_path);
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains(">1<"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,17 +43,9 @@ async fn screen_html_export_tool_renders_dashboard_html_with_presentation_contra
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("file://"));
|
||||
assert!(html.contains("知乎热榜态势驾驶舱"));
|
||||
assert!(html.contains("snapshot-20260329"));
|
||||
assert!(html.contains("问题一"));
|
||||
assert!(html.contains("344万"));
|
||||
assert!(html.contains("const defaultPayload ="));
|
||||
assert!(html.contains("汇报摘要"));
|
||||
assert!(html.contains("fitScreenToViewport"));
|
||||
assert!(html.contains("dashboard-canvas"));
|
||||
assert!(html.contains("themeSwitcher"));
|
||||
assert!(html.contains("gov_blue_gold"));
|
||||
assert!(html.contains("tech_cyan_blue"));
|
||||
assert!(html.contains("industry_ink_green"));
|
||||
assert!(html.contains("meeting_red_gold"));
|
||||
assert!(html.contains("localStorage.setItem(\"zhihu-hotlist-theme\""));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fn deepseek_settings_load_defaults_from_env() {
|
||||
assert_eq!(settings.api_key, "test-key");
|
||||
assert_eq!(settings.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(settings.model, "deepseek-chat");
|
||||
assert!(settings.skills_dir.is_empty());
|
||||
assert_eq!(settings.skills_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,7 +30,7 @@ fn deepseek_request_shape_matches_openai_compatible_chat_format() {
|
||||
api_key: "test-key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
});
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
|
||||
452
tests/deterministic_submit_test.rs
Normal file
452
tests/deterministic_submit_test.rs
Normal file
@@ -0,0 +1,452 @@
|
||||
mod common;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{Datelike, Local};
|
||||
use zeroclaw::skills::load_skills_from_directory;
|
||||
|
||||
use sgclaw::compat::deterministic_submit::{
|
||||
decide_deterministic_submit, DeterministicSubmitDecision,
|
||||
};
|
||||
use sgclaw::compat::tq_lineloss::{
|
||||
contracts::{PeriodMode, ResolvedOrg, ResolvedPeriod},
|
||||
org_resolver::resolve_org,
|
||||
period_resolver::resolve_period,
|
||||
};
|
||||
use sgclaw::runtime::is_zhihu_hotlist_task;
|
||||
|
||||
fn expected_default_month() -> String {
|
||||
let today = Local::now().date_naive();
|
||||
let (year, month) = if today.month() == 1 {
|
||||
(today.year() - 1, 12)
|
||||
} else {
|
||||
(today.year(), today.month() - 1)
|
||||
};
|
||||
format!("{year}-{month:02}")
|
||||
}
|
||||
|
||||
fn expected_default_week_range() -> (String, String, String) {
|
||||
let today = Local::now().date_naive();
|
||||
let month_start = today.with_day(1).expect("current month should have day 1");
|
||||
let start = month_start.format("%Y-%m-%d").to_string();
|
||||
let end = today.format("%Y-%m-%d").to_string();
|
||||
(format!("{start}至{end}"), start, end)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_discovers_tq_lineloss_skill_contract() {
|
||||
let skills_root = PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills");
|
||||
let skills = load_skills_from_directory(&skills_root, true);
|
||||
|
||||
let skill = skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "tq-lineloss-report")
|
||||
.expect("tq-lineloss-report should be discoverable from staged skills root");
|
||||
|
||||
let tool = skill
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "collect_lineloss")
|
||||
.expect("collect_lineloss tool should be discoverable");
|
||||
|
||||
assert_eq!(tool.kind, "browser_script");
|
||||
assert_eq!(tool.command, "scripts/collect_lineloss.js");
|
||||
|
||||
let required_args = [
|
||||
"expected_domain",
|
||||
"org_label",
|
||||
"org_code",
|
||||
"period_mode",
|
||||
"period_mode_code",
|
||||
"period_value",
|
||||
"period_payload",
|
||||
];
|
||||
|
||||
for arg in required_args {
|
||||
assert!(
|
||||
tool.args.contains_key(arg),
|
||||
"expected required arg {arg} in tq-lineloss-report.collect_lineloss"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(tool.args.len(), required_args.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_requires_exact_suffix() {
|
||||
assert!(matches!(
|
||||
decide_deterministic_submit("兰州公司 月累计 2026-03。。。", None, None),
|
||||
DeterministicSubmitDecision::Execute(_)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
decide_deterministic_submit("兰州公司 月累计 2026-03", None, None),
|
||||
DeterministicSubmitDecision::NotDeterministic
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_nonmatch_returns_supported_scene_message() {
|
||||
let decision = decide_deterministic_submit("帮我打开百度。。。", None, None);
|
||||
|
||||
match decision {
|
||||
DeterministicSubmitDecision::Prompt { summary } => {
|
||||
assert!(summary.contains("台区线损") || summary.contains("支持场景"));
|
||||
}
|
||||
other => panic!("expected deterministic prompt for unsupported scene, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_rejects_page_context_mismatch() {
|
||||
let decision = decide_deterministic_submit(
|
||||
"兰州公司 月累计 2026-03。。。",
|
||||
Some("https://www.zhihu.com/hot"),
|
||||
Some("知乎热榜"),
|
||||
);
|
||||
|
||||
match decision {
|
||||
DeterministicSubmitDecision::Prompt { summary } => {
|
||||
assert!(summary.contains("台区线损") || summary.contains("页面") || summary.contains("不匹配"));
|
||||
}
|
||||
other => panic!("expected deterministic mismatch prompt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_hotlist_request_without_suffix_keeps_existing_route() {
|
||||
assert!(is_zhihu_hotlist_task(
|
||||
"打开知乎热榜",
|
||||
Some("https://www.zhihu.com/hot"),
|
||||
Some("知乎热榜")
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
decide_deterministic_submit(
|
||||
"打开知乎热榜",
|
||||
Some("https://www.zhihu.com/hot"),
|
||||
Some("知乎热榜")
|
||||
),
|
||||
DeterministicSubmitDecision::NotDeterministic
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_rejects_non_exact_suffix_variants() {
|
||||
for instruction in [
|
||||
"兰州公司 月累计 2026-03...",
|
||||
"兰州公司 月累计 2026-03。。。。",
|
||||
"兰州公司。。。月累计 2026-03",
|
||||
"兰州公司 月累计 2026-03。。。 ",
|
||||
] {
|
||||
assert!(matches!(
|
||||
decide_deterministic_submit(instruction, None, None),
|
||||
DeterministicSubmitDecision::NotDeterministic
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_org_resolver_matches_city_alias() {
|
||||
assert_eq!(
|
||||
resolve_org("兰州公司").unwrap(),
|
||||
ResolvedOrg {
|
||||
label: "国网兰州供电公司".to_string(),
|
||||
code: "62401".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_org("天水公司").unwrap(),
|
||||
ResolvedOrg {
|
||||
label: "国网天水供电公司".to_string(),
|
||||
code: "62403".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_org_resolver_matches_county_alias() {
|
||||
assert_eq!(
|
||||
resolve_org("榆中县公司").unwrap(),
|
||||
ResolvedOrg {
|
||||
label: "国网榆中县供电公司".to_string(),
|
||||
code: "6240121".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_org("城关供电分公司").unwrap(),
|
||||
ResolvedOrg {
|
||||
label: "城关供电分公司".to_string(),
|
||||
code: "6240108".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_org_resolver_prompts_on_ambiguity() {
|
||||
let summary = resolve_org("城关")
|
||||
.expect_err("ambiguous alias should prompt instead of guessing");
|
||||
|
||||
assert!(summary.contains("供电单位存在歧义") || summary.contains("更完整名称"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_submit_lineloss_missing_company_prompts() {
|
||||
let decision = decide_deterministic_submit("月累计 2026-03。。。", None, None);
|
||||
|
||||
match decision {
|
||||
DeterministicSubmitDecision::Prompt { summary } => {
|
||||
assert!(summary.contains("缺少供电单位") || summary.contains("兰州公司"));
|
||||
}
|
||||
other => panic!("expected missing-company prompt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_parses_month_text() {
|
||||
assert_eq!(
|
||||
resolve_period("月累计 2026-03").unwrap(),
|
||||
ResolvedPeriod {
|
||||
mode: PeriodMode::Month,
|
||||
mode_code: "1".to_string(),
|
||||
value: "2026-03".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"fdate": "2026-03",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_period("月累计 2026年3月").unwrap(),
|
||||
ResolvedPeriod {
|
||||
mode: PeriodMode::Month,
|
||||
mode_code: "1".to_string(),
|
||||
value: "2026-03".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"fdate": "2026-03",
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_parses_week_text() {
|
||||
let resolved = resolve_period("周累计 2026年第12周").unwrap();
|
||||
|
||||
assert_eq!(resolved.mode, PeriodMode::Week);
|
||||
assert_eq!(resolved.mode_code, "2");
|
||||
assert_eq!(resolved.value, "2026-W12");
|
||||
assert_eq!(resolved.payload["tjzq"], "week");
|
||||
assert_eq!(resolved.payload["level"], "00");
|
||||
assert_eq!(resolved.payload["weekSfdate"], "2026-03-16");
|
||||
assert_eq!(resolved.payload["weekEfdate"], "2026-03-22");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_defaults_month_period_from_page_semantics() {
|
||||
let expected_month = expected_default_month();
|
||||
|
||||
assert_eq!(
|
||||
resolve_period("兰州公司 月累计").unwrap(),
|
||||
ResolvedPeriod {
|
||||
mode: PeriodMode::Month,
|
||||
mode_code: "1".to_string(),
|
||||
value: expected_month.clone(),
|
||||
payload: serde_json::json!({
|
||||
"fdate": expected_month,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_defaults_week_period_from_page_semantics() {
|
||||
let (expected_value, expected_start, expected_end) = expected_default_week_range();
|
||||
|
||||
assert_eq!(
|
||||
resolve_period("兰州公司 周累计").unwrap(),
|
||||
ResolvedPeriod {
|
||||
mode: PeriodMode::Week,
|
||||
mode_code: "2".to_string(),
|
||||
value: expected_value,
|
||||
payload: serde_json::json!({
|
||||
"tjzq": "week",
|
||||
"level": "00",
|
||||
"weekSfdate": expected_start,
|
||||
"weekEfdate": expected_end,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_prompts_for_missing_year_on_week() {
|
||||
let summary = resolve_period("周累计 第12周")
|
||||
.expect_err("bare week should prompt for year instead of guessing");
|
||||
|
||||
assert!(summary.contains("年份") || summary.contains("第12周"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_rejects_contradictory_mode() {
|
||||
let summary = resolve_period("月累计 周累计 2026-03")
|
||||
.expect_err("contradictory month/week intent should not execute");
|
||||
|
||||
assert!(summary.contains("月/周") || summary.contains("冲突") || summary.contains("歧义"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_prompts_for_missing_mode() {
|
||||
let summary = resolve_period("兰州公司 2026-03")
|
||||
.expect_err("missing mode should prompt instead of guessing");
|
||||
|
||||
assert!(summary.contains("月/周类型") || summary.contains("月累计") || summary.contains("周累计"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineloss_period_resolver_prompts_for_missing_period() {
|
||||
let summary = resolve_period("兰州公司 月累计")
|
||||
.expect_err("missing period should prompt instead of guessing");
|
||||
|
||||
assert!(summary.contains("周期") || summary.contains("时间") || summary.contains("2026-03"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_execution_plan_contains_canonical_args() {
|
||||
let decision = decide_deterministic_submit(
|
||||
"兰州公司 月累计 2026-03。。。",
|
||||
Some("http://20.76.57.61:8080/#/lineloss"),
|
||||
Some("台区线损报表"),
|
||||
);
|
||||
|
||||
match decision {
|
||||
DeterministicSubmitDecision::Execute(plan) => {
|
||||
let debug = format!("{plan:?}");
|
||||
assert!(debug.contains("国网兰州供电公司"), "missing canonical org label: {debug}");
|
||||
assert!(debug.contains("62401"), "missing canonical org code: {debug}");
|
||||
assert!(debug.contains("2026-03"), "missing canonical period value: {debug}");
|
||||
assert!(debug.contains("month") || debug.contains("Month"), "missing canonical month mode: {debug}");
|
||||
assert!(debug.contains("fdate"), "missing canonical month payload: {debug}");
|
||||
}
|
||||
other => panic!("expected deterministic execute plan, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_missing_period_uses_default_month_execution_plan() {
|
||||
let expected_month = expected_default_month();
|
||||
let decision = decide_deterministic_submit("兰州公司 月累计。。。", None, None);
|
||||
|
||||
match decision {
|
||||
DeterministicSubmitDecision::Execute(plan) => {
|
||||
assert_eq!(plan.period_mode, "month");
|
||||
assert_eq!(plan.period_mode_code, "1");
|
||||
assert_eq!(plan.period_value, expected_month);
|
||||
assert!(plan.period_payload.contains("fdate"));
|
||||
}
|
||||
other => panic!("expected missing month period to default into execution, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_missing_period_uses_default_week_execution_plan() {
|
||||
let (expected_value, expected_start, expected_end) = expected_default_week_range();
|
||||
let decision = decide_deterministic_submit("兰州公司 周累计。。。", None, None);
|
||||
|
||||
match decision {
|
||||
DeterministicSubmitDecision::Execute(plan) => {
|
||||
assert_eq!(plan.period_mode, "week");
|
||||
assert_eq!(plan.period_mode_code, "2");
|
||||
assert_eq!(plan.period_value, expected_value);
|
||||
assert!(plan.period_payload.contains(&expected_start));
|
||||
assert!(plan.period_payload.contains(&expected_end));
|
||||
}
|
||||
other => panic!("expected missing week period to default into execution, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_partial_artifact_summary_contract_is_locked() {
|
||||
let artifact = serde_json::json!({
|
||||
"type": "report-artifact",
|
||||
"report_name": "tq-lineloss-report",
|
||||
"status": "partial",
|
||||
"org": {
|
||||
"label": "国网兰州供电公司",
|
||||
"code": "62401"
|
||||
},
|
||||
"period": {
|
||||
"mode": "month",
|
||||
"mode_code": "1",
|
||||
"value": "2026-03",
|
||||
"payload": {
|
||||
"fdate": "2026-03"
|
||||
}
|
||||
},
|
||||
"columns": ["ORG_NAME", "LINE_LOSS_RATE"],
|
||||
"rows": [
|
||||
{ "ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "3.21" }
|
||||
],
|
||||
"counts": {
|
||||
"rows": 1
|
||||
},
|
||||
"export": {
|
||||
"attempted": true,
|
||||
"status": "failed",
|
||||
"message": "report_log_failed"
|
||||
},
|
||||
"reasons": ["report_log_failed"]
|
||||
});
|
||||
|
||||
assert_eq!(artifact["type"], "report-artifact");
|
||||
assert_eq!(artifact["report_name"], "tq-lineloss-report");
|
||||
assert_eq!(artifact["status"], "partial");
|
||||
assert_eq!(artifact["org"]["label"], "国网兰州供电公司");
|
||||
assert_eq!(artifact["period"]["value"], "2026-03");
|
||||
assert_eq!(artifact["counts"]["rows"], 1);
|
||||
assert_eq!(artifact["reasons"][0], "report_log_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_lineloss_blocked_and_error_artifact_statuses_are_failure_contracts() {
|
||||
for status in ["blocked", "error"] {
|
||||
let artifact = serde_json::json!({
|
||||
"type": "report-artifact",
|
||||
"report_name": "tq-lineloss-report",
|
||||
"status": status,
|
||||
"org": {
|
||||
"label": "国网兰州供电公司",
|
||||
"code": "62401"
|
||||
},
|
||||
"period": {
|
||||
"mode": "week",
|
||||
"mode_code": "2",
|
||||
"value": "2026-W12",
|
||||
"payload": {
|
||||
"tjzq": "week",
|
||||
"level": "00",
|
||||
"weekSfdate": "2026-03-16",
|
||||
"weekEfdate": "2026-03-22"
|
||||
}
|
||||
},
|
||||
"columns": [],
|
||||
"rows": [],
|
||||
"counts": {
|
||||
"rows": 0
|
||||
},
|
||||
"export": {
|
||||
"attempted": false,
|
||||
"status": "skipped",
|
||||
"message": null
|
||||
},
|
||||
"reasons": ["selected_range_unavailable"]
|
||||
});
|
||||
|
||||
assert_eq!(artifact["status"], status);
|
||||
assert_eq!(artifact["type"], "report-artifact");
|
||||
assert_eq!(artifact["period"]["mode"], "week");
|
||||
assert_eq!(artifact["reasons"][0], "selected_range_unavailable");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user