mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin): stabilize skill loading and browser sessions
Register bundled browser instructions at agent scope, prioritize foreground commands over thumbnail capture, and unify cancellation and session cleanup.
This commit is contained in:
@@ -78,7 +78,7 @@ function fakeRunner(
|
||||
if (opts.stopNotFound) {
|
||||
return {
|
||||
code: 4,
|
||||
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
|
||||
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
@@ -93,7 +93,7 @@ function fakeRunner(
|
||||
if (opts.screenshotNotFound) {
|
||||
return {
|
||||
code: 4,
|
||||
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
|
||||
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
@@ -224,6 +224,57 @@ describe("state machine", () => {
|
||||
});
|
||||
|
||||
describe("thumbnail cadence", () => {
|
||||
it("gives foreground work priority and resumes with one fresh capture", async () => {
|
||||
const scheduler = fakeScheduler();
|
||||
let finishCapture: ((result: BskRunResult) => void) | undefined;
|
||||
const killed: string[] = [];
|
||||
const runner: FakeRunner = {
|
||||
calls: [],
|
||||
killed,
|
||||
run(args) {
|
||||
if (args[0] !== "screenshot") {
|
||||
return Promise.resolve({
|
||||
code: 0,
|
||||
stdout: "{}",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
finishCapture = resolve;
|
||||
});
|
||||
},
|
||||
killAll() {},
|
||||
killFor(tag) {
|
||||
killed.push(tag);
|
||||
if (tag === "observation:s1" && finishCapture !== undefined) {
|
||||
finishCapture({
|
||||
code: 5,
|
||||
stdout: JSON.stringify({ code: "cancelled", message: "cancelled" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
});
|
||||
finishCapture = undefined;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
const { service } = setup({ runner, scheduler });
|
||||
service.addSession("s1");
|
||||
scheduler.runNext();
|
||||
await waitFor(() => finishCapture !== undefined);
|
||||
|
||||
const release = service.acquireForeground("s1");
|
||||
expect(killed).toEqual(["observation:s1"]);
|
||||
await waitFor(() => scheduler.pending().length === 0);
|
||||
release();
|
||||
expect(service.isAvailable()).toBe(true);
|
||||
expect(scheduler.pending()).toEqual([0]);
|
||||
});
|
||||
|
||||
it("captures immediately on add and after action end, then fast-cadences while active", async () => {
|
||||
const scheduler = fakeScheduler();
|
||||
const runner = fakeRunner();
|
||||
@@ -351,7 +402,7 @@ describe("stopSession", () => {
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
|
||||
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||
expect(runner.killed).toEqual(["s1"]);
|
||||
expect(runner.killed).toEqual(["observation:s1", "s1"]);
|
||||
const stop = runner.calls.find((c) => c.args[0] === "session" && c.args[1] === "stop");
|
||||
expect(stop?.args).toEqual(["session", "stop", "s1"]);
|
||||
expect(registry.isOwned("s1")).toBe(false);
|
||||
@@ -380,13 +431,13 @@ describe("stopSession", () => {
|
||||
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||
});
|
||||
|
||||
it("returns false and keeps the entry when the stop itself fails", async () => {
|
||||
it("rejects and keeps the entry when the stop itself fails", async () => {
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const runner = fakeRunner({ stopFails: true });
|
||||
const { service } = setup({ registry, runner });
|
||||
service.addSession("s1");
|
||||
await expect(service.stopSession("s1")).resolves.toBe(false);
|
||||
await expect(service.stopSession("s1")).rejects.toThrow(/boom/);
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
@@ -590,7 +641,7 @@ describe("HTTP/SSE interface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("availability and dead sessions", () => {
|
||||
describe("availability and missing sessions", () => {
|
||||
it("flips availability off after repeated global failures and back on success", async () => {
|
||||
const scheduler = fakeScheduler();
|
||||
const runner = fakeRunner({ screenshotFails: true });
|
||||
@@ -610,23 +661,18 @@ describe("availability and dead sessions", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("marks a session dead on session_not_found and stops asking for frames", async () => {
|
||||
it("removes an owned ghost on not_found and stops asking for frames", async () => {
|
||||
const scheduler = fakeScheduler();
|
||||
const runner = fakeRunner({ screenshotNotFound: true });
|
||||
const { service, events } = setup({ runner, scheduler });
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const { service, events } = setup({ runner, scheduler, registry });
|
||||
service.addSession("s1");
|
||||
scheduler.runNext();
|
||||
await waitFor(() => events.some((e) => e.type === "upsert" && e.session?.dead === true));
|
||||
expect(service.getState()[0].dead).toBe(true);
|
||||
expect(service.getState()[0].action).toBe("idle");
|
||||
// No further captures are scheduled for a dead session.
|
||||
expect(scheduler.pending()).toEqual([]);
|
||||
// Instrumentation is dropped for dead sessions.
|
||||
service.beginAction("s1", "clicking");
|
||||
expect(service.getState()[0].action).toBe("idle");
|
||||
// A dead session leaves cleanly.
|
||||
service.removeSession("s1");
|
||||
await waitFor(() => events.some((e) => e.type === "remove"));
|
||||
expect(service.getState()).toEqual([]);
|
||||
expect(registry.isOwned("s1")).toBe(false);
|
||||
expect(scheduler.pending()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears lastError on the next action and keeps it on failure", () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type BskRunResult, createBskRunner, isCommandNotFound, parseBskJson } from "../src/runner";
|
||||
import {
|
||||
type BskRunResult,
|
||||
createBskRunner,
|
||||
isCommandNotFound,
|
||||
isSessionBusyResult,
|
||||
parseBskJson,
|
||||
runWithSessionBusyRetry,
|
||||
} from "../src/runner";
|
||||
|
||||
/** Minimal fake ChildProcess driven by the test. */
|
||||
class FakeChild extends EventEmitter {
|
||||
@@ -63,7 +70,7 @@ describe("createBskRunner", () => {
|
||||
controller.abort();
|
||||
const result = await promise;
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(child.killedWith).toContain("SIGTERM");
|
||||
expect(child.killedWith).toContain("SIGINT");
|
||||
});
|
||||
|
||||
it("kills the child on timeout", async () => {
|
||||
@@ -80,7 +87,7 @@ describe("createBskRunner", () => {
|
||||
const promise = runner.run(["session", "list"]);
|
||||
runner.killAll();
|
||||
const result = await promise;
|
||||
expect(child.killedWith).toContain("SIGTERM");
|
||||
expect(child.killedWith).toContain("SIGINT");
|
||||
expect(result.code).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -140,3 +147,51 @@ describe("isCommandNotFound", () => {
|
||||
expect(isCommandNotFound(new Error("other"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session busy reconciliation", () => {
|
||||
const busy: BskRunResult = {
|
||||
code: 4,
|
||||
stdout: JSON.stringify({
|
||||
code: "timeout",
|
||||
message: "session already has an unfinished command",
|
||||
data: { reason: "session_busy" },
|
||||
}),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
const ok: BskRunResult = {
|
||||
code: 0,
|
||||
stdout: "{}",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
|
||||
it("recognizes only the structured session_busy reason", () => {
|
||||
expect(isSessionBusyResult(busy)).toBe(true);
|
||||
expect(isSessionBusyResult({ ...busy, stdout: '{"code":"timeout"}' })).toBe(false);
|
||||
expect(isSessionBusyResult(ok)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries one transient busy result and returns the settled response", async () => {
|
||||
const replies = [busy, ok];
|
||||
let calls = 0;
|
||||
const result = await runWithSessionBusyRetry(async () => {
|
||||
calls += 1;
|
||||
return replies.shift() ?? ok;
|
||||
});
|
||||
expect(result).toBe(ok);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("does not loop on a persistent busy result", async () => {
|
||||
let calls = 0;
|
||||
const result = await runWithSessionBusyRetry(async () => {
|
||||
calls += 1;
|
||||
return busy;
|
||||
});
|
||||
expect(result).toBe(busy);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Browser skill injection: registration wiring, catalog content, progressive-load
|
||||
// weight, and silent degradation without the skill seam.
|
||||
|
||||
import { Context } from "@deepseek-ai/cordis";
|
||||
import { createScope, scopeTarget } from "@deepseek-ai/dsh-scope";
|
||||
import { SkillRegistry } from "@deepseek-ai/dsh-skill";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerBskSkill } from "../src/skill";
|
||||
import { armAgentScopedBskSkill, registerBskSkill } from "../src/skill";
|
||||
|
||||
function fakeCtx(skills?: unknown) {
|
||||
return { get: (key: string) => (key === "skills" ? skills : undefined) } as never;
|
||||
@@ -100,3 +103,88 @@ describe("registerBskSkill", () => {
|
||||
expect(() => disposer()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("armAgentScopedBskSkill", () => {
|
||||
it("overrides a nearer legacy CLI skill when the DSH agent starts", async () => {
|
||||
const root = new Context();
|
||||
const skillFiber = root.plugin(SkillRegistry);
|
||||
await skillFiber;
|
||||
|
||||
// The plugin's original registration is global.
|
||||
registerBskSkill(skillFiber.ctx);
|
||||
|
||||
// Model the standard preset's filesystem discovery of
|
||||
// ~/.agents/skills/browser-skill. Its nearer scope wins before the fix.
|
||||
const presetKey = { preset: "standard" };
|
||||
const presetScope = createScope(skillFiber.ctx, presetKey);
|
||||
presetScope.ctx.skills.register({
|
||||
name: "browser-skill",
|
||||
description: "Legacy BrowserSkill CLI instructions",
|
||||
content: "Use bsk shell commands.",
|
||||
source: "user-agents",
|
||||
});
|
||||
|
||||
const agentKey = { agent: "test-agent" };
|
||||
const agentScope = createScope(skillFiber.ctx, agentKey, { parent: presetKey });
|
||||
const agent = {
|
||||
id: "test-agent",
|
||||
ctx: agentScope.ctx,
|
||||
} as never;
|
||||
const agents = { list: () => [] };
|
||||
const pluginCtx = skillFiber.ctx.extend({ agents });
|
||||
|
||||
const before = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
|
||||
expect(before?.content).toBe("Use bsk shell commands.");
|
||||
expect(before?.source).toBe("user-agents");
|
||||
|
||||
const disarm = armAgentScopedBskSkill(pluginCtx);
|
||||
pluginCtx.emit(scopeTarget(agent, agentKey), "agent/session-start", {
|
||||
agent,
|
||||
source: "startup",
|
||||
});
|
||||
|
||||
const after = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
|
||||
expect(after?.content).toContain("All browser operations must use the injected tools directly");
|
||||
expect(after?.content).not.toMatch(/\bbsk\b/i);
|
||||
expect(after?.source).toBe("bundled");
|
||||
|
||||
disarm();
|
||||
await agentScope.dispose();
|
||||
await presetScope.dispose();
|
||||
await skillFiber.dispose();
|
||||
});
|
||||
|
||||
it("installs on existing agents for plugin reload and disposes registrations", () => {
|
||||
const registered: Record<string, unknown>[] = [];
|
||||
let disposed = 0;
|
||||
const agent = {
|
||||
ctx: fakeCtx({
|
||||
register(skill: Record<string, unknown>) {
|
||||
registered.push(skill);
|
||||
return () => {
|
||||
disposed += 1;
|
||||
};
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
const listeners = new Map<string, (payload: { agent: never }) => void>();
|
||||
const ctx = {
|
||||
get: (key: string) => (key === "agents" ? { list: () => [agent] } : undefined),
|
||||
on(event: string, listener: (payload: { agent: never }) => void) {
|
||||
listeners.set(event, listener);
|
||||
return () => listeners.delete(event);
|
||||
},
|
||||
} as never;
|
||||
|
||||
const disarm = armAgentScopedBskSkill(ctx);
|
||||
expect(registered).toHaveLength(1);
|
||||
|
||||
// A later lifecycle notification for the same agent must not duplicate it.
|
||||
listeners.get("agent/session-start")?.({ agent });
|
||||
expect(registered).toHaveLength(1);
|
||||
|
||||
disarm();
|
||||
expect(disposed).toBe(1);
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user