feat(dsh-plugin): add Browser tool parity

This commit is contained in:
drakezhang
2026-08-22 21:15:53 +08:00
parent 2719e76438
commit cad0b5cbc9
20 changed files with 2410 additions and 138 deletions
@@ -530,6 +530,9 @@ describe("actionForLabel", () => {
expect(actionForLabel("navigate")).toBe("navigating");
expect(actionForLabel("screenshot")).toBe("capturing");
expect(actionForLabel("session start")).toBe("starting");
expect(actionForLabel("tab borrow")).toBe("borrowing tab");
expect(actionForLabel("request-help")).toBe("waiting for user");
expect(actionForLabel("wait-for-navigation")).toBe("waiting for navigation");
});
});
@@ -1,4 +1,4 @@
// BSK skill injection: registration wiring, catalog content, progressive-load
// Browser skill injection: registration wiring, catalog content, progressive-load
// weight, and silent degradation without the skill seam.
import { describe, expect, it } from "vitest";
@@ -9,7 +9,7 @@ function fakeCtx(skills?: unknown) {
}
describe("registerBskSkill", () => {
it("registers the catalog entry with the assembled body", () => {
it("registers the catalog entry with the DSH browser-tool body", () => {
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
const skills = {
register(skill: Record<string, unknown>) {
@@ -26,23 +26,51 @@ describe("registerBskSkill", () => {
expect(skill.name).toBe("browser-skill");
expect(typeof skill.description).toBe("string");
expect(String(skill.description)).toContain("browser_*");
// body = dsh prelude + canonical CLI skill body + dsh postlude
const content = String(skill.content);
expect(content).toContain("browser_observe");
expect(String(skill.description)).toContain("never invoke bsk through bash or shell");
expect(content).toContain("Always call those injected `browser_*` tools");
expect(content).toContain("Never use `bash`, `shell`, `exec`");
expect(content).toContain("## DSH routing reminder");
expect(content.trimEnd()).toMatch(
/always begin with[\s\S]*`browser_session_start`[\s\S]*`browser_session_stop`\.$/,
);
expect(content).toContain("Owned sessions only");
const mentionedTools = [...new Set(content.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? [])].sort();
expect(mentionedTools).toEqual([
"browser_click",
"browser_console",
"browser_emulate",
"browser_fill",
"browser_get_html",
"browser_hover",
"browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_network",
"browser_observe",
"browser_press",
"browser_reload",
"browser_request_help",
"browser_screenshot",
"browser_select",
"browser_session_list",
"browser_session_start",
"browser_session_stop",
"browser_snapshot",
"browser_tab_borrow",
"browser_tab_close",
"browser_tab_create",
"browser_tab_list",
"browser_tab_return",
"browser_tab_select",
"browser_wait_for_navigation",
"browser_window_resize",
]);
expect(String(skill.description)).not.toMatch(/\bbsk\b/i);
expect(content).not.toMatch(/\bbsk\b/i);
expect(content).not.toMatch(/```(?:bash|sh|shell)\b/i);
expect(content).not.toMatch(/--[a-z]/);
expect(content).toContain("All browser operations must use the injected tools directly");
expect(content).toContain("Mandatory workflow");
expect(content).toContain("Refs invalidate after navigation");
expect(content).toContain("evaluation and interaction recording are intentionally unsupported");
expect(content.length).toBeGreaterThan(10_000);
expect(skill.source).toBe("bundled");
// the original YAML frontmatter must not leak into the body
expect(content.includes("name: browser-skill\ndescription:")).toBe(false);
// Source frontmatter is registration metadata and must not leak into the body.
expect(content.startsWith("---")).toBe(false);
expect(content).not.toContain("name: browser-skill\ndescription:");
// disposer passthrough
unregister();
expect(skill.__disposed).toBe(true);
@@ -142,16 +142,33 @@ describe("tool registration", () => {
const { tools } = setup({});
expect([...tools.keys()].sort()).toEqual([
"browser_click",
"browser_console",
"browser_emulate",
"browser_fill",
"browser_get_html",
"browser_hover",
"browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_network",
"browser_observe",
"browser_press",
"browser_reload",
"browser_request_help",
"browser_screenshot",
"browser_select",
"browser_session_list",
"browser_session_start",
"browser_session_stop",
"browser_snapshot",
"browser_tab_borrow",
"browser_tab_close",
"browser_tab_create",
"browser_tab_list",
"browser_tab_return",
"browser_tab_select",
"browser_wait_for_navigation",
"browser_window_resize",
]);
});
@@ -436,6 +453,336 @@ describe("interaction tools", () => {
/mutually exclusive/,
);
});
it("browser_hover and browser_select map their structured arguments", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
hover: { tab_id: 7, used_ref: "@e3", x: 12, y: 24 },
select: {
tab_id: 7,
used_ref: "@e4",
multiple: true,
selected_values: ["a", "b"],
selected_labels: ["Alpha", "Beta"],
},
});
await startSession(tools);
const hovered = (await tools
.get("browser_hover")
?.execute(
{ target: "@e3", modifiers: ["shift"], settleMs: 250, timeoutMs: 2000 },
makeExec(),
)) as { x: number; y: number };
expect(hovered).toMatchObject({ x: 12, y: 24 });
expect(calls[1].args).toEqual([
"hover",
"--session",
"s1",
"--modifiers",
"shift",
"--settle",
"250ms",
"--timeout",
"2000ms",
"@e3",
]);
const selected = (await tools
.get("browser_select")
?.execute({ target: "@e4", values: ["a", "b"], tabId: 7 }, makeExec())) as {
selectedValues: string[];
selectedLabels: string[];
};
expect(selected).toMatchObject({
selectedValues: ["a", "b"],
selectedLabels: ["Alpha", "Beta"],
});
expect(calls[2].args).toEqual([
"select",
"--session",
"s1",
"--tab-id",
"7",
"--value",
"a",
"--value",
"b",
"@e4",
]);
});
});
describe("phase-one tab tools", () => {
it("maps list/create/close/select/borrow/return through the owned session", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"tab list": {
tabs: [
{
tab_id: 7,
title: "Example",
url: "https://example.test",
window_id: 2,
active: true,
scope: "agent",
},
],
},
"tab create": { tab_id: 8, window_id: 2, url: "https://new.test" },
"tab close": { tab_id: 8 },
"tab select": { tab_id: 7, window_id: 2 },
"tab borrow": {
tab_id: 20,
original_window_id: 9,
original_index: 1,
agent_window_id: 2,
},
"tab return": {
tab_id: 20,
returned_to_window_id: 9,
returned_to_index: 1,
fallback: false,
},
});
await startSession(tools);
const listed = (await tools
.get("browser_tab_list")
?.execute({ scope: "agent" }, makeExec())) as { tabs: { tabId: number }[] };
expect(listed.tabs[0].tabId).toBe(7);
const created = (await tools
.get("browser_tab_create")
?.execute({ url: "https://new.test", active: false, index: 1 }, makeExec())) as {
tabId: number;
};
expect(created.tabId).toBe(8);
await tools.get("browser_tab_close")?.execute({ tabId: 8 }, makeExec());
await tools.get("browser_tab_select")?.execute({ tabId: 7 }, makeExec());
const borrowed = (await tools
.get("browser_tab_borrow")
?.execute({ tabId: 20 }, makeExec())) as { originalWindowId: number };
expect(borrowed.originalWindowId).toBe(9);
const returned = (await tools
.get("browser_tab_return")
?.execute({ tabId: 20 }, makeExec())) as { fallback: boolean };
expect(returned.fallback).toBe(false);
expect(calls.slice(1).map((call) => call.args)).toEqual([
["tab", "list", "--session", "s1", "--scope", "agent"],
[
"tab",
"create",
"--session",
"s1",
"--url",
"https://new.test",
"--no-active",
"--index",
"1",
],
["tab", "close", "8", "--session", "s1"],
["tab", "select", "7", "--session", "s1"],
["tab", "borrow", "20", "--session", "s1"],
["tab", "return", "20", "--session", "s1"],
]);
});
});
describe("phase-one navigation tools", () => {
it("maps back/forward/reload/wait and preserves timeout budgets", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"navigate-back": {
tab_id: 7,
previous_url: "https://b.test",
final_url: "https://a.test",
reached: "load",
},
"navigate-forward": {
tab_id: 7,
previous_url: "https://a.test",
final_url: "https://b.test",
reached: "load",
},
reload: {
tab_id: 7,
previous_url: "https://b.test",
final_url: "https://b.test",
reached: "networkidle",
},
"wait-for-navigation": { tab_id: 7, reached: "timeout", error_text: "no navigation" },
});
await startSession(tools);
await tools
.get("browser_navigate_back")
?.execute({ tabId: 7, waitUntil: "commit", timeoutMs: 1000 }, makeExec());
await tools.get("browser_navigate_forward")?.execute({}, makeExec());
await tools
.get("browser_reload")
?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
const waited = (await tools
.get("browser_wait_for_navigation")
?.execute({ timeoutMs: 2500 }, makeExec())) as { reached: string; errorText: string };
expect(waited).toMatchObject({ reached: "timeout", errorText: "no navigation" });
expect(calls[1].args).toEqual([
"navigate-back",
"--session",
"s1",
"--tab-id",
"7",
"--wait-until",
"commit",
"--timeout",
"1000ms",
]);
expect(calls[2].args).toEqual(["navigate-forward", "--session", "s1"]);
expect(calls[3].args).toEqual([
"reload",
"--session",
"s1",
"--wait-until",
"networkidle",
"--hard",
]);
expect(calls[4].args).toEqual([
"wait-for-navigation",
"--session",
"s1",
"--timeout",
"2500ms",
]);
expect(calls[4].options.timeoutMs).toBe(120_000);
});
});
describe("phase-one support tools", () => {
it("maps request-help targets and structured completion criteria", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"request-help": {
outcome: "completed",
completed_by: "system",
note: "signed in",
tab_id: 7,
resolved_targets: [{ matched: true, ref: "@e3" }],
},
});
await startSession(tools);
const value = (await tools.get("browser_request_help")?.execute(
{
prompt: "Please sign in",
title: "Login required",
targets: ["@e3", "#submit"],
timeoutMs: 300_000,
completionCriteria: {
any: [{ urlContains: "/dashboard" }, { selectorExists: "[data-user]" }],
stableForMs: 1000,
},
},
makeExec(),
)) as { outcome: string; note: string };
expect(value).toMatchObject({ outcome: "completed", note: "signed in" });
expect(calls[1].options.timeoutMs).toBe(315_000);
expect(calls[1].args.slice(0, 8)).toEqual([
"request-help",
"--session",
"s1",
"--prompt",
"Please sign in",
"--timeout",
"300000ms",
"--title",
]);
const criteriaIndex = calls[1].args.indexOf("--completion-criteria");
expect(JSON.parse(calls[1].args[criteriaIndex + 1])).toEqual({
any: [{ url_contains: "/dashboard" }, { selector_exists: "[data-user]" }],
stable_for_ms: 1000,
});
});
it("maps get-html, console, and network results without raw shell access", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"get-html": { tab_id: 7, html: "<main>Hi</main>", truncated: false, byte_size: 15 },
console: {
tab_id: 7,
entries: [
{
sequence: 3,
kind: "exception",
level: "error",
text: "boom",
stack_trace: [{ function_name: "run", url: "https://a.test/app.js", line: 4 }],
},
],
next_since: 3,
truncated: false,
},
network: {
tab_id: 7,
entries: [
{
sequence: 5,
kind: "response",
method: "GET",
url: "https://a.test/api",
status: 200,
mime_type: "application/json",
},
],
next_since: 5,
truncated: false,
},
});
await startSession(tools);
const html = (await tools
.get("browser_get_html")
?.execute({ ref: "@e2", maxBytes: 2048 }, makeExec())) as { html: string };
expect(html.html).toBe("<main>Hi</main>");
const consoleValue = (await tools
.get("browser_console")
?.execute({ since: 1, limit: 10, includeStack: true }, makeExec())) as {
entries: { stackTrace: { functionName: string }[] }[];
};
expect(consoleValue.entries[0].stackTrace[0].functionName).toBe("run");
const networkValue = (await tools
.get("browser_network")
?.execute({ tabId: 7, maxTextChars: 512 }, makeExec())) as {
entries: { mimeType: string }[];
};
expect(networkValue.entries[0].mimeType).toBe("application/json");
expect(calls.slice(1).map((call) => call.args)).toEqual([
["get-html", "--session", "s1", "--ref", "@e2", "--max-bytes", "2048"],
["console", "--session", "s1", "--since", "1", "--limit", "10", "--include-stack"],
["network", "--session", "s1", "--tab-id", "7", "--max-text-chars", "512"],
]);
});
it("resizes only within the CLI-supported range", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"window resize": { window_id: 2, width: 1280, height: 800 },
});
await startSession(tools);
const resize = tools.get("browser_window_resize");
const value = (await resize?.execute({ width: 1280, height: 800 }, makeExec())) as {
windowId: number;
};
expect(value.windowId).toBe(2);
expect(calls[1].args).toEqual([
"window",
"resize",
"--session",
"s1",
"--width",
"1280",
"--height",
"800",
]);
await expect(resize?.execute({ width: 99, height: 800 }, makeExec())).rejects.toThrow(
/100\.\.=7680/,
);
});
});
describe("browser_screenshot", () => {