Compare commits
6 Commits
81de162756
...
883647dffc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883647dffc | ||
|
|
b454fa3f54 | ||
|
|
311cc1fee6 | ||
|
|
7443b9da7f | ||
|
|
34035cdc9c | ||
|
|
4becf81066 |
502
Cargo.lock
generated
502
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]
|
||||
@@ -19,5 +19,5 @@ 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"
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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`.
|
||||
551
docs/superpowers/plans/2026-04-11-main-into-ws-merge-v2-plan.md
Normal file
551
docs/superpowers/plans/2026-04-11-main-into-ws-merge-v2-plan.md
Normal file
@@ -0,0 +1,551 @@
|
||||
# Main → WS Merge v2 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:** 把最新 `origin/main` 合并到 `feature/claw-ws`,让 `ws` 分支最终同时保留 **pipe + ws** 两套通信能力、保留 Zhihu 行为,并用 `main` 上正式的 fault-details 实现替换 `ws` 上已 cleanup 删除的旧重复实现。
|
||||
|
||||
**Architecture:** 这次合并不是“把 cleanup 永久保持成没有 fault-details”,而是“先删除 ws 上旧重复实现,再吸收 main 上正式实现”。冲突裁决优先级是:**先保 pipe、再保 ws、再保 Zhihu、同时拒绝 ws 上旧重复 scene/fault-details 实现回流**。整个过程使用 `git merge --no-commit --no-ff origin/main`,冲突解决后只做聚焦验证,停在未提交状态。
|
||||
|
||||
**Tech Stack:** Git, Rust 2021, Cargo test, sgClaw pipe transport, ws transport, compat/runtime/orchestration stack, Zhihu direct workflow tests.
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
- 当前分支必须是 `feature/claw-ws`
|
||||
- `2026-04-09-ws-branch-scene-cleanup-plan.md` 已完成
|
||||
- 当前不在 merge 状态
|
||||
- 当前没有 tracked 未提交改动
|
||||
- 本次**不创建 worktree**,按当前仓库执行
|
||||
- 本次结束点是:**已合并、已验证、未提交**
|
||||
|
||||
---
|
||||
|
||||
## Final Merge Principles
|
||||
|
||||
### 1) `main` 是 pipe 主线
|
||||
合并后不能把 `main` 上现有的 pipe 管道通信破坏掉。
|
||||
|
||||
### 2) `ws` 分支最终要同时保留 pipe + ws
|
||||
合并后不能让 `ws` 分支丢掉 websocket 路径,也不能只剩 pipe。
|
||||
|
||||
### 3) 两边都有 Zhihu
|
||||
合并后不能把现有 Zhihu 行为合坏,尤其是 ws→Zhihu 保留路径。
|
||||
|
||||
### 4) fault-details 以 `main` 正式实现为准
|
||||
- `ws` 上那套旧重复实现:**不能回流**
|
||||
- `main` 上正式实现:**应被合进来**
|
||||
- 最终结果不是“没有 fault-details”,而是“没有 ws 那套旧 fault-details,只保留 main 正式版本”
|
||||
|
||||
### 5) 不回流旧 scene plumbing
|
||||
以下旧面不能作为最终结果保留:
|
||||
- ws 自己那套旧 scene registry / old scene plumbing
|
||||
- ws cleanup 已删掉的旧重复 route/contract
|
||||
- 仅为旧 `skill_staging` 场景装配服务的残留逻辑
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### A. 合并时重点观察的共享/高风险文件
|
||||
- `Cargo.toml`
|
||||
- `Cargo.lock`
|
||||
- `src/agent/mod.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/compat/config_adapter.rs`
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/direct_skill_runtime.rs`
|
||||
- `src/compat/openxml_office_tool.rs`
|
||||
|
||||
### B. pipe / ws / Zhihu 保护面
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `tests/agent_runtime_test.rs`
|
||||
- `tests/browser_ws_backend_test.rs`
|
||||
- `tests/service_ws_session_test.rs`
|
||||
- `tests/task_runner_test.rs`
|
||||
|
||||
### C. cleanup 后仍需防止旧实现回流的文件
|
||||
- `src/runtime/mod.rs`
|
||||
- `src/runtime/engine.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/compat/config_adapter.rs`
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- `tests/runtime_profile_test.rs`
|
||||
- `tests/compat_config_test.rs`
|
||||
|
||||
### D. 可能需要随 main 正式 fault-details 一起更新的测试面
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- `tests/compat_config_test.rs`
|
||||
- `tests/browser_script_skill_tool_test.rs`
|
||||
- `tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution Rule Table
|
||||
|
||||
| 类别 | 最终保留原则 |
|
||||
|---|---|
|
||||
| pipe 主路径 | **优先保留可工作的 main 版本**,不能被 ws 改坏 |
|
||||
| ws 路径 | **必须继续保留 ws 能力**,不能因吸收 main 而丢失 |
|
||||
| Zhihu | 两边相关能力都不能合坏,至少保住现有 keep-path |
|
||||
| fault-details | **保留 main 正式实现**,不保留 ws 旧重复实现 |
|
||||
| old scene/95598 cleanup 残留 | 不允许以 ws 旧重复实现形式回流 |
|
||||
| `skillsDir` / config | 以最终产品需要为准;若 main 正式实现不要求旧 array-style/scene expansion,则不回流 |
|
||||
| 临时 merge 修补 | 一律不保留 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Confirm Merge Preconditions And Diff Surface
|
||||
|
||||
**Files:**
|
||||
- No code changes expected
|
||||
- Observe repo state and branch diff only
|
||||
|
||||
- [ ] **Step 1: Confirm current branch**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
Expected:
|
||||
```text
|
||||
feature/claw-ws
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm no merge is in progress**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git rev-parse -q --verify MERGE_HEAD
|
||||
```
|
||||
|
||||
Expected: exit code `1`.
|
||||
|
||||
- [ ] **Step 3: Confirm no tracked local changes**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --name-only && printf '\n---STAGED---\n' && git diff --cached --name-only
|
||||
```
|
||||
|
||||
Expected:
|
||||
```text
|
||||
|
||||
---STAGED---
|
||||
```
|
||||
|
||||
- [ ] **Step 4: List current untracked files**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: only known local untracked items, or a clearly understood list.
|
||||
|
||||
- [ ] **Step 5: Update `origin/main`**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git fetch origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Show ws vs main diff surface before merge**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --name-status HEAD...origin/main
|
||||
```
|
||||
|
||||
Expected: clear file list to compare likely merge surface.
|
||||
|
||||
- [ ] **Step 7: Stop if preconditions fail**
|
||||
|
||||
Stop if:
|
||||
- branch is wrong
|
||||
- merge is in progress
|
||||
- tracked changes exist
|
||||
- untracked file collision with `origin/main` is found and unresolved
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Start The Merge Without Committing
|
||||
|
||||
**Files:**
|
||||
- Merge index / working tree only
|
||||
|
||||
- [ ] **Step 1: Start no-commit merge**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git merge --no-commit --no-ff origin/main
|
||||
```
|
||||
|
||||
Expected:
|
||||
- either auto-merge pauses before commit
|
||||
- or Git reports conflicts
|
||||
|
||||
- [ ] **Step 2: Capture merge surface immediately**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Separate results into three buckets**
|
||||
Create a working list of conflicted files under:
|
||||
1. pipe-critical
|
||||
2. ws/Zhihu-critical
|
||||
3. shared infra / tests
|
||||
|
||||
- [ ] **Step 4: If no conflicts, proceed directly to Task 4 verification**
|
||||
|
||||
- [ ] **Step 5: If conflicts exist, proceed to Task 3**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Resolve Conflicts By System Role, Not By Branch Bias
|
||||
|
||||
**Files:**
|
||||
- Only files reported by Git as conflicted
|
||||
|
||||
#### Global conflict policy
|
||||
For every conflicted hunk, answer these four questions in order:
|
||||
|
||||
1. Does this hunk affect **pipe** correctness?
|
||||
2. Does this hunk affect **ws** correctness?
|
||||
3. Does this hunk affect **Zhihu** correctness?
|
||||
4. Is this hunk part of **ws old duplicate fault-details/scene logic** or **main official implementation**?
|
||||
|
||||
Then apply the rule:
|
||||
- **pipe cannot break**
|
||||
- **ws cannot break**
|
||||
- **Zhihu cannot break**
|
||||
- **ws old duplicate fault-details must stay deleted**
|
||||
- **main official fault-details should come in**
|
||||
|
||||
---
|
||||
|
||||
#### Task 3A: Resolve pipe-critical shared runtime files
|
||||
|
||||
**Files:**
|
||||
- `src/compat/runtime.rs`
|
||||
- `src/agent/task_runner.rs`
|
||||
- `src/agent/mod.rs`
|
||||
- `src/config/settings.rs`
|
||||
- `src/compat/config_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: For each conflict, keep the side that preserves main’s pipe behavior**
|
||||
|
||||
- [ ] **Step 2: Reject ws-only duplicate business logic that main already owns**
|
||||
|
||||
- [ ] **Step 3: Keep ws support if the file also serves ws path**
|
||||
This is additive preservation, not “main wins everything”.
|
||||
|
||||
- [ ] **Step 4: Verify each resolved file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3B: Resolve ws / Zhihu-critical routing files
|
||||
|
||||
**Files:**
|
||||
- `src/compat/workflow_executor.rs`
|
||||
- `src/compat/orchestration.rs`
|
||||
|
||||
- [ ] **Step 1: Bring in main’s official fault-details path if it lives here**
|
||||
|
||||
- [ ] **Step 2: Do not reintroduce ws’s old duplicate fault-details path**
|
||||
|
||||
- [ ] **Step 3: Preserve ws submit/browser websocket path**
|
||||
|
||||
- [ ] **Step 4: Preserve Zhihu routing path**
|
||||
|
||||
- [ ] **Step 5: Verify each resolved file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3C: Resolve shared infra files minimally
|
||||
|
||||
**Files:**
|
||||
- `Cargo.toml`
|
||||
- `Cargo.lock`
|
||||
- `src/compat/browser_script_skill_tool.rs`
|
||||
- `src/compat/direct_skill_runtime.rs`
|
||||
- `src/compat/openxml_office_tool.rs`
|
||||
|
||||
- [ ] **Step 1: Keep only the dependency/code shape needed by the merged result**
|
||||
|
||||
- [ ] **Step 2: Do not keep lines from prior failed merge attempts**
|
||||
|
||||
- [ ] **Step 3: Accept main fixes unless they break pipe/ws/Zhihu behavior**
|
||||
|
||||
- [ ] **Step 4: Verify each resolved file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3D: Resolve tests to reflect final intended product
|
||||
|
||||
**Files:**
|
||||
- `tests/compat_runtime_test.rs`
|
||||
- `tests/runtime_profile_test.rs`
|
||||
- `tests/compat_config_test.rs`
|
||||
- `tests/agent_runtime_test.rs`
|
||||
- `tests/browser_script_skill_tool_test.rs`
|
||||
- `tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
- [ ] **Step 1: Keep tests proving pipe path still works**
|
||||
|
||||
- [ ] **Step 2: Keep tests proving ws path still works**
|
||||
|
||||
- [ ] **Step 3: Keep Zhihu keep-path regression**
|
||||
|
||||
- [ ] **Step 4: Replace cleanup-only “fault-details absent” assertions if final intended state is now “fault-details present via main official implementation”**
|
||||
|
||||
- [ ] **Step 5: Do not keep assertions that only prove ws’s old duplicate implementation is absent if they now contradict the intended merged product**
|
||||
|
||||
- [ ] **Step 6: Verify each resolved test file has no conflict markers**
|
||||
|
||||
Run per file:
|
||||
```bash
|
||||
git diff --check -- <path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3E: Confirm merge is fully resolved
|
||||
|
||||
**Files:**
|
||||
- No code changes expected
|
||||
|
||||
- [ ] **Step 1: Confirm no unmerged entries remain**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --name-only --diff-filter=U
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 2: Show final resolved file list**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --cached --name-only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify Final Product Behavior, Not Cleanup Intermediate State
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/agent_runtime_test.rs`
|
||||
- Test: `tests/browser_ws_backend_test.rs`
|
||||
- Test: `tests/service_ws_session_test.rs`
|
||||
- Test: `tests/task_runner_test.rs`
|
||||
- Test: `tests/compat_runtime_test.rs`
|
||||
- Test: `tests/runtime_profile_test.rs`
|
||||
- Test: `tests/compat_config_test.rs`
|
||||
- Conditional: `tests/browser_script_skill_tool_test.rs`
|
||||
- Conditional: `tests/compat_openxml_office_tool_test.rs`
|
||||
|
||||
#### Verification goals
|
||||
This task must prove all four:
|
||||
|
||||
1. **pipe path still works**
|
||||
2. **ws path still works**
|
||||
3. **Zhihu still works**
|
||||
4. **final fault-details implementation is the main version, not ws’s old duplicate**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4A: Verify pipe-related behavior
|
||||
|
||||
- [ ] **Step 1: Run task runner coverage**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test task_runner_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run compat runtime suite relevant to main path**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If pipe-specific tests fail, stop and fix merge resolution before continuing**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4B: Verify ws-related behavior
|
||||
|
||||
- [ ] **Step 1: Run browser websocket backend suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_ws_backend_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run service websocket session suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test service_ws_session_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If ws-specific tests fail, stop and fix merge resolution before continuing**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4C: Verify Zhihu behavior
|
||||
|
||||
- [ ] **Step 1: Re-run ws→Zhihu keep-path regression**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test agent_runtime_test production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
```text
|
||||
1 passed; 0 failed
|
||||
```
|
||||
|
||||
- [ ] **Step 2: If additional Zhihu tests were touched by conflicts, run the smallest affected test target**
|
||||
|
||||
Run as needed:
|
||||
```bash
|
||||
cargo test --test agent_runtime_test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 4D: Verify config/runtime contracts
|
||||
|
||||
- [ ] **Step 1: Run runtime profile suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test runtime_profile_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run compat config suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_config_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Ensure contracts now reflect final merged product, not the cleanup-only intermediate**
|
||||
|
||||
---
|
||||
|
||||
#### Task 4E: Verify shared infra if touched
|
||||
|
||||
- [ ] **Step 1: If browser-script tool files were touched**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test browser_script_skill_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
- [ ] **Step 2: If openxml files were touched**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --test compat_openxml_office_tool_test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 4F: Compile guard
|
||||
|
||||
- [ ] **Step 1: Run compile-only full test build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cargo test --no-run
|
||||
```
|
||||
|
||||
Expected: exit code `0`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Confirm The Merge Outcome Matches The Principle
|
||||
|
||||
**Files:**
|
||||
- No code changes expected
|
||||
|
||||
- [ ] **Step 1: Show final status**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `UU` / `AA` / `DD`
|
||||
- merged, validated, uncommitted state only
|
||||
|
||||
- [ ] **Step 2: Show final staged summary**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Report the four required facts with command-backed evidence**
|
||||
Only if verified:
|
||||
1. pipe 没坏
|
||||
2. ws 没坏
|
||||
3. Zhihu 没坏
|
||||
4. 最终 fault-details 来自 main 正式实现,而不是 ws 旧重复实现
|
||||
|
||||
- [ ] **Step 4: Stop here**
|
||||
Do **not** run:
|
||||
```bash
|
||||
git commit
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
出现以下任一情况立即停止,不擅自扩展处理:
|
||||
|
||||
- `origin/main` 的正式 fault-details 实现依赖 cleanup 已删掉的契约,而这已经超出简单 merge 范围
|
||||
- pipe 与 ws 同时依赖同一段共享代码,但两边要求已结构性冲突
|
||||
- Zhihu keep-path 失败
|
||||
- `cargo test --no-run` 失败且问题超出本次 merge surface
|
||||
- 需要重新设计 pipe/ws 共存方式,而不是单纯合并
|
||||
|
||||
---
|
||||
|
||||
## One-line Execution Rule
|
||||
|
||||
**这次 merge 的最终标准不是“继续保持 ws 没有 fault-details”,而是“保住 pipe、保住 ws、保住 Zhihu,并让 main 的正式 fault-details 替换 ws 旧重复实现”。**
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
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>
|
||||
@@ -95,8 +95,18 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
page_url: normalize_optional_submit_field(page_url),
|
||||
page_title: normalize_optional_submit_field(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)
|
||||
if configured_browser_ws_url(context).is_some() {
|
||||
let browser_backend = browser_backend_for_submit(browser_tool, context, &request)?;
|
||||
run_submit_task_with_browser_backend(
|
||||
transport,
|
||||
transport,
|
||||
browser_backend,
|
||||
context,
|
||||
request,
|
||||
)
|
||||
} else {
|
||||
run_submit_task(transport, transport, browser_tool, context, request)
|
||||
}
|
||||
}
|
||||
BrowserMessage::Init { .. } => {
|
||||
eprintln!("ignoring duplicate init after handshake");
|
||||
|
||||
@@ -176,7 +176,7 @@ pub fn run_submit_task<T: Transport + 'static>(
|
||||
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dirs =
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
@@ -189,7 +189,7 @@ pub fn run_submit_task<T: Transport + 'static>(
|
||||
});
|
||||
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(", ")),
|
||||
message: format!("skills dir resolved to {}", resolved_skills_dir.display()),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
@@ -198,6 +198,37 @@ pub fn run_submit_task<T: Transport + 'static>(
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if settings.direct_submit_skill.is_some() {
|
||||
match crate::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
let _ = send_mode_log(sink, "direct_skill_primary");
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: outcome.success,
|
||||
summary: outcome.summary,
|
||||
});
|
||||
}
|
||||
Err(PipeError::Protocol(message))
|
||||
if message.contains("must use skill.tool format") =>
|
||||
{
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: message,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return sink.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
||||
&& crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
@@ -310,7 +341,7 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
||||
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dirs =
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
@@ -323,7 +354,7 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
||||
});
|
||||
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(", ")),
|
||||
message: format!("skills dir resolved to {}", resolved_skills_dir.display()),
|
||||
});
|
||||
let _ = sink.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
|
||||
@@ -667,7 +667,7 @@ fn normalize_callback_result(
|
||||
}))
|
||||
}
|
||||
"eval" if result.callback == EVAL_CALLBACK_NAME => {
|
||||
let value = result.payload.get("value").and_then(Value::as_str)?;
|
||||
let value = result.payload.get("value")?.clone();
|
||||
Some(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
|
||||
success: true,
|
||||
data: json!({ "text": value }),
|
||||
@@ -1403,4 +1403,36 @@ mod tests {
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_callback_result_path_a_eval_accepts_structured_value_payload() {
|
||||
let request = make_request("eval");
|
||||
let result = CallbackResult {
|
||||
callback: "sgclawOnEval".to_string(),
|
||||
request_url: "http://127.0.0.1:17888/sgclaw/browser-helper.html".to_string(),
|
||||
target_url: Some("https://www.zhihu.com/hot".to_string()),
|
||||
action: Some("sgBrowserExcuteJsCodeByDomain".to_string()),
|
||||
payload: json!({
|
||||
"value": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
let response = normalize_callback_result(&request, result, Duration::from_millis(10));
|
||||
assert!(response.is_some(), "Path A eval should accept structured values");
|
||||
match response.unwrap() {
|
||||
super::super::callback_backend::BrowserCallbackResponse::Success(s) => {
|
||||
assert_eq!(
|
||||
s.data.get("text").unwrap(),
|
||||
&json!({
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
})
|
||||
);
|
||||
}
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,47 +12,15 @@ use zeroclaw::tools::{Tool, ToolResult};
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::Action;
|
||||
|
||||
pub struct BrowserScriptInvocation<'a> {
|
||||
pub tool: &'a SkillTool,
|
||||
pub skill_root: &'a Path,
|
||||
}
|
||||
|
||||
pub struct BrowserScriptSkillTool {
|
||||
tool_name: String,
|
||||
tool_description: String,
|
||||
tool: SkillTool,
|
||||
skill_root: PathBuf,
|
||||
script_path: PathBuf,
|
||||
args: HashMap<String, String>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
}
|
||||
|
||||
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 {
|
||||
pub fn new(
|
||||
skill_name: &str,
|
||||
@@ -60,14 +28,13 @@ impl BrowserScriptSkillTool {
|
||||
skill_root: &Path,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
) -> 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,
|
||||
})
|
||||
@@ -119,12 +86,15 @@ 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,
|
||||
)
|
||||
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(),
|
||||
};
|
||||
|
||||
execute_browser_script_tool(&tool, &self.skill_root, self.browser_tool.as_ref(), args).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,20 +135,26 @@ pub fn build_browser_script_skill_tools(
|
||||
pub async fn execute_browser_script_tool(
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
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
|
||||
)));
|
||||
}
|
||||
|
||||
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>,
|
||||
browser_tool: &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,
|
||||
@@ -263,6 +239,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(),
|
||||
|
||||
@@ -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,23 @@ 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 {
|
||||
settings
|
||||
.skills_dir
|
||||
.as_deref()
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
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 {
|
||||
settings
|
||||
.skills_dir
|
||||
.as_deref()
|
||||
.map(normalize_configured_skills_dir)
|
||||
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
|
||||
}
|
||||
|
||||
fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
|
||||
@@ -138,13 +119,3 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
341
src/compat/direct_skill_runtime.rs
Normal file
341
src/compat/direct_skill_runtime.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
use std::path::Path;
|
||||
|
||||
use reqwest::Url;
|
||||
use serde_json::{Map, Value};
|
||||
use zeroclaw::skills::load_skills_from_directory;
|
||||
|
||||
use crate::browser::PipeBrowserBackend;
|
||||
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 (skill_name, tool_name) = parse_configured_tool_name(configured_tool)?;
|
||||
let expected_domain = derive_expected_domain(task_context)?;
|
||||
let period = derive_period(instruction)?;
|
||||
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"
|
||||
))
|
||||
})?;
|
||||
|
||||
if tool.kind != "browser_script" {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"direct submit tool {configured_tool} must be browser_script, got {}",
|
||||
tool.kind
|
||||
)));
|
||||
}
|
||||
|
||||
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"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert("expected_domain".to_string(), Value::String(expected_domain));
|
||||
args.insert("period".to_string(), Value::String(period));
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let result = runtime
|
||||
.block_on(execute_browser_script_tool(
|
||||
tool,
|
||||
skill_root,
|
||||
&browser_backend,
|
||||
Value::Object(args),
|
||||
))
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
|
||||
if result.success {
|
||||
Ok(interpret_direct_submit_output(&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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod browser_script_skill_tool;
|
||||
pub mod browser_tool_adapter;
|
||||
pub mod config_adapter;
|
||||
pub mod cron_adapter;
|
||||
pub mod direct_skill_runtime;
|
||||
pub mod event_bridge;
|
||||
pub mod memory_adapter;
|
||||
pub mod openxml_office_tool;
|
||||
|
||||
@@ -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,8 @@ 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)
|
||||
.or_else(|_| render_locally(&template_path, &payload_path, &output_path))?;
|
||||
let artifact_path = rendered["data"]["artifact"]["path"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
@@ -163,9 +162,7 @@ fn failed_tool_result(error: String) -> ToolResult {
|
||||
|
||||
fn create_job_root(workspace_root: &Path) -> anyhow::Result<PathBuf> {
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
let path = workspace_root
|
||||
.join(".sgclaw-openxml")
|
||||
.join(format!("{nanos}"));
|
||||
let path = workspace_root.join(".sgclaw-openxml").join(format!("{nanos}"));
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -223,10 +220,7 @@ fn canonicalize_column_name(value: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
fn reorder_row(row: &[Value], column_order: &[usize]) -> Vec<Value> {
|
||||
column_order
|
||||
.iter()
|
||||
.map(|index| row[*index].clone())
|
||||
.collect()
|
||||
column_order.iter().map(|index| row[*index].clone()).collect()
|
||||
}
|
||||
|
||||
fn write_payload_json(path: &Path, rows: &[Vec<Value>]) -> anyhow::Result<()> {
|
||||
@@ -285,18 +279,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",
|
||||
@@ -358,14 +342,11 @@ fn worksheet_xml_from_xlsx(path: &Path) -> anyhow::Result<String> {
|
||||
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)?;
|
||||
sheet.read_to_string(&mut xml)?;
|
||||
Ok(xml)
|
||||
}
|
||||
|
||||
fn render_template_xml(
|
||||
template: &str,
|
||||
variables: &serde_json::Map<String, Value>,
|
||||
) -> String {
|
||||
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}}}}}");
|
||||
@@ -392,7 +373,7 @@ fn write_rendered_xlsx(
|
||||
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);
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index)?;
|
||||
@@ -416,6 +397,34 @@ fn xml_escape(value: &str) -> String {
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
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 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 openxml_cli_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"openxml-cli.exe"
|
||||
} else {
|
||||
"openxml-cli"
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
@@ -427,34 +436,39 @@ 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),
|
||||
}])?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -473,7 +487,7 @@ fn write_zip_file(path: &Path, entries: &[Content<'_>]) -> anyhow::Result<()> {
|
||||
|
||||
let file = fs::File::create(path)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
for entry in entries {
|
||||
zip.start_file(entry.path, options)?;
|
||||
zip.write_all(entry.body.as_bytes())?;
|
||||
@@ -482,6 +496,42 @@ fn write_zip_file(path: &Path, entries: &[Content<'_>]) -> anyhow::Result<()> {
|
||||
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_entry_name(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn worksheet_xml(row_count: usize) -> String {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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};
|
||||
@@ -36,6 +37,7 @@ pub fn execute_task_with_browser_backend(
|
||||
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(),
|
||||
@@ -47,6 +49,7 @@ pub fn execute_task_with_browser_backend(
|
||||
transport,
|
||||
browser_backend.clone(),
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
@@ -73,6 +76,7 @@ pub fn execute_task_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
@@ -84,6 +88,7 @@ pub fn execute_task_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
@@ -101,6 +106,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,6 +118,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
@@ -138,6 +145,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
@@ -149,6 +157,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
&skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
|
||||
@@ -146,12 +146,12 @@ pub async fn execute_task_with_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();
|
||||
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
||||
let loaded_skills = engine.loaded_skills(&config, std::slice::from_ref(&skills_dir));
|
||||
let loaded_skill_versions = loaded_skills
|
||||
.iter()
|
||||
.map(|skill| (skill.name.clone(), skill.version.clone()))
|
||||
@@ -198,7 +198,7 @@ pub async fn execute_task_with_provider(
|
||||
let mut agent = engine.build_agent(
|
||||
provider,
|
||||
&config,
|
||||
&skills_dir,
|
||||
std::slice::from_ref(&skills_dir),
|
||||
tools,
|
||||
browser_surface_present,
|
||||
instruction,
|
||||
|
||||
@@ -12,10 +12,10 @@ 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 = {";
|
||||
const PAYLOAD_END_MARKER: &str = "const themeMeta = {";
|
||||
|
||||
pub struct ScreenHtmlExportTool {
|
||||
workspace_root: PathBuf,
|
||||
|
||||
@@ -5,15 +5,11 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use zeroclaw::skills::load_skills_from_directory;
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::compat::artifact_open::{open_exported_xlsx, open_local_dashboard, PostExportOpen};
|
||||
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
|
||||
use crate::compat::config_adapter::resolve_scene_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
@@ -27,7 +23,6 @@ const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com";
|
||||
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
|
||||
const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator";
|
||||
const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write";
|
||||
const FAULT_DETAILS_SCENE_ID: &str = "fault-details-report";
|
||||
const HOTLIST_READY_POLL_ATTEMPTS: usize = 10;
|
||||
const HOTLIST_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const EDITOR_READY_POLL_ATTEMPTS: usize = 12;
|
||||
@@ -39,7 +34,6 @@ const HOTLIST_TEXT_READY_PATTERN: &str =
|
||||
r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*热度";
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowRoute {
|
||||
FaultDetailsReport,
|
||||
ZhihuHotlistExportXlsx,
|
||||
ZhihuHotlistScreen,
|
||||
ZhihuArticleEntry,
|
||||
@@ -66,13 +60,6 @@ pub fn detect_route(
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> Option<WorkflowRoute> {
|
||||
if let Some(scene) = crate::runtime::match_scene_instruction(instruction) {
|
||||
if scene.id == FAULT_DETAILS_SCENE_ID
|
||||
&& matches!(scene.dispatch_mode, crate::runtime::DispatchMode::DirectBrowser)
|
||||
{
|
||||
return Some(WorkflowRoute::FaultDetailsReport);
|
||||
}
|
||||
}
|
||||
if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard")
|
||||
@@ -106,8 +93,7 @@ pub fn detect_route(
|
||||
pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool {
|
||||
matches!(
|
||||
route,
|
||||
WorkflowRoute::FaultDetailsReport
|
||||
| WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| WorkflowRoute::ZhihuHotlistScreen
|
||||
| WorkflowRoute::ZhihuArticleEntry
|
||||
| WorkflowRoute::ZhihuArticleDraft
|
||||
@@ -133,8 +119,7 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
|
||||
looks_like_denial
|
||||
|| matches!(
|
||||
route,
|
||||
WorkflowRoute::FaultDetailsReport
|
||||
| WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| WorkflowRoute::ZhihuHotlistScreen
|
||||
| WorkflowRoute::ZhihuArticleEntry
|
||||
| WorkflowRoute::ZhihuArticleDraft
|
||||
@@ -147,22 +132,22 @@ pub fn execute_route_with_browser_backend(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
workspace_root: &Path,
|
||||
skills_dir: &Path,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
route: WorkflowRoute,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
match route {
|
||||
WorkflowRoute::FaultDetailsReport => execute_fault_details_route(
|
||||
browser_backend.clone(),
|
||||
instruction,
|
||||
workspace_root,
|
||||
settings,
|
||||
task_context.page_url.as_deref(),
|
||||
),
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
|
||||
let top_n = extract_top_n(instruction);
|
||||
let items = collect_hotlist_items(transport, browser_backend.as_ref(), top_n, task_context)?;
|
||||
let items = collect_hotlist_items(
|
||||
transport,
|
||||
browser_backend.as_ref(),
|
||||
skills_dir,
|
||||
top_n,
|
||||
task_context,
|
||||
)?;
|
||||
if items.is_empty() {
|
||||
return Err(PipeError::Protocol(
|
||||
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
||||
@@ -177,11 +162,12 @@ pub fn execute_route_with_browser_backend(
|
||||
}
|
||||
}
|
||||
WorkflowRoute::ZhihuArticleEntry => {
|
||||
execute_zhihu_article_entry_route(transport, browser_backend.as_ref())
|
||||
execute_zhihu_article_entry_route(transport, browser_backend.as_ref(), skills_dir)
|
||||
}
|
||||
WorkflowRoute::ZhihuArticleDraft => execute_zhihu_article_route(
|
||||
transport,
|
||||
browser_backend.as_ref(),
|
||||
skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
false,
|
||||
@@ -191,6 +177,7 @@ pub fn execute_route_with_browser_backend(
|
||||
WorkflowRoute::ZhihuArticlePublish => execute_zhihu_article_route(
|
||||
transport,
|
||||
browser_backend.as_ref(),
|
||||
skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
true,
|
||||
@@ -201,6 +188,7 @@ pub fn execute_route_with_browser_backend(
|
||||
execute_generated_zhihu_article_publish_route(
|
||||
transport,
|
||||
browser_backend.as_ref(),
|
||||
skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
workspace_root,
|
||||
@@ -214,6 +202,7 @@ pub fn execute_route<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
workspace_root: &Path,
|
||||
skills_dir: &Path,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
route: WorkflowRoute,
|
||||
@@ -225,6 +214,7 @@ pub fn execute_route<T: Transport + 'static>(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
@@ -232,164 +222,16 @@ pub fn execute_route<T: Transport + 'static>(
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_fault_details_route(
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
instruction: &str,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
page_url: Option<&str>,
|
||||
) -> Result<String, PipeError> {
|
||||
let scene = crate::runtime::match_scene_instruction(instruction).ok_or_else(|| {
|
||||
PipeError::Protocol("故障明细直连路由失败:未找到场景元数据。".to_string())
|
||||
})?;
|
||||
if scene.id != FAULT_DETAILS_SCENE_ID {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"故障明细直连路由失败:场景不匹配,got {}",
|
||||
scene.id
|
||||
)));
|
||||
}
|
||||
|
||||
let period = derive_fault_details_period(instruction).ok_or_else(|| {
|
||||
PipeError::Protocol(
|
||||
"故障明细直连路由失败:无法从当前指令安全推导必填参数 period,请明确提供例如“导出 2026-04 故障明细”。"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let skills_dirs = resolve_scene_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||
let skill = skills_dirs
|
||||
.iter()
|
||||
.flat_map(|dir| load_skills_from_directory(dir, true))
|
||||
.find(|skill| skill.name == scene.skill_package)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"故障明细直连路由失败:未找到技能包 {} in [{}]",
|
||||
scene.skill_package,
|
||||
skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
})?;
|
||||
let skill_root = skill
|
||||
.location
|
||||
.as_deref()
|
||||
.and_then(Path::parent)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"故障明细直连路由失败:技能包 {} 缺少有效位置元数据",
|
||||
scene.skill_package
|
||||
))
|
||||
})?;
|
||||
let tool = skill
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == scene.skill_tool)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(format!(
|
||||
"故障明细直连路由失败:技能包 {} 缺少工具 {}",
|
||||
scene.skill_package, scene.skill_tool
|
||||
))
|
||||
})?;
|
||||
if tool.kind != "browser_script" {
|
||||
return Err(PipeError::Protocol(format!(
|
||||
"故障明细直连路由失败:工具 {} 必须是 browser_script,当前为 {}",
|
||||
scene.skill_tool, tool.kind
|
||||
)));
|
||||
}
|
||||
|
||||
let expected_domain = fault_details_expected_domain(page_url, &scene.expected_domain)
|
||||
.ok_or_else(|| {
|
||||
PipeError::Protocol(
|
||||
"故障明细直连路由失败:无法从当前页面上下文解析可用域名。".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
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_backend,
|
||||
json!({
|
||||
"expected_domain": expected_domain,
|
||||
"period": period,
|
||||
}),
|
||||
))
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "fault-details-report browser script failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(result.output)
|
||||
}
|
||||
|
||||
fn fault_details_expected_domain(page_url: Option<&str>, fallback: &str) -> Option<String> {
|
||||
page_url
|
||||
.and_then(host_from_url)
|
||||
.or_else(|| host_from_url(fallback))
|
||||
}
|
||||
|
||||
fn host_from_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(url) = Url::parse(trimmed) {
|
||||
return url.host_str().map(|host| host.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
let host = trimmed
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
|
||||
fn derive_fault_details_period(instruction: &str) -> Option<String> {
|
||||
let month_re = Regex::new(r"(20\d{2})[-/年](0?[1-9]|1[0-2])").expect("valid fault details month regex");
|
||||
let derived = month_re.captures_iter(instruction).find_map(|capture| {
|
||||
let matched = capture.get(0)?;
|
||||
let before_is_digit = instruction[..matched.start()]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|ch| ch.is_ascii_digit());
|
||||
let after_is_digit = instruction[matched.end()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| ch.is_ascii_digit());
|
||||
if before_is_digit || after_is_digit {
|
||||
return None;
|
||||
}
|
||||
|
||||
let year = capture.get(1).map(|m| m.as_str()).unwrap_or_default();
|
||||
let month = capture
|
||||
.get(2)
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok())
|
||||
.unwrap_or(1);
|
||||
Some(format!("{year}-{month:02}"))
|
||||
});
|
||||
derived
|
||||
}
|
||||
|
||||
fn collect_hotlist_items(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
top_n: usize,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<Vec<HotlistItem>, PipeError> {
|
||||
if let Some(items) = ensure_hotlist_page_ready(transport, browser_tool, top_n, task_context)? {
|
||||
if let Some(items) =
|
||||
ensure_hotlist_page_ready(transport, browser_tool, skills_dir, top_n, task_context)?
|
||||
{
|
||||
return Ok(items);
|
||||
}
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -398,7 +240,7 @@ fn collect_hotlist_items(
|
||||
})?;
|
||||
let response = browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": load_hotlist_extractor_script(top_n)? }),
|
||||
json!({ "script": load_hotlist_extractor_script(skills_dir, top_n)? }),
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if !response.success {
|
||||
@@ -419,6 +261,7 @@ fn collect_hotlist_items(
|
||||
fn ensure_hotlist_page_ready(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
top_n: usize,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
|
||||
@@ -441,7 +284,7 @@ fn ensure_hotlist_page_ready(
|
||||
// Best-effort wait for content to appear; ignore the boolean result –
|
||||
// we always follow up with the probe.
|
||||
let _ = poll_for_hotlist_readiness(browser_tool);
|
||||
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
|
||||
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, skills_dir, top_n)? {
|
||||
return Ok(Some(items));
|
||||
}
|
||||
}
|
||||
@@ -450,7 +293,7 @@ fn ensure_hotlist_page_ready(
|
||||
for attempt in 0..2 {
|
||||
navigate_hotlist_page(transport, browser_tool)?;
|
||||
let _ = poll_for_hotlist_readiness(browser_tool);
|
||||
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
|
||||
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, skills_dir, top_n)? {
|
||||
return Ok(Some(items));
|
||||
}
|
||||
last_error = Some(format!(
|
||||
@@ -477,6 +320,7 @@ fn ensure_hotlist_page_ready(
|
||||
/// reports "editor_unavailable".
|
||||
fn poll_for_editor_readiness(
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
desired_mode: &str,
|
||||
) -> Result<Value, PipeError> {
|
||||
let args = json!({ "desired_mode": desired_mode });
|
||||
@@ -485,6 +329,7 @@ fn poll_for_editor_readiness(
|
||||
for attempt in 0..EDITOR_READY_POLL_ATTEMPTS {
|
||||
match execute_browser_skill_script(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
args.clone(),
|
||||
@@ -498,9 +343,7 @@ fn poll_for_editor_readiness(
|
||||
last_state = Some(state);
|
||||
}
|
||||
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
|
||||
Err(_) => {
|
||||
// Script may fail while the page is still navigating; tolerate.
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
if attempt + 1 < EDITOR_READY_POLL_ATTEMPTS {
|
||||
@@ -508,12 +351,11 @@ fn poll_for_editor_readiness(
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last observed state so the caller can surface the
|
||||
// "editor_unavailable" message; or make one final attempt.
|
||||
match last_state {
|
||||
Some(state) => Ok(state),
|
||||
None => execute_browser_skill_script(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
args,
|
||||
@@ -525,6 +367,7 @@ fn poll_for_editor_readiness(
|
||||
fn probe_hotlist_extractor(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
top_n: usize,
|
||||
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -533,7 +376,7 @@ fn probe_hotlist_extractor(
|
||||
})?;
|
||||
let response = browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": load_hotlist_extractor_script(top_n)? }),
|
||||
json!({ "script": load_hotlist_extractor_script(skills_dir, top_n)? }),
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if !response.success {
|
||||
@@ -708,6 +551,7 @@ pub fn finalize_screen_export(
|
||||
fn execute_zhihu_article_route(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
publish_mode: bool,
|
||||
@@ -732,6 +576,7 @@ fn execute_zhihu_article_route(
|
||||
})?;
|
||||
let creator_state = execute_browser_skill_script(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" }),
|
||||
@@ -755,6 +600,7 @@ fn execute_zhihu_article_route(
|
||||
})?;
|
||||
let editor_state = poll_for_editor_readiness(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
if publish_mode { "publish" } else { "draft" },
|
||||
)?;
|
||||
if is_login_required_payload(&editor_state) {
|
||||
@@ -773,10 +619,11 @@ fn execute_zhihu_article_route(
|
||||
message: "call zhihu-write.fill_article_draft".to_string(),
|
||||
})?;
|
||||
let fill_result = if browser_tool.supports_live_input() {
|
||||
execute_zhihu_fill_via_live_input(browser_tool, &article, publish_mode)?
|
||||
execute_zhihu_fill_via_live_input(browser_tool, skills_dir, &article, publish_mode)?
|
||||
} else {
|
||||
execute_browser_skill_script(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
"zhihu-write",
|
||||
"fill_article_draft.js",
|
||||
json!({
|
||||
@@ -814,6 +661,7 @@ fn execute_zhihu_article_route(
|
||||
fn execute_generated_zhihu_article_publish_route(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
@@ -834,6 +682,7 @@ fn execute_generated_zhihu_article_publish_route(
|
||||
execute_zhihu_article_route(
|
||||
transport,
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
instruction,
|
||||
task_context,
|
||||
true,
|
||||
@@ -874,6 +723,7 @@ fn task_requests_zhihu_generated_article_publish(
|
||||
fn execute_zhihu_article_entry_route(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
) -> Result<String, PipeError> {
|
||||
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -882,6 +732,7 @@ fn execute_zhihu_article_entry_route(
|
||||
})?;
|
||||
let creator_state = execute_browser_skill_script(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" }),
|
||||
@@ -903,10 +754,7 @@ fn execute_zhihu_article_entry_route(
|
||||
level: "info".to_string(),
|
||||
message: "call zhihu-write.prepare_article_editor".to_string(),
|
||||
})?;
|
||||
let editor_state = poll_for_editor_readiness(
|
||||
browser_tool,
|
||||
"draft",
|
||||
)?;
|
||||
let editor_state = poll_for_editor_readiness(browser_tool, skills_dir, "draft")?;
|
||||
if is_login_required_payload(&editor_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&editor_state,
|
||||
@@ -921,8 +769,9 @@ fn execute_zhihu_article_entry_route(
|
||||
)))
|
||||
}
|
||||
|
||||
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
|
||||
fn load_hotlist_extractor_script(skills_dir: &Path, top_n: usize) -> Result<String, PipeError> {
|
||||
load_browser_skill_script(
|
||||
skills_dir,
|
||||
"zhihu-hotlist",
|
||||
"extract_hotlist.js",
|
||||
json!({ "top_n": top_n.to_string() }),
|
||||
@@ -1007,12 +856,14 @@ fn navigate_zhihu_page(
|
||||
|
||||
fn execute_browser_skill_script(
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
skill_name: &str,
|
||||
script_name: &str,
|
||||
args: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<Value, PipeError> {
|
||||
let wrapped_script = load_browser_skill_script(skill_name, script_name, args)?;
|
||||
let wrapped_script =
|
||||
load_browser_skill_script(skills_dir, skill_name, script_name, args)?;
|
||||
let response = browser_tool.invoke(
|
||||
Action::Eval,
|
||||
json!({ "script": wrapped_script }),
|
||||
@@ -1039,6 +890,7 @@ fn live_input_probe_script(selector_candidates: &[&str]) -> String {
|
||||
|
||||
fn execute_zhihu_fill_via_live_input(
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skills_dir: &Path,
|
||||
article: &ArticleDraft,
|
||||
publish_mode: bool,
|
||||
) -> Result<Value, PipeError> {
|
||||
@@ -1176,6 +1028,7 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
|
||||
// enable the button after the content fill updates the editor state.
|
||||
let fill_result = execute_browser_skill_script(
|
||||
browser_tool,
|
||||
skills_dir,
|
||||
"zhihu-write",
|
||||
"fill_article_draft.js",
|
||||
json!({
|
||||
@@ -1275,11 +1128,15 @@ mod tests {
|
||||
"test-key".to_string(),
|
||||
"http://127.0.0.1:9".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn test_skills_dir() -> &'static Path {
|
||||
Path::new("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills")
|
||||
}
|
||||
|
||||
struct MockWorkflowTransport {
|
||||
sent: Mutex<Vec<AgentMessage>>,
|
||||
responses: Mutex<VecDeque<BrowserMessage>>,
|
||||
@@ -1439,6 +1296,7 @@ mod tests {
|
||||
transport.as_ref(),
|
||||
backend.clone(),
|
||||
Path::new("."),
|
||||
test_skills_dir(),
|
||||
"打开知乎写文章页面",
|
||||
&CompatTaskContext::default(),
|
||||
WorkflowRoute::ZhihuArticleEntry,
|
||||
@@ -1459,6 +1317,7 @@ mod tests {
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" })
|
||||
@@ -1471,6 +1330,7 @@ mod tests {
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
json!({ "desired_mode": "draft" })
|
||||
@@ -1543,6 +1403,7 @@ mod tests {
|
||||
transport.as_ref(),
|
||||
backend.clone(),
|
||||
Path::new("."),
|
||||
test_skills_dir(),
|
||||
"打开知乎写文章页面",
|
||||
&CompatTaskContext::default(),
|
||||
WorkflowRoute::ZhihuArticleEntry,
|
||||
@@ -1563,6 +1424,7 @@ mod tests {
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" })
|
||||
@@ -1580,6 +1442,7 @@ mod tests {
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
json!({ "desired_mode": "draft" })
|
||||
@@ -1668,6 +1531,7 @@ mod tests {
|
||||
let summary = execute_zhihu_article_route(
|
||||
transport.as_ref(),
|
||||
backend.as_ref(),
|
||||
test_skills_dir(),
|
||||
"标题:测试标题\n正文:第一段内容",
|
||||
&CompatTaskContext::default(),
|
||||
false,
|
||||
@@ -1798,6 +1662,7 @@ mod tests {
|
||||
let summary = execute_zhihu_article_route(
|
||||
transport.as_ref(),
|
||||
backend.as_ref(),
|
||||
test_skills_dir(),
|
||||
"标题:测试标题\n正文:第一段内容",
|
||||
&CompatTaskContext::default(),
|
||||
false,
|
||||
@@ -1828,6 +1693,7 @@ mod tests {
|
||||
assert_eq!(invocations[8].0, Action::Eval);
|
||||
assert_eq!(invocations[8].1["script"], json!(
|
||||
load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-write",
|
||||
"fill_article_draft.js",
|
||||
json!({
|
||||
@@ -1926,6 +1792,7 @@ mod tests {
|
||||
let _ = execute_zhihu_article_route(
|
||||
transport.as_ref(),
|
||||
backend.as_ref(),
|
||||
test_skills_dir(),
|
||||
"标题:测试标题\n正文:第一段内容\n第二段内容",
|
||||
&CompatTaskContext::default(),
|
||||
false,
|
||||
@@ -1944,6 +1811,7 @@ mod tests {
|
||||
#[test]
|
||||
fn zhihu_fill_script_checks_live_input_before_dom_fill_fallback() {
|
||||
let script = load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-write",
|
||||
"fill_article_draft.js",
|
||||
json!({
|
||||
@@ -1978,6 +1846,7 @@ mod tests {
|
||||
#[test]
|
||||
fn zhihu_fill_script_live_input_uses_editor_content_instead_of_whole_page_text() {
|
||||
let script = load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-write",
|
||||
"fill_article_draft.js",
|
||||
json!({
|
||||
@@ -2070,6 +1939,7 @@ mod tests {
|
||||
transport.as_ref(),
|
||||
backend.clone(),
|
||||
Path::new("."),
|
||||
test_skills_dir(),
|
||||
"打开知乎写文章页面",
|
||||
&CompatTaskContext::default(),
|
||||
WorkflowRoute::ZhihuArticleEntry,
|
||||
@@ -2090,6 +1960,7 @@ mod tests {
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" })
|
||||
@@ -2107,6 +1978,7 @@ mod tests {
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
test_skills_dir(),
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
json!({ "desired_mode": "draft" })
|
||||
@@ -2148,7 +2020,13 @@ mod tests {
|
||||
};
|
||||
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
let items = collect_hotlist_items(
|
||||
transport.as_ref(),
|
||||
&browser_backend,
|
||||
test_skills_dir(),
|
||||
10,
|
||||
&task_context,
|
||||
)
|
||||
.expect("hotlist collection should succeed");
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
@@ -2202,7 +2080,13 @@ mod tests {
|
||||
};
|
||||
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
let items = collect_hotlist_items(
|
||||
transport.as_ref(),
|
||||
&browser_backend,
|
||||
test_skills_dir(),
|
||||
10,
|
||||
&task_context,
|
||||
)
|
||||
.expect("hotlist collection should succeed after readiness polling");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
@@ -2271,7 +2155,13 @@ mod tests {
|
||||
};
|
||||
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
let items = collect_hotlist_items(
|
||||
transport.as_ref(),
|
||||
&browser_backend,
|
||||
test_skills_dir(),
|
||||
10,
|
||||
&task_context,
|
||||
)
|
||||
.expect("hotlist collection should succeed after one navigation retry");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
@@ -2338,7 +2228,13 @@ mod tests {
|
||||
};
|
||||
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
let items = collect_hotlist_items(
|
||||
transport.as_ref(),
|
||||
&browser_backend,
|
||||
test_skills_dir(),
|
||||
10,
|
||||
&task_context,
|
||||
)
|
||||
.expect("hotlist collection should succeed via extractor probe");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
@@ -2357,15 +2253,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn load_browser_skill_script(
|
||||
skills_dir: &Path,
|
||||
skill_name: &str,
|
||||
script_name: &str,
|
||||
args: Value,
|
||||
) -> Result<String, PipeError> {
|
||||
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
|
||||
.join("skill_lib")
|
||||
.join("skills")
|
||||
let script_path = skills_dir
|
||||
.join(skill_name)
|
||||
.join("scripts")
|
||||
.join(script_name);
|
||||
|
||||
@@ -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,
|
||||
@@ -156,7 +173,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,6 +183,7 @@ impl SgClawSettings {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
@@ -199,7 +217,8 @@ impl SgClawSettings {
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -284,7 +303,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,
|
||||
@@ -302,7 +322,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>,
|
||||
@@ -313,10 +334,15 @@ impl SgClawSettings {
|
||||
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 +366,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),
|
||||
@@ -433,18 +460,11 @@ 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> {
|
||||
raw.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|path| if path.is_absolute() { path } else { config_dir.join(path) })
|
||||
}
|
||||
|
||||
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
|
||||
@@ -460,6 +480,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 +529,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 +537,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)]
|
||||
|
||||
@@ -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 {
|
||||
@@ -153,9 +151,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());
|
||||
}
|
||||
@@ -276,17 +271,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>,
|
||||
@@ -402,14 +386,6 @@ fn load_runtime_skills(config: &ZeroClawConfig, skills_dirs: &[PathBuf]) -> Vec<
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -7,15 +7,18 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::agent::{
|
||||
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||||
};
|
||||
use sgclaw::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::compat::runtime::CompatTaskContext;
|
||||
use sgclaw::config::SgClawSettings;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use tungstenite::{accept, Message};
|
||||
use uuid::Uuid;
|
||||
use tungstenite::{accept, error::ProtocolError, Message};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -34,6 +37,7 @@ fn write_config(
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
skills_dir: Option<&str>,
|
||||
browser_ws_url: Option<&str>,
|
||||
) -> PathBuf {
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
let mut payload = json!({
|
||||
@@ -45,6 +49,9 @@ fn write_config(
|
||||
if let Some(skills_dir) = skills_dir {
|
||||
payload["skillsDir"] = json!(skills_dir);
|
||||
}
|
||||
if let Some(browser_ws_url) = browser_ws_url {
|
||||
payload["browserWsUrl"] = json!(browser_ws_url);
|
||||
}
|
||||
fs::write(&config_path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
|
||||
config_path
|
||||
}
|
||||
@@ -80,7 +87,10 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
|
||||
let message = match socket.read() {
|
||||
Ok(message) => message,
|
||||
Err(tungstenite::Error::ConnectionClosed)
|
||||
| Err(tungstenite::Error::AlreadyClosed) => break,
|
||||
| Err(tungstenite::Error::AlreadyClosed)
|
||||
| Err(tungstenite::Error::Protocol(
|
||||
ProtocolError::ResetWithoutClosingHandshake,
|
||||
)) => break,
|
||||
Err(err) => panic!("browser ws test server read failed: {err}"),
|
||||
};
|
||||
let payload = match message {
|
||||
@@ -155,20 +165,567 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
|
||||
(format!("ws://{address}"), frames, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_ws_server_treats_reset_without_closing_handshake_as_disconnect() {
|
||||
let err = tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake);
|
||||
assert!(matches!(
|
||||
err,
|
||||
tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake)
|
||||
));
|
||||
}
|
||||
|
||||
fn provider_path_test_policy() -> MacPolicy {
|
||||
policy_for_domains(&["www.baidu.com"])
|
||||
}
|
||||
|
||||
fn direct_runtime_test_policy() -> MacPolicy {
|
||||
policy_for_domains(&["95598.sgcc.com.cn"])
|
||||
}
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
policy_for_domains(&["www.zhihu.com"])
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 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 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 completion = direct_submit_completion(&sent).expect("task completion");
|
||||
|
||||
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 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 { .. })));
|
||||
}
|
||||
|
||||
#[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 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 production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
@@ -179,17 +736,16 @@ fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstr
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let (ws_url, frames, ws_handle) = start_browser_ws_server();
|
||||
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()),
|
||||
Some(&ws_url),
|
||||
);
|
||||
|
||||
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 browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
@@ -306,7 +862,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));
|
||||
|
||||
@@ -32,6 +32,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,
|
||||
&PipeBrowserBackend::from_inner(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,
|
||||
&PipeBrowserBackend::from_inner(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,
|
||||
&PipeBrowserBackend::from_inner(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");
|
||||
@@ -115,9 +283,91 @@ return {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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(
|
||||
&script_path,
|
||||
r#"
|
||||
return {
|
||||
sheet_name: "知乎热榜",
|
||||
rows: [[1, "标题", args.top_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 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());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: script_name.to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, backend)
|
||||
.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": [[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("rows: [[1, \"标题\", args.top_n]]")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_helper_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-fault-details");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
@@ -152,7 +402,7 @@ 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 backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
@@ -164,10 +414,15 @@ return {
|
||||
args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"period": "2026-04"
|
||||
}))
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&backend,
|
||||
json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"period": "2026-04"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -208,7 +463,7 @@ async fn browser_script_helper_requires_expected_domain() {
|
||||
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 backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
@@ -220,9 +475,14 @@ async fn browser_script_helper_requires_expected_domain() {
|
||||
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,
|
||||
&backend,
|
||||
json!({
|
||||
"period": "2026-04"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -234,6 +494,122 @@ async fn browser_script_helper_requires_expected_domain() {
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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"),
|
||||
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![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 = PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
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 structured fault details".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/collect_fault_details.js".to_string(),
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&backend,
|
||||
json!({
|
||||
"expected_domain": "https://www.zhihu.com/",
|
||||
"period": "2026-03"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
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" }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -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!(first.skills_dir.is_none());
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
@@ -111,23 +111,23 @@ 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]
|
||||
fn resolve_skills_dir_prefers_nested_skills_subdirectory_for_configured_repo_root() {
|
||||
fn ws_cleanup_resolves_single_configured_skills_dir() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join("skill_lib/skills")).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![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,10 +241,29 @@ 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 ws_cleanup_rejects_array_style_skills_dir_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-config-{}", 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-test",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": ["skill_lib", "skill_staging"]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(sgclaw::config::SgClawSettings::load(Some(config_path.as_path())).is_err());
|
||||
}
|
||||
|
||||
#[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()));
|
||||
@@ -280,7 +324,7 @@ fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
|
||||
"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,12 @@
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
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 +14,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 +44,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_eq!(output_json["output_path"], json!(output_path.to_str().unwrap()));
|
||||
|
||||
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 +78,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 +106,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<"));
|
||||
|
||||
@@ -112,21 +112,15 @@ fn write_skill_script(skill_dir: &std::path::Path, relative_path: &str, body: &s
|
||||
}
|
||||
|
||||
fn real_skill_lib_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
let repo_parent = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("skill_lib")
|
||||
}
|
||||
|
||||
fn staged_skill_root() -> PathBuf {
|
||||
PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging")
|
||||
}
|
||||
|
||||
fn project_skills_root() -> PathBuf {
|
||||
staged_skill_root()
|
||||
.parent()
|
||||
.expect("staged skill root should have parent")
|
||||
.to_path_buf()
|
||||
.to_path_buf();
|
||||
let hyphenated = repo_parent.join("skill-lib");
|
||||
if hyphenated.exists() {
|
||||
return hyphenated;
|
||||
}
|
||||
repo_parent.join("skill_lib")
|
||||
}
|
||||
|
||||
fn success_browser_response(seq: u64, data: Value) -> BrowserMessage {
|
||||
@@ -360,6 +354,9 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(summary, "已通过 ZeroClaw 执行任务: 打开百度搜索天气");
|
||||
@@ -423,7 +420,7 @@ fn compat_runtime_includes_default_workspace_skills_in_provider_request() {
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -687,7 +684,10 @@ fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_ll
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" &&
|
||||
message == "sgclaw runtime version=0.1.0 protocol=1.0"
|
||||
message == &format!(
|
||||
"sgclaw runtime version={} protocol=1.0",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
@@ -894,6 +894,11 @@ fn handle_browser_message_falls_back_to_compat_runtime_for_unsupported_instructi
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_requires_llm_configuration_when_no_model_is_available() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
@@ -961,7 +966,7 @@ fn compat_runtime_includes_prior_turns_in_follow_up_provider_request() {
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
@@ -1060,7 +1065,7 @@ fn compat_runtime_does_not_forward_raw_aom_snapshot_back_to_provider() {
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
|
||||
let large_snapshot_marker = "snapshot-marker ".repeat(2048);
|
||||
@@ -1122,7 +1127,7 @@ fn compat_runtime_injects_browser_contract_and_page_context_into_provider_reques
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Vec::new(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -1198,7 +1203,7 @@ fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::GeneralAssistant;
|
||||
@@ -1276,7 +1281,7 @@ fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
@@ -1361,7 +1366,7 @@ top_n = "How many hotlist rows to extract."
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1468,7 +1473,7 @@ return {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1539,7 +1544,7 @@ fn zhihu_hotlist_browser_skill_flow_does_not_expose_shell_or_glob_tools() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
Some(real_skill_lib_root()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1593,7 +1598,7 @@ fn compat_runtime_browser_attached_profile_keeps_file_read_available_for_local_p
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1644,7 +1649,7 @@ fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
Some(real_skill_lib_root()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1699,7 +1704,7 @@ fn compat_runtime_allows_zhihu_hotlist_screen_export_tool_in_browser_profile() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
Some(real_skill_lib_root()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1781,7 +1786,7 @@ fn compat_runtime_logs_read_skill_usage_with_skill_name() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
@@ -1918,7 +1923,7 @@ fn browser_attached_excel_request_uses_execution_contract_not_skill_source_stuff
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
Some(real_skill_lib_root()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1970,7 +1975,7 @@ fn browser_attached_publish_request_injects_confirmation_contract() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
Some(real_skill_lib_root()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -2601,313 +2606,6 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_p
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_exposes_project_skills_and_staged_scene_skills_together() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已同时看到顶层与场景技能"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"deepseek-chat",
|
||||
Some(project_skills_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "告诉我当前有哪些知乎和95598相关 skill".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.net/".to_string(),
|
||||
page_title: "Example Domain".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let loaded_skills_message = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("loaded skills: ") =>
|
||||
{
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected loaded skills log entry");
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已同时看到顶层与场景技能"
|
||||
)
|
||||
}));
|
||||
assert!(loaded_skills_message.contains("zhihu-hotlist@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("zhihu-write@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("fault-details-report@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("95598-repair-city-dispatch@0.1.0"));
|
||||
assert!(first_request.contains("zhihu-hotlist"));
|
||||
assert!(first_request.contains("zhihu-write"));
|
||||
assert!(first_request.contains("fault-details-report"));
|
||||
assert!(first_request.contains("95598-repair-city-dispatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_finds_staged_scene_skill_under_project_skills_root() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(project_skills_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_exposes_staged_95598_scene_skills_and_contract_on_agent_path() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已按95598场景进入通用代理路径"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["95598.example.invalid"]),
|
||||
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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列"
|
||||
.to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://95598.example.invalid/dispatch".to_string(),
|
||||
page_title: "95598抢修市指监测".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let loaded_skills_message = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("loaded skills: ") =>
|
||||
{
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected loaded skills log entry");
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "compat_llm_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已按95598场景进入通用代理路径"
|
||||
)
|
||||
}));
|
||||
assert!(loaded_skills_message.contains("fault-details-report@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("95598-repair-city-dispatch@0.1.0"));
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(first_request.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(first_request.contains("Current page URL: https://95598.example.invalid/dispatch"));
|
||||
assert!(first_request.contains("Current page title: 95598抢修市指监测"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_surface_disabled_fault_details_turn_uses_general_assistant_provider_path() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已走通用助手路径"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = workspace_root.join("sgclaw_config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"apiKey": "deepseek-test-key",
|
||||
"baseUrl": base_url,
|
||||
"model": "deepseek-chat",
|
||||
"runtimeProfile": "generalAssistant",
|
||||
"skillsDir": staged_skill_root(),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let loaded_skills_message = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("loaded skills: ") =>
|
||||
{
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "compat_llm_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已走通用助手路径"
|
||||
)
|
||||
}));
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(!first_request.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(!first_request.contains("browser workflow, not a text-only task"));
|
||||
assert!(!first_request.contains("generic browser probing only after"));
|
||||
assert!(loaded_skills_message.is_none());
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_zhihu_hotlist_request_keeps_zhihu_contract_without_scene_injection() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
@@ -2926,7 +2624,7 @@ fn browser_attached_zhihu_hotlist_request_keeps_zhihu_contract_without_scene_inj
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
Some(real_skill_lib_root()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -2964,348 +2662,28 @@ fn browser_attached_zhihu_hotlist_request_keeps_zhihu_contract_without_scene_inj
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_direct_browser_scene_matches_primary_orchestration_gate() {
|
||||
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"导出故障明细",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_direct_browser_scene_detects_direct_route() {
|
||||
use sgclaw::compat::workflow_executor::{detect_route, WorkflowRoute};
|
||||
fn ws_cleanup_no_longer_detects_fault_details_scene_route() {
|
||||
use sgclaw::compat::workflow_executor::detect_route;
|
||||
|
||||
assert_eq!(
|
||||
detect_route(
|
||||
"导出故障明细",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账")
|
||||
Some("业务台账"),
|
||||
),
|
||||
Some(WorkflowRoute::FaultDetailsReport)
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_scene_metadata_keeps_unrelated_primary_routing_unchanged() {
|
||||
let registry = [sgclaw::runtime::SceneRegistryEntry {
|
||||
id: "unrelated-scene".to_string(),
|
||||
name: "无关场景".to_string(),
|
||||
summary: "与故障明细无关。".to_string(),
|
||||
tags: vec!["other".to_string()],
|
||||
inputs: vec!["period".to_string()],
|
||||
outputs: vec!["artifact".to_string()],
|
||||
skill_package: "unrelated-skill".to_string(),
|
||||
skill_tool: "run_other".to_string(),
|
||||
skill_artifact_type: "artifact".to_string(),
|
||||
dispatch_mode: sgclaw::runtime::DispatchMode::DirectBrowser,
|
||||
expected_domain: "other.example.invalid".to_string(),
|
||||
aliases: vec!["别的事情".to_string()],
|
||||
default_args: serde_json::Map::new(),
|
||||
}];
|
||||
|
||||
assert!(sgclaw::runtime::match_scene_instruction_in_registry(®istry, "别的事情").is_some());
|
||||
assert!(sgclaw::runtime::match_scene_instruction_in_registry(®istry, "导出故障明细").is_none());
|
||||
fn ws_cleanup_scene_keywords_do_not_trigger_primary_orchestration() {
|
||||
assert!(!sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"帮我汇总今天待办",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账"),
|
||||
"请处理95598抢修市指监测",
|
||||
Some("https://95598.example.invalid/dispatch"),
|
||||
Some("95598抢修市指监测"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_returns_clear_failure_when_period_cannot_be_derived() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
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("period") && summary.contains("无法")
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_uses_current_page_host_as_expected_domain() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
let eval_command = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (params, expected_domain) = eval_command.expect("direct route should call browser eval");
|
||||
assert_eq!(expected_domain, "example.invalid");
|
||||
let script = params["script"].as_str().unwrap_or_default();
|
||||
assert!(script.contains("const args = {\"period\":\"2026-04\"};"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_uses_packaged_browser_script_from_configured_skills_root() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let staged_root = workspace_root.join("custom_fault_details_staging");
|
||||
let custom_skills_dir = staged_root.join("skills");
|
||||
let skill_dir = write_skill_manifest_package(
|
||||
&custom_skills_dir,
|
||||
"fault-details-report",
|
||||
r#"
|
||||
[skill]
|
||||
name = "fault-details-report"
|
||||
description = "Collect fault detail rows via a packaged browser script."
|
||||
version = "0.1.0"
|
||||
|
||||
[[tools]]
|
||||
name = "collect_fault_details"
|
||||
description = "Collect fault detail rows for the target period."
|
||||
kind = "browser_script"
|
||||
command = "scripts/custom_fault_details.js"
|
||||
|
||||
[tools.args]
|
||||
period = "Target report period."
|
||||
"#,
|
||||
);
|
||||
write_skill_script(
|
||||
&skill_dir,
|
||||
"scripts/custom_fault_details.js",
|
||||
r#"
|
||||
return {
|
||||
sheet_name: "故障明细",
|
||||
rows: [[args.period || "unknown", "CUSTOM_FAULT_DETAILS_MARKER"]],
|
||||
marker: "CUSTOM_FAULT_DETAILS_MARKER"
|
||||
};
|
||||
"#,
|
||||
);
|
||||
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_root.to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
let eval_command = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (params, expected_domain) = eval_command.expect("direct route should call browser eval");
|
||||
assert_eq!(expected_domain, "example.invalid");
|
||||
let script = params["script"].as_str().unwrap_or_default();
|
||||
assert!(script.contains("const args = {\"period\":\"2026-04\"};"));
|
||||
assert!(script.contains("CUSTOM_FAULT_DETAILS_MARKER"));
|
||||
assert!(!script.contains("collect_fault_details.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_executes_browser_script_eval_when_period_is_derived() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
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,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
let eval_command = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (params, expected_domain) = eval_command.expect("direct route should call browser eval");
|
||||
assert_eq!(expected_domain, "example.invalid");
|
||||
let script = params["script"].as_str().unwrap_or_default();
|
||||
assert!(script.contains("const args = {\"period\":\"2026-04\"};"));
|
||||
assert!(script.contains("sheet_name") || script.contains("return JSON.stringify") || script.contains("rows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_generated_auto_publish_matches_primary_orchestration_gate() {
|
||||
assert!(
|
||||
|
||||
@@ -43,17 +43,18 @@ 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\""));
|
||||
assert!(html.contains("lead-summary"));
|
||||
assert!(html.contains("bar-chart"));
|
||||
assert!(html.contains("top-chart"));
|
||||
assert!(html.contains("pie-chart"));
|
||||
assert!(html.contains("bubble-chart"));
|
||||
assert!(html.contains("metric-categories"));
|
||||
assert!(html.contains("themeMeta"));
|
||||
assert!(html.contains("screen_html_export"));
|
||||
assert!(html.contains("table-note"));
|
||||
}
|
||||
|
||||
@@ -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!(settings.skills_dir.is_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 {
|
||||
|
||||
@@ -50,13 +50,13 @@ fn loaded_skills_excludes_browser_script_tools_when_browser_surface_is_unavailab
|
||||
));
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
let skill_root = temp_skill_root();
|
||||
write_browser_script_skill(&skill_root, "fault-details-report");
|
||||
write_browser_script_skill(&skill_root, "workspace-browser-skill");
|
||||
|
||||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
vec![skill_root.clone()],
|
||||
Some(skill_root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::GeneralAssistant;
|
||||
@@ -64,7 +64,7 @@ fn loaded_skills_excludes_browser_script_tools_when_browser_surface_is_unavailab
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(&workspace_root, &settings);
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::GeneralAssistant);
|
||||
|
||||
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
||||
let loaded_skills = engine.loaded_skills(&config, std::slice::from_ref(&skills_dir));
|
||||
|
||||
assert!(loaded_skills.is_empty());
|
||||
}
|
||||
@@ -125,19 +125,16 @@ fn browser_attached_publish_prompt_requires_explicit_confirmation_before_clickin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_95598_scene_prompt_requires_scene_tool_before_generic_browser_probing() {
|
||||
fn ws_cleanup_browser_profile_does_not_inject_95598_scene_contract() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列",
|
||||
"请处理95598抢修市指监测,查看抢修市指派单并汇总当前队列",
|
||||
Some("https://95598.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"));
|
||||
assert!(!instruction.contains("collect_repair_orders"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -151,7 +148,7 @@ fn browser_attached_unrelated_task_does_not_receive_95598_scene_contract() {
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(!instruction.contains("collect_repair_orders"));
|
||||
assert!(!instruction.contains("browser workflow, not a text-only task"));
|
||||
assert!(!instruction.contains("generic browser probing only after"));
|
||||
}
|
||||
@@ -161,13 +158,13 @@ fn general_assistant_95598_scene_prompt_does_not_receive_browser_scene_contract(
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::GeneralAssistant);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列",
|
||||
"请处理95598抢修市指监测,查看抢修市指派单并汇总当前队列",
|
||||
Some("https://95598.example.invalid/dispatch"),
|
||||
Some("95598抢修市指监测"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(!instruction.contains("collect_repair_orders"));
|
||||
assert!(!instruction.contains("browser workflow, not a text-only task"));
|
||||
assert!(!instruction.contains("generic browser probing only after"));
|
||||
}
|
||||
@@ -178,7 +175,7 @@ fn legacy_settings_default_to_plan_first_superrpa_and_openxml_backends() {
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -51,7 +51,12 @@ fn submit_task_without_llm_configuration_returns_clear_error() {
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "sgclaw runtime version=0.1.0 protocol=1.0"
|
||||
if level == "info"
|
||||
&& message
|
||||
== &format!(
|
||||
"sgclaw runtime version={} protocol=1.0",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sgclaw::runtime::{
|
||||
load_scene_registry_from_root, match_scene_instruction_in_registry, DispatchMode,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn scene_registry_loads_first_slice_dispatch_policies() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let fault_details = registry
|
||||
.iter()
|
||||
.find(|entry| entry.id == "fault-details-report")
|
||||
.expect("fault-details-report scene should load");
|
||||
assert_eq!(fault_details.dispatch_mode, DispatchMode::DirectBrowser);
|
||||
assert_eq!(fault_details.expected_domain, "sgcc.example.invalid");
|
||||
assert_eq!(fault_details.skill_package, "fault-details-report");
|
||||
assert_eq!(fault_details.skill_tool, "collect_fault_details");
|
||||
|
||||
let repair_dispatch = registry
|
||||
.iter()
|
||||
.find(|entry| entry.id == "95598-repair-city-dispatch")
|
||||
.expect("95598-repair-city-dispatch scene should load");
|
||||
assert_eq!(repair_dispatch.dispatch_mode, DispatchMode::AgentBrowser);
|
||||
assert_eq!(repair_dispatch.skill_package, "95598-repair-city-dispatch");
|
||||
assert_eq!(repair_dispatch.skill_tool, "collect_repair_orders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_matches_fault_details_natural_language_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let matched =
|
||||
match_scene_instruction_in_registry(®istry, "请帮我导出故障明细").expect("scene should match");
|
||||
|
||||
assert_eq!(matched.id, "fault-details-report");
|
||||
assert_eq!(matched.dispatch_mode, DispatchMode::DirectBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_matches_city_dispatch_natural_language_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let matched = match_scene_instruction_in_registry(®istry, "帮我看一下95598抢修市指监测")
|
||||
.expect("scene should match");
|
||||
|
||||
assert_eq!(matched.id, "95598-repair-city-dispatch");
|
||||
assert_eq!(matched.dispatch_mode, DispatchMode::AgentBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_matches_rephrased_instruction_via_alias_terms() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let matched = match_scene_instruction_in_registry(®istry, "想看市指那边的95598抢修队列")
|
||||
.expect("scene should match");
|
||||
|
||||
assert_eq!(matched.id, "95598-repair-city-dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_returns_none_for_unrelated_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert!(match_scene_instruction_in_registry(®istry, "今天上海天气怎么样").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_ignores_missing_or_broken_scene_files() {
|
||||
let root = TempSceneRoot::new();
|
||||
root.write_scene(
|
||||
"fault-details-report",
|
||||
r#"{
|
||||
"id": "fault-details-report",
|
||||
"name": "故障明细",
|
||||
"summary": "查询故障明细行并生成结构化报表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["report-artifact"],
|
||||
"tags": ["fault", "report"],
|
||||
"skill": {
|
||||
"package": "fault-details-report",
|
||||
"tool": "collect_fault_details",
|
||||
"artifact_type": "report-artifact"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
root.write_scene("95598-repair-city-dispatch", "{ broken json");
|
||||
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry[0].id, "fault-details-report");
|
||||
assert_eq!(registry[0].dispatch_mode, DispatchMode::DirectBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_ignores_mismatched_scene_metadata_id() {
|
||||
let root = TempSceneRoot::new();
|
||||
root.write_scene(
|
||||
"fault-details-report",
|
||||
r#"{
|
||||
"id": "wrong-scene-id",
|
||||
"name": "故障明细",
|
||||
"summary": "查询故障明细行并生成结构化报表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["report-artifact"],
|
||||
"tags": ["fault", "report"],
|
||||
"skill": {
|
||||
"package": "fault-details-report",
|
||||
"tool": "collect_fault_details",
|
||||
"artifact_type": "report-artifact"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
root.write_scene(
|
||||
"95598-repair-city-dispatch",
|
||||
r#"{
|
||||
"id": "95598-repair-city-dispatch",
|
||||
"name": "95598抢修市指监测",
|
||||
"summary": "采集95598抢修市指监测列表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["repair-orders"],
|
||||
"tags": ["95598", "repair", "dispatch"],
|
||||
"skill": {
|
||||
"package": "95598-repair-city-dispatch",
|
||||
"tool": "collect_repair_orders",
|
||||
"artifact_type": "repair-orders"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry[0].id, "95598-repair-city-dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_returns_none_for_ambiguous_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert!(
|
||||
match_scene_instruction_in_registry(®istry, "请同时处理导出故障明细和95598抢修市指监测").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
struct TempSceneRoot {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl TempSceneRoot {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("scene-registry-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join("scenes")).expect("temp scene root should be created");
|
||||
Self { root }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
fn write_scene(&self, scene_id: &str, contents: &str) {
|
||||
let scene_dir = self.root.join("scenes").join(scene_id);
|
||||
fs::create_dir_all(&scene_dir).expect("scene directory should be created");
|
||||
fs::write(scene_dir.join("scene.json"), contents).expect("scene file should be written");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_first_slice_scenes(root: &TempSceneRoot) {
|
||||
root.write_scene(
|
||||
"fault-details-report",
|
||||
r#"{
|
||||
"id": "fault-details-report",
|
||||
"name": "故障明细",
|
||||
"summary": "查询故障明细行并生成结构化报表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["report-artifact"],
|
||||
"tags": ["fault", "report"],
|
||||
"skill": {
|
||||
"package": "fault-details-report",
|
||||
"tool": "collect_fault_details",
|
||||
"artifact_type": "report-artifact"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
root.write_scene(
|
||||
"95598-repair-city-dispatch",
|
||||
r#"{
|
||||
"id": "95598-repair-city-dispatch",
|
||||
"name": "95598抢修市指监测",
|
||||
"summary": "采集95598抢修市指监测列表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["repair-orders"],
|
||||
"tags": ["95598", "repair", "dispatch"],
|
||||
"skill": {
|
||||
"package": "95598-repair-city-dispatch",
|
||||
"tool": "collect_repair_orders",
|
||||
"artifact_type": "repair-orders"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
}
|
||||
|
||||
impl Drop for TempSceneRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use tungstenite::{accept, Message};
|
||||
const RUNTIME_DROP_PANIC_TEXT: &str =
|
||||
"Cannot drop a runtime in a context where blocking is not allowed";
|
||||
|
||||
const TEST_ZHIHU_SKILLS_DIR: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills";
|
||||
|
||||
fn read_ws_text(stream: &mut tungstenite::WebSocket<std::net::TcpStream>) -> String {
|
||||
match stream.read().unwrap() {
|
||||
Message::Text(text) => text.to_string(),
|
||||
@@ -756,6 +758,7 @@ fn client_to_service_regression_routes_zhihu_through_callback_host_without_inval
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "{TEST_ZHIHU_SKILLS_DIR}",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
|
||||
@@ -14,6 +14,7 @@ use sgclaw::service::{ClientMessage, ServiceEventSink, ServiceMessage, ServiceSe
|
||||
|
||||
const RUNTIME_DROP_PANIC_TEXT: &str =
|
||||
"Cannot drop a runtime in a context where blocking is not allowed";
|
||||
const TEST_ZHIHU_SKILLS_DIR: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills";
|
||||
|
||||
fn read_ws_text<S>(stream: &mut tungstenite::WebSocket<S>) -> String
|
||||
where
|
||||
@@ -808,6 +809,7 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "{TEST_ZHIHU_SKILLS_DIR}",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
@@ -981,6 +983,7 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "{TEST_ZHIHU_SKILLS_DIR}",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
|
||||
Reference in New Issue
Block a user