diff --git a/docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md b/docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md deleted file mode 100644 index 90b5bc8..0000000 --- a/docs/superpowers/plans/2026-04-06-scene-skill-runtime-routing-plan.md +++ /dev/null @@ -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 -pub fn match_scene_instruction(instruction: &str) -> Option -``` - -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" -``` diff --git a/docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md b/docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md deleted file mode 100644 index 91cc880..0000000 --- a/docs/superpowers/specs/2026-04-06-scene-skill-runtime-routing-design.md +++ /dev/null @@ -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. diff --git a/src/agent/task_runner.rs b/src/agent/task_runner.rs index 686a2a9..03b01ab 100644 --- a/src/agent/task_runner.rs +++ b/src/agent/task_runner.rs @@ -176,7 +176,7 @@ pub fn run_submit_task( 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( }); 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::>().join(", ")), + message: format!("skills dir resolved to {}", resolved_skills_dir.display()), }); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), @@ -310,7 +310,7 @@ pub fn run_submit_task_with_browser_backend( 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 +323,7 @@ pub fn run_submit_task_with_browser_backend( }); 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::>().join(", ")), + message: format!("skills dir resolved to {}", resolved_skills_dir.display()), }); let _ = sink.send(&AgentMessage::LogEntry { level: "info".to_string(), diff --git a/src/compat/config_adapter.rs b/src/compat/config_adapter.rs index 6bfb7ce..cbd8554 100644 --- a/src/compat/config_adapter.rs +++ b/src/compat/config_adapter.rs @@ -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 { - 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 { - resolve_skills_dir_paths(workspace_root, &settings.skills_dir) -} - -pub fn resolve_scene_skills_dir_from_sgclaw_settings( - workspace_root: &Path, - settings: &SgClawSettings, -) -> Vec { - 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 { - if configured_dirs.is_empty() { - vec![zeroclaw_default_skills_dir(workspace_root)] - } else { - configured_dirs - .iter() - .map(|d| normalize_configured_skills_dir(d)) - .collect() - } -} diff --git a/src/compat/runtime.rs b/src/compat/runtime.rs index c58d615..7193235 100644 --- a/src/compat/runtime.rs +++ b/src/compat/runtime.rs @@ -146,12 +146,12 @@ pub async fn execute_task_with_provider( instruction: &str, task_context: &CompatTaskContext, config: ZeroClawConfig, - skills_dir: Vec, + skills_dir: PathBuf, settings: SgClawSettings, ) -> Result { 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, diff --git a/src/compat/workflow_executor.rs b/src/compat/workflow_executor.rs index b3fa78b..5675f56 100644 --- a/src/compat/workflow_executor.rs +++ b/src/compat/workflow_executor.rs @@ -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 { - 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 @@ -153,13 +138,6 @@ pub fn execute_route_with_browser_backend( settings: &SgClawSettings, ) -> Result { 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)?; @@ -232,157 +210,6 @@ pub fn execute_route( ) } -fn execute_fault_details_route( - browser_backend: Arc, - instruction: &str, - workspace_root: &Path, - settings: &SgClawSettings, - page_url: Option<&str>, -) -> Result { - 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::>().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 { - page_url - .and_then(host_from_url) - .or_else(|| host_from_url(fallback)) -} - -fn host_from_url(raw: &str) -> Option { - 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 { - 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::().ok()) - .unwrap_or(1); - Some(format!("{year}-{month:02}")) - }); - derived -} - fn collect_hotlist_items( transport: &dyn crate::agent::AgentEventSink, browser_tool: &dyn BrowserBackend, @@ -1275,7 +1102,7 @@ mod tests { "test-key".to_string(), "http://127.0.0.1:9".to_string(), "deepseek-chat".to_string(), - Vec::new(), + None, ) .unwrap() } diff --git a/src/config/settings.rs b/src/config/settings.rs index b7a45ab..dc60e58 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,7 +1,6 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; -use serde::de; use thiserror::Error; use crate::runtime::RuntimeProfile; @@ -106,7 +105,7 @@ pub struct DeepSeekSettings { pub api_key: String, pub base_url: String, pub model: String, - pub skills_dir: Vec, + pub skills_dir: Option, } impl DeepSeekSettings { @@ -125,7 +124,7 @@ pub struct SgClawSettings { pub provider_api_key: String, pub provider_base_url: String, pub provider_model: String, - pub skills_dir: Vec, + pub skills_dir: Option, pub skills_prompt_mode: SkillsPromptMode, pub runtime_profile: RuntimeProfile, pub planner_mode: PlannerMode, @@ -156,7 +155,7 @@ impl SgClawSettings { api_key: String, base_url: String, model: String, - skills_dir: Vec, + skills_dir: Option, ) -> Result { Self::new( api_key, @@ -199,7 +198,7 @@ impl SgClawSettings { api_key, base_url, model, - Vec::new(), + None, None, None, None, @@ -284,7 +283,7 @@ 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), skills_prompt_mode, runtime_profile, planner_mode, @@ -302,7 +301,7 @@ impl SgClawSettings { api_key: String, base_url: String, model: String, - skills_dir: Vec, + skills_dir: Option, skills_prompt_mode: Option, runtime_profile: Option, planner_mode: Option, @@ -433,18 +432,11 @@ fn parse_office_backend(raw: &str) -> Result { } } -fn resolve_configured_skills_dirs(raw: Vec, config_dir: &Path) -> Vec { - 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, config_dir: &Path) -> Option { + 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 { @@ -486,49 +478,6 @@ fn normalize_enum_token(raw: &str) -> String { .to_ascii_lowercase() } -fn deserialize_skills_dirs<'de, D>(deserializer: D) -> Result, D::Error> -where - D: de::Deserializer<'de>, -{ - struct StringOrVec; - - impl<'de> de::Visitor<'de> for StringOrVec { - type Value = Vec; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string or array of strings") - } - - fn visit_str(self, value: &str) -> Result, E> { - if value.trim().is_empty() { - Ok(Vec::new()) - } else { - Ok(vec![value.to_string()]) - } - } - - fn visit_seq>(self, mut seq: A) -> Result, A::Error> { - let mut dirs = Vec::new(); - while let Some(value) = seq.next_element::()? { - if !value.trim().is_empty() { - dirs.push(value); - } - } - Ok(dirs) - } - - fn visit_none(self) -> Result, E> { - Ok(Vec::new()) - } - - fn visit_unit(self) -> Result, E> { - Ok(Vec::new()) - } - } - - deserializer.deserialize_any(StringOrVec) -} - #[derive(Debug, Deserialize)] struct RawSgClawSettings { #[serde(rename = "apiKey", default)] @@ -537,8 +486,8 @@ struct RawSgClawSettings { base_url: String, #[serde(default)] model: String, - #[serde(rename = "skillsDir", alias = "skills_dir", default, deserialize_with = "deserialize_skills_dirs")] - skills_dir: Vec, + #[serde(rename = "skillsDir", alias = "skills_dir", default)] + skills_dir: Option, #[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)] skills_prompt_mode: Option, #[serde(rename = "runtimeProfile", alias = "runtime_profile", default)] diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index 7aafc06..cfd55ed 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -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 { - 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 } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index c610554..8c919cc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -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; diff --git a/src/runtime/scene_registry.rs b/src/runtime/scene_registry.rs deleted file mode 100644 index 6655595..0000000 --- a/src/runtime/scene_registry.rs +++ /dev/null @@ -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, - pub inputs: Vec, - pub outputs: Vec, - pub skill_package: String, - pub skill_tool: String, - pub skill_artifact_type: String, - pub dispatch_mode: DispatchMode, - pub expected_domain: String, - pub aliases: Vec, - pub default_args: Map, -} - -#[derive(Debug, Deserialize)] -struct SceneMetadata { - id: String, - name: String, - summary: String, - #[serde(default)] - tags: Vec, - #[serde(default)] - inputs: Vec, - #[serde(default)] - outputs: Vec, - 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 { - load_scene_registry_from_root(Path::new(STAGED_SCENE_ROOT)) -} - -pub fn load_scene_registry_from_root(root: &Path) -> Vec { - 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 { - 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 { - 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 { - 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 { - 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 { - 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() -} diff --git a/tests/compat_config_test.rs b/tests/compat_config_test.rs index a3bdae1..31d6b3a 100644 --- a/tests/compat_config_test.rs +++ b/tests/compat_config_test.rs @@ -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(); @@ -216,10 +187,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 +270,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(); diff --git a/tests/compat_cron_test.rs b/tests/compat_cron_test.rs index cbd06fe..e7c9f6d 100644 --- a/tests/compat_cron_test.rs +++ b/tests/compat_cron_test.rs @@ -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); diff --git a/tests/compat_memory_test.rs b/tests/compat_memory_test.rs index 5fafdac..74f712c 100644 --- a/tests/compat_memory_test.rs +++ b/tests/compat_memory_test.rs @@ -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); diff --git a/tests/compat_runtime_test.rs b/tests/compat_runtime_test.rs index 97f39e2..475efe2 100644 --- a/tests/compat_runtime_test.rs +++ b/tests/compat_runtime_test.rs @@ -118,17 +118,6 @@ fn real_skill_lib_root() -> PathBuf { .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() -} - fn success_browser_response(seq: u64, data: Value) -> BrowserMessage { BrowserMessage::Response { seq, @@ -423,7 +412,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( @@ -961,7 +950,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 +1049,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 +1111,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 +1187,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 +1265,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 +1350,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 +1457,7 @@ return { "deepseek-test-key".to_string(), base_url, "deepseek-chat".to_string(), - Vec::new(), + None, ) .unwrap(); settings.runtime_profile = RuntimeProfile::BrowserAttached; @@ -1539,7 +1528,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 +1582,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 +1633,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 +1688,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 +1770,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 +1907,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 +1959,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 +2590,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 +2608,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 +2646,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!( diff --git a/tests/deepseek_provider_test.rs b/tests/deepseek_provider_test.rs index 53d6d64..ef1b00f 100644 --- a/tests/deepseek_provider_test.rs +++ b/tests/deepseek_provider_test.rs @@ -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 { diff --git a/tests/runtime_profile_test.rs b/tests/runtime_profile_test.rs index 1f354e8..d7d1cf7 100644 --- a/tests/runtime_profile_test.rs +++ b/tests/runtime_profile_test.rs @@ -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(); diff --git a/tests/scene_registry_test.rs b/tests/scene_registry_test.rs deleted file mode 100644 index d5a54f5..0000000 --- a/tests/scene_registry_test.rs +++ /dev/null @@ -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); - } -}