commit 27a0ebe1d7db710f2669cc5b1fa1e41bd69d9637 Author: admin Date: Wed Jun 24 09:45:48 2026 +0800 Initial rrweb SGrobot skill package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..1315865 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,59 @@ +--- +name: rrweb-sgrobot-adapter +description: Generate SGrobot-compatible PlanTemplate.toml and runtime SKILL.toml artifacts from rrweb semantic traces. Use when adapting recorded browser workflows, rrweb traces, semantic-trace JSON, or browser automation recordings for SGrobot/sgclaw plan_templates and .sgclaw-workspace skills without changing SGrobot core. +--- + +# RRWeb SGrobot Adapter + +Use this skill to turn one rrweb semantic trace into SGrobot runtime artifacts: + +```text +plan_templates//PlanTemplate.toml +.sgclaw-workspace/skills/-execute/SKILL.toml +.sgclaw-workspace/skills/-execute/scripts/execute_trace.js +.sgclaw-workspace/skills/-execute/trace.semantic.json +``` + +Do not treat `SKILL.md` as the SGrobot runtime artifact. In SGrobot: + +- `PlanTemplate.toml` is the user-facing business task orchestration. +- `.sgclaw-workspace/skills/**/SKILL.toml` is the executable runtime skill. +- `.agents/skills/**/SKILL.md` is only an agent/Codex helper. + +## Workflow + +1. Read the semantic trace JSON and confirm it has `id`, `name`, `startUrl`, and non-empty `steps`. +2. Read [references/sgrobot-adapter.md](references/sgrobot-adapter.md) when you need SGrobot mapping details. +3. Generate artifacts with: + +```bash +node scripts/generate_sgrobot_flow.mjs \ + --trace examples/basic-trace.json \ + --out /path/to/sgrobot-architecture-design \ + --default-mode dry-run +``` + +Use `--default-mode execute` only when the user explicitly wants the generated skill to mutate the target page. Generated execute-mode plans set `requires_approval = true` when the trace includes mutating actions. + +4. Inspect the generated `PlanTemplate.toml` and `SKILL.toml` before handing off. The plan step `skills = ["..."]` must match the generated `[skill].name`. +5. In a live SGrobot repo, verify `/api/debug/plans` sees the plan and `/api/skill` sees the skill before running the digital employee task. + +## Trace Shape + +Minimal trace: + +```json +{ + "id": "expense-approval", + "name": "费用审批", + "description": "打开审批页,填写意见并点击同意。", + "startUrl": "https://oa.example.test/expense/123", + "steps": [ + { "type": "assertText", "text": "费用审批", "label": "确认在审批页" }, + { "type": "fill", "selector": "textarea[name='comment']", "value": "同意", "label": "填写审批意见" }, + { "type": "click", "selector": "button[data-action='approve']", "label": "点击同意", "risk": "write" } + ] +} +``` + +Supported first-version action types are `assertText`, `extractText`, `click`, `fill`, `input`, `type`, `select`, `check`, `uncheck`, and `submit`. Prefer stable CSS selectors; XPath is supported with `xpath=` or leading `//`. diff --git a/docs/superpowers/plans/2026-06-12-sgrobot-adapter.md b/docs/superpowers/plans/2026-06-12-sgrobot-adapter.md new file mode 100644 index 0000000..8ed276a --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-sgrobot-adapter.md @@ -0,0 +1,150 @@ +# SGrobot Adapter 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:** Build a reusable generator that turns one rrweb semantic trace into SGrobot-compatible `PlanTemplate.toml` and runtime `SKILL.toml` artifacts without changing SGrobot core. + +**Architecture:** Keep this repository as a Codex skill plus deterministic generator. The generator writes one business-specific plan and one business-specific sgBrowser runtime skill per recording, with the semantic trace baked into the generated page script and saved beside it as evidence. + +**Tech Stack:** Node.js ESM, `node:test`, SGrobot `PlanTemplate.toml`, SGrobot runtime `SKILL.toml`, sgBrowser `sgBrowserExcuteJsCodeByDomain`. + +--- + +### Task 1: Generator Contract + +**Files:** +- Create: `tests/generate_sgrobot_flow.test.mjs` +- Create: `scripts/generate_sgrobot_flow.mjs` +- Create: `package.json` + +- [ ] **Step 1: Write the failing tests** + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { generateSgRobotArtifacts, writeArtifacts } from "../scripts/generate_sgrobot_flow.mjs"; + +const trace = { + id: "expense-approval", + name: "费用审批", + description: "打开审批页,填写意见并点击同意。", + startUrl: "https://oa.example.test/expense/123", + steps: [ + { type: "assertText", text: "费用审批", label: "确认在审批页" }, + { type: "fill", selector: "textarea[name='comment']", value: "同意", label: "填写审批意见" }, + { type: "click", selector: "button[data-action='approve']", label: "点击同意", risk: "write" } + ] +}; + +test("generates SGrobot plan and runtime skill artifacts", () => { + const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" }); + assert.equal(result.summary.planId, "rrweb-expense-approval"); + assert.equal(result.summary.skillName, "rrweb-expense-approval-execute"); + assert.equal(result.summary.requiresApproval, true); + assert.equal(result.files.get("plan_templates/rrweb-expense-approval/PlanTemplate.toml").includes('skills = ["rrweb-expense-approval-execute"]'), true); + assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml").includes('command = "sgBrowserExcuteJsCodeByDomain"'), true); + assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml").includes('expected_domain = "oa.example.test"'), true); + assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/scripts/execute_trace.js").includes('"default_mode": "execute"'), true); +}); + +test("writes generated artifacts under a SGrobot repository root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rrweb-sgrobot-")); + try { + const result = generateSgRobotArtifacts(trace); + await writeArtifacts(root, result.files); + const plan = await readFile(path.join(root, "plan_templates/rrweb-expense-approval/PlanTemplate.toml"), "utf8"); + const skill = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"), "utf8"); + const evidence = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/trace.semantic.json"), "utf8"); + assert.match(plan, /skills = \["browser\.open_url"\]/); + assert.match(skill, /script_path = "scripts\/execute_trace.js"/); + assert.equal(JSON.parse(evidence).id, "expense-approval"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/generate_sgrobot_flow.test.mjs` + +Expected: FAIL with `Cannot find module '../scripts/generate_sgrobot_flow.mjs'`. + +- [ ] **Step 3: Implement the generator** + +Create `scripts/generate_sgrobot_flow.mjs` exporting `generateSgRobotArtifacts(trace, options)` and `writeArtifacts(root, files)`. The generator must validate `id`, `name`, `startUrl`, and `steps`, derive `expected_domain` from `startUrl`, emit SGrobot paths, and support `defaultMode` of `dry-run` or `execute`. + +- [ ] **Step 4: Add package metadata** + +Create `package.json` with: + +```json +{ + "name": "rrweb-sgrobot-skill", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test tests/*.test.mjs", + "generate:sgrobot": "node scripts/generate_sgrobot_flow.mjs" + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test` + +Expected: PASS with both generator tests green. + +### Task 2: Codex Skill Wrapper + +**Files:** +- Create: `SKILL.md` +- Create: `references/sgrobot-adapter.md` +- Create: `examples/basic-trace.json` + +- [ ] **Step 1: Write the skill instructions** + +Create a concise `SKILL.md` that tells Codex to use `scripts/generate_sgrobot_flow.mjs` when adapting rrweb semantic traces for SGrobot. It must state that `.agents/skills/**/SKILL.md` is only an agent helper, while SGrobot runtime artifacts are `PlanTemplate.toml` and `.sgclaw-workspace/skills/**/SKILL.toml`. + +- [ ] **Step 2: Add focused SGrobot mapping reference** + +Create `references/sgrobot-adapter.md` with the SGrobot-specific mapping rules: one generated plan per recording, one generated runtime skill per recording, `browser.open_url` first, generated sgBrowser execution skill second, optional `json.save_result` third, and step-level approval when execute mode includes mutating actions. + +- [ ] **Step 3: Add a realistic example trace** + +Create `examples/basic-trace.json` using the same schema covered by the test: `id`, `name`, `description`, `startUrl`, and `steps`. + +- [ ] **Step 4: Validate the skill folder** + +Run: `python3 /Users/zhaoyilun/.codex/skills/.system/skill-creator/scripts/quick_validate.py /Users/zhaoyilun/Documents/rrweb-skill` + +Expected: PASS. + +### Task 3: Verification Against SGrobot Shape + +**Files:** +- Read-only check: `/Users/zhaoyilun/projects/sgrobot-architecture-design/docs/concepts/skill-and-plan-template.md` +- Read-only check: `/Users/zhaoyilun/projects/sgrobot-architecture-design/docs/how-to/add-skill.md` + +- [ ] **Step 1: Generate sample output to a temp directory** + +Run: `node scripts/generate_sgrobot_flow.mjs --trace examples/basic-trace.json --out /tmp/rrweb-sgrobot-sample --default-mode dry-run --force` + +Expected: the command prints paths for the plan, skill, runtime script, and trace evidence. + +- [ ] **Step 2: Inspect generated files** + +Run: `find /tmp/rrweb-sgrobot-sample -type f | sort` + +Expected: files exist under `plan_templates/rrweb-expense-approval/` and `.sgclaw-workspace/skills/rrweb-expense-approval-execute/`. + +- [ ] **Step 3: Re-run tests** + +Run: `npm test` + +Expected: PASS. diff --git a/examples/basic-trace.json b/examples/basic-trace.json new file mode 100644 index 0000000..2151e95 --- /dev/null +++ b/examples/basic-trace.json @@ -0,0 +1,25 @@ +{ + "id": "expense-approval", + "name": "费用审批", + "description": "打开审批页,填写意见并点击同意。", + "startUrl": "https://oa.example.test/expense/123", + "steps": [ + { + "type": "assertText", + "text": "费用审批", + "label": "确认在审批页" + }, + { + "type": "fill", + "selector": "textarea[name='comment']", + "value": "同意", + "label": "填写审批意见" + }, + { + "type": "click", + "selector": "button[data-action='approve']", + "label": "点击同意", + "risk": "write" + } + ] +} diff --git a/examples/demo-site/customer-callback.html b/examples/demo-site/customer-callback.html new file mode 100644 index 0000000..7537434 --- /dev/null +++ b/examples/demo-site/customer-callback.html @@ -0,0 +1,210 @@ + + + + + + + 客户回访工单登记 + + + +
+
+

客户回访工单登记

+

低压故障抢修完成后,客服需要登记一次客户回访结果。

+
+ +
+
工单编号GD-20260612-0421
+
客户姓名李女士
+
回访任务待处理
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+ + + + diff --git a/examples/recordings/customer-callback-trace.json b/examples/recordings/customer-callback-trace.json new file mode 100644 index 0000000..d86523b --- /dev/null +++ b/examples/recordings/customer-callback-trace.json @@ -0,0 +1,31 @@ +{ + "id": "customer-callback-draft", + "name": "客户回访工单登记", + "description": "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。", + "startUrl": "http://127.0.0.1:4187/customer-callback.html", + "steps": [ + { + "type": "assertText", + "text": "客户回访工单登记", + "label": "确认进入客户回访工单页面" + }, + { + "type": "select", + "selector": "[data-trace=\"callback-result\"]", + "value": "satisfied", + "label": "选择回访结果" + }, + { + "type": "fill", + "selector": "[data-trace=\"callback-remark\"]", + "value": "已电话回访客户,现场供电恢复正常,客户确认满意。", + "label": "填写回访备注" + }, + { + "type": "click", + "selector": "[data-action=\"save-draft\"]", + "label": "点击保存草稿", + "risk": "write" + } + ] +} diff --git a/output/playwright/customer-callback-after-execute.png b/output/playwright/customer-callback-after-execute.png new file mode 100644 index 0000000..296ac20 Binary files /dev/null and b/output/playwright/customer-callback-after-execute.png differ diff --git a/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml new file mode 100644 index 0000000..58d6bc3 --- /dev/null +++ b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml @@ -0,0 +1,23 @@ +[skill] +name = "rrweb-customer-callback-draft-execute" +description = "Run the generated rrweb semantic trace for 客户回访工单登记 through sgBrowser." +version = "0.1.0" +author = "rrweb-skill" +tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"] + +prompts = [ + "Use rrweb-customer-callback-draft-execute only for the generated 客户回访工单登记 browser flow.", + "Run it after browser.open_url has opened the target page.", + "Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step." +] + +[[tools]] +name = "execute_trace" +description = "Replay or dry-run the generated 客户回访工单登记 semantic trace in sgBrowser." +kind = "sgbrowser_action" +command = "sgBrowserExcuteJsCodeByDomain" +expected_domain = "127.0.0.1" + +[tools.args] +script_path = "scripts/execute_trace.js" +mode = "Baked default mode is execute. Runtime may pass mode=execute or mode=dry-run if supported." diff --git a/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js new file mode 100644 index 0000000..8864427 --- /dev/null +++ b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js @@ -0,0 +1,240 @@ +const TRACE = { + "id": "customer-callback-draft", + "name": "客户回访工单登记", + "start_url": "http://127.0.0.1:4187/customer-callback.html", + "expected_domain": "127.0.0.1", + "default_mode": "execute", + "actions": [ + { + "type": "assertText", + "text": "客户回访工单登记", + "label": "确认进入客户回访工单页面" + }, + { + "type": "select", + "selector": "[data-trace=\"callback-result\"]", + "value": "satisfied", + "label": "选择回访结果" + }, + { + "type": "fill", + "selector": "[data-trace=\"callback-remark\"]", + "value": "已电话回访客户,现场供电恢复正常,客户确认满意。", + "label": "填写回访备注" + }, + { + "type": "click", + "selector": "[data-action=\"save-draft\"]", + "label": "点击保存草稿", + "risk": "write" + } + ] +}; +const input = + (typeof args === "object" && args) || + (typeof tool_args === "object" && tool_args) || + (typeof sgclaw_args === "object" && sgclaw_args) || + {}; + +function normalizeMode(value) { + const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase(); + return raw === "execute" ? "execute" : "dry-run"; +} + +function normalizeType(value) { + return String(value || "").replace(/[\s_-]+/g, "").toLowerCase(); +} + +function cleanText(value) { + return String(value || "").replace(/\s+/g, " ").trim(); +} + +function selectorList(action) { + const selectors = []; + if (action.selector) { + selectors.push(action.selector); + } + if (Array.isArray(action.selectors)) { + for (const selector of action.selectors) { + if (selector) { + selectors.push(selector); + } + } + } + return selectors; +} + +function findByXPath(xpath) { + try { + return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + } catch (_) { + return null; + } +} + +function findByText(text) { + const needle = cleanText(text); + if (!needle) { + return null; + } + const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label")); + return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null; +} + +function findElement(action) { + for (const rawSelector of selectorList(action)) { + const selector = String(rawSelector).trim(); + if (!selector) { + continue; + } + if (selector.startsWith("xpath=")) { + const node = findByXPath(selector.slice("xpath=".length)); + if (node) { + return node; + } + continue; + } + if (selector.startsWith("//") || selector.startsWith("(//")) { + const node = findByXPath(selector); + if (node) { + return node; + } + continue; + } + try { + const node = document.querySelector(selector); + if (node) { + return node; + } + } catch (_) { + continue; + } + } + return findByText(action.text || action.label); +} + +function describeElement(node) { + if (!node) { + return null; + } + return { + tag: String(node.tagName || "").toLowerCase(), + id: node.id || "", + name: node.getAttribute ? node.getAttribute("name") || "" : "", + text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120), + }; +} + +function dispatchFieldEvents(node) { + node.dispatchEvent(new Event("input", { bubbles: true })); + node.dispatchEvent(new Event("change", { bubbles: true })); +} + +function runAction(action, index, shouldExecute) { + const type = normalizeType(action.type); + const result = { + index, + type: action.type, + label: action.label || "", + mode: shouldExecute ? "execute" : "dry-run", + ok: true, + }; + + if (type === "asserttext" || type === "waitfortext") { + const needle = cleanText(action.text || action.value); + const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent); + result.ok = needle ? haystack.includes(needle) : false; + result.expected_text = needle; + return result; + } + + if (type === "extracttext") { + const target = findElement(action) || document.body || document.documentElement; + result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000)); + result.element = describeElement(target); + return result; + } + + const element = findElement(action); + result.element = describeElement(element); + if (!element) { + result.ok = false; + result.error = "target element not found"; + return result; + } + + if (type === "click" || type === "submit") { + if (shouldExecute) { + element.scrollIntoView({ block: "center", inline: "center" }); + if (typeof element.focus === "function") { + element.focus(); + } + if (type === "submit" && element.form) { + element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit(); + } else { + element.click(); + } + } + return result; + } + + if (type === "fill" || type === "input" || type === "type") { + result.value = action.value; + if (shouldExecute) { + element.focus && element.focus(); + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "select") { + result.value = action.value; + if (shouldExecute) { + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "check" || type === "uncheck") { + result.checked = type === "check"; + if (shouldExecute) { + element.checked = type === "check"; + dispatchFieldEvents(element); + } + return result; + } + + result.ok = false; + result.error = "unsupported action type"; + return result; +} + +const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode); +const shouldExecute = mode === "execute"; +const actionResults = []; +let ok = true; + +for (let index = 0; index < TRACE.actions.length; index += 1) { + const actionResult = runAction(TRACE.actions[index], index, shouldExecute); + actionResults.push(actionResult); + if (!actionResult.ok) { + ok = false; + break; + } +} + +return JSON.stringify({ + ok, + trace_id: TRACE.id, + trace_name: TRACE.name, + mode, + executed: shouldExecute, + expected_domain: TRACE.expected_domain, + url: window.location.href, + title: document.title || "", + action_count: TRACE.actions.length, + action_results: actionResults, + finished_at: new Date().toISOString(), +}); diff --git a/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json new file mode 100644 index 0000000..d86523b --- /dev/null +++ b/output/sgrobot-customer-callback/.sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json @@ -0,0 +1,31 @@ +{ + "id": "customer-callback-draft", + "name": "客户回访工单登记", + "description": "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。", + "startUrl": "http://127.0.0.1:4187/customer-callback.html", + "steps": [ + { + "type": "assertText", + "text": "客户回访工单登记", + "label": "确认进入客户回访工单页面" + }, + { + "type": "select", + "selector": "[data-trace=\"callback-result\"]", + "value": "satisfied", + "label": "选择回访结果" + }, + { + "type": "fill", + "selector": "[data-trace=\"callback-remark\"]", + "value": "已电话回访客户,现场供电恢复正常,客户确认满意。", + "label": "填写回访备注" + }, + { + "type": "click", + "selector": "[data-action=\"save-draft\"]", + "label": "点击保存草稿", + "risk": "write" + } + ] +} diff --git a/output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml b/output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml new file mode 100644 index 0000000..ec4ada0 --- /dev/null +++ b/output/sgrobot-customer-callback/plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml @@ -0,0 +1,30 @@ +[plan] +id = "rrweb-customer-callback-draft" +name = "客户回访工单登记" +description = "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。" +version = "1.0.0" + +[plan.trigger] +kind = "manual" + +[[plan.steps]] +id = "open-target-page" +title = "Open target page" +description = "Call browser.open_url to open http://127.0.0.1:4187/customer-callback.html." +skills = ["browser.open_url"] +depends_on = [] + +[[plan.steps]] +id = "run-recorded-flow" +title = "Run recorded browser flow" +description = "Execute the generated rrweb semantic trace through sgBrowser on 127.0.0.1. Default mode: execute." +skills = ["rrweb-customer-callback-draft-execute"] +depends_on = ["open-target-page"] +requires_approval = true + +[[plan.steps]] +id = "save-result" +title = "Save structured result" +description = "Save the generated flow result as a structured JSON artifact." +skills = ["json.save_result"] +depends_on = ["run-recorded-flow"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..4793479 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "rrweb-sgrobot-skill", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test tests/*.test.mjs", + "generate:sgrobot": "node scripts/generate_sgrobot_flow.mjs" + } +} diff --git a/references/sgrobot-adapter.md b/references/sgrobot-adapter.md new file mode 100644 index 0000000..7232a51 --- /dev/null +++ b/references/sgrobot-adapter.md @@ -0,0 +1,74 @@ +# SGrobot Adapter Reference + +## Boundary + +SGrobot uses two runtime artifact families: + +- `plan_templates//PlanTemplate.toml`: business orchestration, selectable as a digital employee task. +- `.sgclaw-workspace/skills//SKILL.toml`: atomic runtime capability loaded by Skill Catalog. + +The Codex-facing `SKILL.md` in this repository only teaches an agent how to generate those artifacts. + +## Generated Flow Shape + +For each rrweb semantic trace, generate one business-specific package: + +```text +plan_templates/rrweb-/PlanTemplate.toml +.sgclaw-workspace/skills/rrweb--execute/SKILL.toml +.sgclaw-workspace/skills/rrweb--execute/scripts/execute_trace.js +.sgclaw-workspace/skills/rrweb--execute/trace.semantic.json +``` + +Do not generate a single universal runtime skill that expects `trace_path` unless SGrobot core gains stable per-step argument passing. Current stable PlanTemplate usage binds a step to a skill name; the runtime resolves that skill's primary tool. + +## PlanTemplate Mapping + +Use three steps: + +1. `open-target-page`: `skills = ["browser.open_url"]`; description includes the trace `startUrl`. +2. `run-recorded-flow`: `skills = ["rrweb--execute"]`; executes generated page script with sgBrowser. +3. `save-result`: `skills = ["json.save_result"]`; saves structured output. + +Set `requires_approval = true` on `run-recorded-flow` when `--default-mode execute` is used and the trace includes mutating actions such as `click`, `fill`, `select`, `check`, `submit`, or `risk = "write"`. + +## Runtime Skill Mapping + +Generated `SKILL.toml` should use: + +```toml +[[tools]] +name = "execute_trace" +kind = "sgbrowser_action" +command = "sgBrowserExcuteJsCodeByDomain" +expected_domain = "" + +[tools.args] +script_path = "scripts/execute_trace.js" +``` + +Keep `sgBrowserExcuteJsCodeByDomain` and the `sgBrowerserOpenPage` spelling used by the existing SGrobot project. Do not replace this path with Playwright, CDP, or a generic browser action. + +## Runtime Script Rules + +The generated `execute_trace.js` runs inside the target page. It should: + +- Default to `dry-run` unless the generator is called with `--default-mode execute`. +- Resolve target elements by stable CSS selector first, XPath second, visible text fallback last. +- Return structured JSON with `ok`, `mode`, `trace_id`, `url`, `title`, and per-action results. +- Stop on the first failed action and include an error message. + +## Verification + +After generating into a SGrobot repo: + +```bash +node scripts/generate_sgrobot_flow.mjs --trace examples/basic-trace.json --out /Users/zhaoyilun/projects/sgrobot-architecture-design --default-mode dry-run +``` + +Then verify in SGrobot: + +```powershell +Invoke-RestMethod 'http://127.0.0.1:42617/api/debug/plans?limit=20' | ConvertTo-Json -Depth 5 +Invoke-RestMethod 'http://127.0.0.1:42617/api/skill' | ConvertTo-Json -Depth 4 +``` diff --git a/scripts/generate_sgrobot_flow.mjs b/scripts/generate_sgrobot_flow.mjs new file mode 100644 index 0000000..255b421 --- /dev/null +++ b/scripts/generate_sgrobot_flow.mjs @@ -0,0 +1,536 @@ +#!/usr/bin/env node +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MUTATING_TYPES = new Set(["click", "fill", "input", "type", "select", "check", "uncheck", "submit", "press"]); +const WRITE_RISKS = new Set(["write", "dangerous", "destructive", "submit", "payment", "approval"]); + +export function generateSgRobotArtifacts(trace, options = {}) { + const normalized = normalizeTrace(trace, options); + const files = new Map(); + const skillRoot = `.sgclaw-workspace/skills/${normalized.skillName}`; + + files.set(`plan_templates/${normalized.planId}/PlanTemplate.toml`, renderPlanTemplate(normalized)); + files.set(`${skillRoot}/SKILL.toml`, renderSkillToml(normalized)); + files.set(`${skillRoot}/scripts/execute_trace.js`, renderRuntimeScript(normalized)); + files.set(`${skillRoot}/trace.semantic.json`, `${JSON.stringify(trace, null, 2)}\n`); + + return { + summary: { + planId: normalized.planId, + skillName: normalized.skillName, + expectedDomain: normalized.expectedDomain, + defaultMode: normalized.defaultMode, + requiresApproval: normalized.requiresApproval, + actionCount: normalized.actions.length, + }, + files, + }; +} + +export async function writeArtifacts(root, files, options = {}) { + const rootPath = path.resolve(root); + const written = []; + + for (const [relativePath, content] of files.entries()) { + if (path.isAbsolute(relativePath) || relativePath.includes("..")) { + throw new Error(`Refusing unsafe generated path: ${relativePath}`); + } + + const targetPath = path.resolve(rootPath, relativePath); + if (!targetPath.startsWith(`${rootPath}${path.sep}`)) { + throw new Error(`Refusing path outside output root: ${relativePath}`); + } + + if (!options.force) { + try { + await access(targetPath); + throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`); + } catch (error) { + if (error && error.code !== "ENOENT") { + throw error; + } + } + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content, "utf8"); + written.push(targetPath); + } + + return written; +} + +function normalizeTrace(trace, options) { + if (!trace || typeof trace !== "object" || Array.isArray(trace)) { + throw new Error("Trace must be a JSON object."); + } + + const id = requireString(trace.id, "trace.id"); + const name = requireString(trace.name, "trace.name"); + const startUrl = requireString(trace.startUrl || trace.start_url, "trace.startUrl"); + const start = parseHttpUrl(startUrl, "trace.startUrl"); + const actions = Array.isArray(trace.steps) ? trace.steps : trace.actions; + + if (!Array.isArray(actions) || actions.length === 0) { + throw new Error("Trace must include a non-empty steps array."); + } + + const baseId = toKebabId(id); + const planId = cleanId(trace.planId || trace.plan_id || `rrweb-${baseId}`, "plan id"); + const skillName = cleanId(trace.skillName || trace.skill_name || `${planId}-execute`, "skill name"); + const expectedDomain = String(trace.expectedDomain || trace.expected_domain || start.hostname).trim(); + const defaultMode = normalizeMode(options.defaultMode || options.default_mode || trace.defaultMode || trace.default_mode || "dry-run"); + const normalizedActions = actions.map((action, index) => normalizeAction(action, index)); + const hasMutatingAction = normalizedActions.some(isMutatingAction); + const requiresApproval = + trace.requiresApproval === true || + trace.requires_approval === true || + (defaultMode === "execute" && hasMutatingAction); + + return { + id, + name, + description: String(trace.description || `${name} generated from rrweb semantic trace.`), + startUrl: start.href, + planId, + skillName, + expectedDomain, + defaultMode, + requiresApproval, + actions: normalizedActions, + }; +} + +function normalizeAction(action, index) { + if (!action || typeof action !== "object" || Array.isArray(action)) { + throw new Error(`Trace step ${index + 1} must be an object.`); + } + + const type = requireString(action.type || action.action, `trace.steps[${index}].type`); + const normalized = { + ...action, + type, + label: String(action.label || action.name || `${type} #${index + 1}`), + }; + + if (needsTarget(type) && !action.selector && !Array.isArray(action.selectors) && !action.text) { + throw new Error(`Trace step ${index + 1} (${type}) needs selector, selectors, or text.`); + } + + if (needsValue(type) && action.value === undefined) { + throw new Error(`Trace step ${index + 1} (${type}) needs value.`); + } + + return normalized; +} + +function renderPlanTemplate(trace) { + const approvalLine = trace.requiresApproval ? "\nrequires_approval = true" : ""; + + return `[plan] +id = ${tomlString(trace.planId)} +name = ${tomlString(trace.name)} +description = ${tomlString(trace.description)} +version = "1.0.0" + +[plan.trigger] +kind = "manual" + +[[plan.steps]] +id = "open-target-page" +title = "Open target page" +description = ${tomlString(`Call browser.open_url to open ${trace.startUrl}.`)} +skills = ["browser.open_url"] +depends_on = [] + +[[plan.steps]] +id = "run-recorded-flow" +title = "Run recorded browser flow" +description = ${tomlString(`Execute the generated rrweb semantic trace through sgBrowser on ${trace.expectedDomain}. Default mode: ${trace.defaultMode}.`)} +skills = [${tomlString(trace.skillName)}] +depends_on = ["open-target-page"]${approvalLine} + +[[plan.steps]] +id = "save-result" +title = "Save structured result" +description = "Save the generated flow result as a structured JSON artifact." +skills = ["json.save_result"] +depends_on = ["run-recorded-flow"] +`; +} + +function renderSkillToml(trace) { + return `[skill] +name = ${tomlString(trace.skillName)} +description = ${tomlString(`Run the generated rrweb semantic trace for ${trace.name} through sgBrowser.`)} +version = "0.1.0" +author = "rrweb-skill" +tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"] + +prompts = [ + ${tomlString(`Use ${trace.skillName} only for the generated ${trace.name} browser flow.`)}, + "Run it after browser.open_url has opened the target page.", + "Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step." +] + +[[tools]] +name = "execute_trace" +description = ${tomlString(`Replay or dry-run the generated ${trace.name} semantic trace in sgBrowser.`)} +kind = "sgbrowser_action" +command = "sgBrowserExcuteJsCodeByDomain" +expected_domain = ${tomlString(trace.expectedDomain)} + +[tools.args] +script_path = "scripts/execute_trace.js" +mode = ${tomlString(`Baked default mode is ${trace.defaultMode}. Runtime may pass mode=execute or mode=dry-run if supported.`)} +`; +} + +function renderRuntimeScript(trace) { + const runtimeTrace = { + id: trace.id, + name: trace.name, + start_url: trace.startUrl, + expected_domain: trace.expectedDomain, + default_mode: trace.defaultMode, + actions: trace.actions, + }; + + return `const TRACE = ${JSON.stringify(runtimeTrace, null, 2)}; +const input = + (typeof args === "object" && args) || + (typeof tool_args === "object" && tool_args) || + (typeof sgclaw_args === "object" && sgclaw_args) || + {}; + +function normalizeMode(value) { + const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase(); + return raw === "execute" ? "execute" : "dry-run"; +} + +function normalizeType(value) { + return String(value || "").replace(/[\\s_-]+/g, "").toLowerCase(); +} + +function cleanText(value) { + return String(value || "").replace(/\\s+/g, " ").trim(); +} + +function selectorList(action) { + const selectors = []; + if (action.selector) { + selectors.push(action.selector); + } + if (Array.isArray(action.selectors)) { + for (const selector of action.selectors) { + if (selector) { + selectors.push(selector); + } + } + } + return selectors; +} + +function findByXPath(xpath) { + try { + return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + } catch (_) { + return null; + } +} + +function findByText(text) { + const needle = cleanText(text); + if (!needle) { + return null; + } + const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label")); + return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null; +} + +function findElement(action) { + for (const rawSelector of selectorList(action)) { + const selector = String(rawSelector).trim(); + if (!selector) { + continue; + } + if (selector.startsWith("xpath=")) { + const node = findByXPath(selector.slice("xpath=".length)); + if (node) { + return node; + } + continue; + } + if (selector.startsWith("//") || selector.startsWith("(//")) { + const node = findByXPath(selector); + if (node) { + return node; + } + continue; + } + try { + const node = document.querySelector(selector); + if (node) { + return node; + } + } catch (_) { + continue; + } + } + return findByText(action.text || action.label); +} + +function describeElement(node) { + if (!node) { + return null; + } + return { + tag: String(node.tagName || "").toLowerCase(), + id: node.id || "", + name: node.getAttribute ? node.getAttribute("name") || "" : "", + text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120), + }; +} + +function dispatchFieldEvents(node) { + node.dispatchEvent(new Event("input", { bubbles: true })); + node.dispatchEvent(new Event("change", { bubbles: true })); +} + +function runAction(action, index, shouldExecute) { + const type = normalizeType(action.type); + const result = { + index, + type: action.type, + label: action.label || "", + mode: shouldExecute ? "execute" : "dry-run", + ok: true, + }; + + if (type === "asserttext" || type === "waitfortext") { + const needle = cleanText(action.text || action.value); + const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent); + result.ok = needle ? haystack.includes(needle) : false; + result.expected_text = needle; + return result; + } + + if (type === "extracttext") { + const target = findElement(action) || document.body || document.documentElement; + result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000)); + result.element = describeElement(target); + return result; + } + + const element = findElement(action); + result.element = describeElement(element); + if (!element) { + result.ok = false; + result.error = "target element not found"; + return result; + } + + if (type === "click" || type === "submit") { + if (shouldExecute) { + element.scrollIntoView({ block: "center", inline: "center" }); + if (typeof element.focus === "function") { + element.focus(); + } + if (type === "submit" && element.form) { + element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit(); + } else { + element.click(); + } + } + return result; + } + + if (type === "fill" || type === "input" || type === "type") { + result.value = action.value; + if (shouldExecute) { + element.focus && element.focus(); + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "select") { + result.value = action.value; + if (shouldExecute) { + element.value = String(action.value); + dispatchFieldEvents(element); + } + return result; + } + + if (type === "check" || type === "uncheck") { + result.checked = type === "check"; + if (shouldExecute) { + element.checked = type === "check"; + dispatchFieldEvents(element); + } + return result; + } + + result.ok = false; + result.error = "unsupported action type"; + return result; +} + +const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode); +const shouldExecute = mode === "execute"; +const actionResults = []; +let ok = true; + +for (let index = 0; index < TRACE.actions.length; index += 1) { + const actionResult = runAction(TRACE.actions[index], index, shouldExecute); + actionResults.push(actionResult); + if (!actionResult.ok) { + ok = false; + break; + } +} + +return JSON.stringify({ + ok, + trace_id: TRACE.id, + trace_name: TRACE.name, + mode, + executed: shouldExecute, + expected_domain: TRACE.expected_domain, + url: window.location.href, + title: document.title || "", + action_count: TRACE.actions.length, + action_results: actionResults, + finished_at: new Date().toISOString(), +}); +`; +} + +function isMutatingAction(action) { + const type = String(action.type || "").trim().toLowerCase(); + const risk = String(action.risk || "").trim().toLowerCase(); + return MUTATING_TYPES.has(type) || WRITE_RISKS.has(risk); +} + +function needsTarget(type) { + const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase(); + return !["asserttext", "waitfortext"].includes(normalized); +} + +function needsValue(type) { + const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase(); + return ["fill", "input", "type", "select"].includes(normalized); +} + +function normalizeMode(value) { + const mode = String(value || "").trim().toLowerCase(); + if (mode === "dry-run" || mode === "dryrun" || mode === "dry_run") { + return "dry-run"; + } + if (mode === "execute") { + return "execute"; + } + throw new Error(`Unsupported default mode: ${value}`); +} + +function cleanId(value, label) { + const cleaned = toKebabId(value); + if (!cleaned) { + throw new Error(`Invalid ${label}: ${value}`); + } + return cleaned; +} + +function toKebabId(value) { + return String(value || "") + .trim() + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function requireString(value, label) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string.`); + } + return value.trim(); +} + +function parseHttpUrl(value, label) { + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("Only http/https URLs are supported."); + } + return url; + } catch (error) { + throw new Error(`${label} must be a valid http/https URL. ${error.message}`); + } +} + +function tomlString(value) { + return JSON.stringify(String(value)); +} + +function parseCliArgs(argv) { + const args = { + defaultMode: "dry-run", + force: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--trace") { + args.trace = argv[++index]; + } else if (token === "--out") { + args.out = argv[++index]; + } else if (token === "--default-mode" || token === "--mode") { + args.defaultMode = argv[++index]; + } else if (token === "--force") { + args.force = true; + } else if (token === "--help" || token === "-h") { + args.help = true; + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + + return args; +} + +function usage() { + return `Usage: + node scripts/generate_sgrobot_flow.mjs --trace --out [--default-mode dry-run|execute] [--force] +`; +} + +async function main() { + const cli = parseCliArgs(process.argv.slice(2)); + if (cli.help) { + console.log(usage()); + return; + } + if (!cli.trace || !cli.out) { + throw new Error(`${usage()}Both --trace and --out are required.`); + } + + const trace = JSON.parse(await readFile(cli.trace, "utf8")); + const result = generateSgRobotArtifacts(trace, { defaultMode: cli.defaultMode }); + const written = await writeArtifacts(cli.out, result.files, { force: cli.force }); + + console.log(JSON.stringify({ ...result.summary, written }, null, 2)); +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/validate_sgrobot_artifacts.mjs b/scripts/validate_sgrobot_artifacts.mjs new file mode 100644 index 0000000..5c8c904 --- /dev/null +++ b/scripts/validate_sgrobot_artifacts.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(process.argv[2] || "."); +const planId = process.argv[3]; +const skillName = process.argv[4]; + +if (!planId || !skillName) { + console.error("Usage: node scripts/validate_sgrobot_artifacts.mjs "); + process.exit(1); +} + +const planPath = path.join(root, "plan_templates", planId, "PlanTemplate.toml"); +const skillPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "SKILL.toml"); +const scriptPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "scripts", "execute_trace.js"); +const tracePath = path.join(root, ".sgclaw-workspace", "skills", skillName, "trace.semantic.json"); + +const [plan, skill, script, traceText] = await Promise.all([ + readFile(planPath, "utf8"), + readFile(skillPath, "utf8"), + readFile(scriptPath, "utf8"), + readFile(tracePath, "utf8"), +]); + +const trace = JSON.parse(traceText); +const checks = [ + ["plan has [plan]", plan.includes("[plan]")], + ["plan id matches", plan.includes(`id = "${planId}"`)], + ["plan opens page via browser.open_url", plan.includes('skills = ["browser.open_url"]')], + ["plan calls generated skill", plan.includes(`skills = ["${skillName}"]`)], + ["plan saves result", plan.includes('skills = ["json.save_result"]')], + ["write flow requires approval", plan.includes("requires_approval = true")], + ["skill name matches", skill.includes(`name = "${skillName}"`)], + ["skill uses sgBrowser action", skill.includes('kind = "sgbrowser_action"')], + ["skill uses sgBrowser JS by domain", skill.includes('command = "sgBrowserExcuteJsCodeByDomain"')], + ["skill references execute script", skill.includes('script_path = "scripts/execute_trace.js"')], + ["runtime script contains trace", script.includes(`"id": "${trace.id}"`)], + ["runtime script returns structured JSON", script.includes("action_results")], + ["trace has realistic steps", Array.isArray(trace.steps) && trace.steps.length >= 4], +]; + +const failed = checks.filter(([, ok]) => !ok); +for (const [name, ok] of checks) { + console.log(`${ok ? "ok" : "not ok"} - ${name}`); +} + +if (failed.length > 0) { + process.exitCode = 1; +} diff --git a/tests/customer_callback_demo.test.mjs b/tests/customer_callback_demo.test.mjs new file mode 100644 index 0000000..63bbea7 --- /dev/null +++ b/tests/customer_callback_demo.test.mjs @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { generateSgRobotArtifacts } from "../scripts/generate_sgrobot_flow.mjs"; + +test("recorded customer callback flow generates SGrobot-compliant artifacts", async () => { + const trace = JSON.parse(await readFile("examples/recordings/customer-callback-trace.json", "utf8")); + const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" }); + + const plan = result.files.get("plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml"); + const skill = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml"); + const script = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js"); + const evidence = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json"); + + assert.equal(result.summary.planId, "rrweb-customer-callback-draft"); + assert.equal(result.summary.skillName, "rrweb-customer-callback-draft-execute"); + assert.equal(result.summary.requiresApproval, true); + assert.match(plan, /skills = \["browser\.open_url"\]/); + assert.match(plan, /skills = \["rrweb-customer-callback-draft-execute"\]/); + assert.match(plan, /requires_approval = true/); + assert.match(skill, /kind = "sgbrowser_action"/); + assert.match(skill, /command = "sgBrowserExcuteJsCodeByDomain"/); + assert.match(skill, /script_path = "scripts\/execute_trace.js"/); + assert.match(script, /"type": "select"/); + assert.match(script, /"type": "fill"/); + assert.match(script, /"risk": "write"/); + assert.equal(JSON.parse(evidence).steps.length, 4); +}); diff --git a/tests/generate_sgrobot_flow.test.mjs b/tests/generate_sgrobot_flow.test.mjs new file mode 100644 index 0000000..5296732 --- /dev/null +++ b/tests/generate_sgrobot_flow.test.mjs @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { generateSgRobotArtifacts, writeArtifacts } from "../scripts/generate_sgrobot_flow.mjs"; + +const trace = { + id: "expense-approval", + name: "费用审批", + description: "打开审批页,填写意见并点击同意。", + startUrl: "https://oa.example.test/expense/123", + steps: [ + { type: "assertText", text: "费用审批", label: "确认在审批页" }, + { type: "fill", selector: "textarea[name='comment']", value: "同意", label: "填写审批意见" }, + { type: "click", selector: "button[data-action='approve']", label: "点击同意", risk: "write" }, + ], +}; + +test("generates SGrobot plan and runtime skill artifacts", () => { + const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" }); + + assert.equal(result.summary.planId, "rrweb-expense-approval"); + assert.equal(result.summary.skillName, "rrweb-expense-approval-execute"); + assert.equal(result.summary.requiresApproval, true); + + const plan = result.files.get("plan_templates/rrweb-expense-approval/PlanTemplate.toml"); + const skill = result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"); + const script = result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/scripts/execute_trace.js"); + + assert.equal(plan.includes('skills = ["rrweb-expense-approval-execute"]'), true); + assert.equal(plan.includes("requires_approval = true"), true); + assert.equal(skill.includes('command = "sgBrowserExcuteJsCodeByDomain"'), true); + assert.equal(skill.includes('expected_domain = "oa.example.test"'), true); + assert.equal(script.includes('"default_mode": "execute"'), true); + assert.equal(script.includes('replace(/[\\s_-]+/g, "")'), true); + assert.equal(script.includes('replace(/[\\\\s_-]+/g, "")'), false); +}); + +test("writes generated artifacts under a SGrobot repository root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rrweb-sgrobot-")); + try { + const result = generateSgRobotArtifacts(trace); + await writeArtifacts(root, result.files); + + const plan = await readFile(path.join(root, "plan_templates/rrweb-expense-approval/PlanTemplate.toml"), "utf8"); + const skill = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"), "utf8"); + const evidence = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/trace.semantic.json"), "utf8"); + + assert.match(plan, /skills = \["browser\.open_url"\]/); + assert.match(skill, /script_path = "scripts\/execute_trace.js"/); + assert.equal(JSON.parse(evidence).id, "expense-approval"); + } finally { + await rm(root, { recursive: true, force: true }); + } +});