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:
drakezhang
2026-08-27 19:59:20 +08:00
parent 554861d6b1
commit 1a70d2200e
10 changed files with 501 additions and 92 deletions
@@ -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);
});
});