mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48a53269e | ||
|
|
3bbd6ab19f |
@@ -247,6 +247,41 @@ describe("App", () => {
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats protocol drift as still connected and prompts an upgrade", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "version_skew",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "version_skew",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("已连接")).toBeTruthy();
|
||||
expect(screen.getByText("可升级")).toBeTruthy();
|
||||
expect(screen.queryByText("协议不一致")).toBeNull();
|
||||
expect(screen.queryByText("Action needed")).toBeNull();
|
||||
expect(screen.queryByText("兼容")).toBeNull();
|
||||
const warning = screen.getByText(/协议版本不同,请及时升级/);
|
||||
expect(warning.textContent).toContain("CLI 协议");
|
||||
expect(warning.textContent).toContain("扩展协议");
|
||||
|
||||
openRecordView();
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
fireEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control hints toggle", () => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export function App() {
|
||||
}, [copiedTick]);
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const connectionLive = statusState === "connected" || isSkewed;
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||
const extensionVersion = snapshot.extensionVersion || "—";
|
||||
@@ -90,7 +91,7 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
@@ -206,12 +207,12 @@ export function App() {
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
cliProtocol: daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
||||
## Prerequisites
|
||||
|
||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||
|
||||
## Mandatory workflow
|
||||
@@ -299,7 +299,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
|
||||
|
||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||
|
||||
|
||||
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// `bsk doctor` check: every connected browser should use the same
|
||||
/// protocol version as the daemon. Minor protocol drift is accepted by
|
||||
/// the daemon but flagged here so the user can see who needs updating
|
||||
/// (M10.4).
|
||||
/// `bsk doctor` check: connected browsers should speak a protocol the
|
||||
/// daemon accepts. A different protocol string is still a live
|
||||
/// connection — report Ok so agents keep working. The detail is an
|
||||
/// upgrade reminder, not a blocker.
|
||||
///
|
||||
/// Review M2 (round-1 minor): when no browsers are connected, the
|
||||
/// check has nothing to compare against, so it now reports
|
||||
@@ -396,13 +396,12 @@ fn check_browsers_protocol_compatible(status: Option<&StatusResult>) -> CheckRes
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
CheckResult::fail(
|
||||
CheckResult::ok(
|
||||
name,
|
||||
format!(
|
||||
"{} browser(s) have protocol minor drift from the daemon: {stale}",
|
||||
"{} browser(s) report a different protocol version (still usable — continue, and upgrade soon): {stale}",
|
||||
status.version_skew_browsers.len()
|
||||
),
|
||||
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -581,7 +580,7 @@ mod m2_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsers_check_reports_fail_when_skew_present() {
|
||||
fn browsers_check_reports_ok_when_skew_present() {
|
||||
let status = fake_status(
|
||||
vec![BrowserStatusEntry {
|
||||
instance_id: "alpha".into(),
|
||||
@@ -605,9 +604,17 @@ mod m2_tests {
|
||||
}],
|
||||
);
|
||||
let check = check_browsers_protocol_compatible(Some(&status));
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(check.detail.contains("alpha"));
|
||||
assert!(check.hint.is_some());
|
||||
assert!(
|
||||
check
|
||||
.detail
|
||||
.contains("still usable — continue, and upgrade soon")
|
||||
);
|
||||
assert!(
|
||||
check.hint.is_none(),
|
||||
"a protocol-version note must not hint to stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -635,7 +642,7 @@ mod m2_tests {
|
||||
}],
|
||||
);
|
||||
let check = check_browsers_protocol_compatible(Some(&status));
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(
|
||||
check
|
||||
.detail
|
||||
|
||||
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
|
||||
let bold = "\x1b[1m";
|
||||
let reset = "\x1b[0m";
|
||||
eprintln!(
|
||||
"{bold}{yellow}warning:{reset} {} browser(s) have protocol version drift from the daemon{reset}",
|
||||
"{bold}{yellow}note:{reset} {} browser(s) report a different protocol version; still usable — continue, and run `bsk update` soon{reset}",
|
||||
s.version_skew_browsers.len()
|
||||
);
|
||||
for skew in &s.version_skew_browsers {
|
||||
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
|
||||
skew.label.clone()
|
||||
};
|
||||
eprintln!(
|
||||
" {} ({}) — protocol ext {} vs daemon {} (app ext v{}, daemon v{}) — please align protocol versions",
|
||||
" {} ({}) — protocol ext {} vs CLI {} (app ext v{}, CLI v{}) — still usable; please upgrade",
|
||||
skew.instance_id,
|
||||
label,
|
||||
display_protocol(&skew.client_protocol_version),
|
||||
|
||||
@@ -107,8 +107,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/schemastery": "^3.18.1",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { registerObservationRoutes } from "./observation-http";
|
||||
import { KeyedExecutor } from "./queue";
|
||||
import { type BskRunner, createBskRunner } from "./runner";
|
||||
import { SessionRegistry } from "./sessions";
|
||||
import { armAgentScopedBskSkill, registerBskSkill } from "./skill";
|
||||
import { registerBskSkill } from "./skill";
|
||||
import { type PluginConfig, registerTools } from "./tools";
|
||||
|
||||
export const name = "dsh-plugin-browserskill";
|
||||
@@ -95,11 +95,6 @@ export function apply(
|
||||
// ONLY model-visible advertisement — the tool suite reveals itself on a
|
||||
// successful skill invocation (or a session whose history already has one).
|
||||
const unregisterSkill = registerBskSkill(ctx);
|
||||
// A same-name CLI skill left in ~/.agents is discovered in the nearer preset
|
||||
// layer and shadows the global registration above. Re-register through every
|
||||
// exact agent context at startup so DSH always loads the browser_* protocol
|
||||
// instructions; the shared CLI skill remains untouched for other agents.
|
||||
const disarmAgentSkill = armAgentScopedBskSkill(ctx);
|
||||
const removeSuite = resolved.lazyTools
|
||||
? armLazyTools(ctx, () =>
|
||||
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
|
||||
@@ -140,15 +135,17 @@ export function apply(
|
||||
ctx.effect(() => {
|
||||
return () => {
|
||||
removeSuite();
|
||||
disarmAgentSkill();
|
||||
unregisterSkill();
|
||||
removeRoutes();
|
||||
disarmArchiveCleanup();
|
||||
observation.dispose();
|
||||
runner.killAll();
|
||||
const stops = registry
|
||||
.ownedIds()
|
||||
.map((sessionId) => observation.stopSession(sessionId).catch(() => false));
|
||||
return Promise.all(stops).then(() => observation.dispose());
|
||||
.map((sessionId) =>
|
||||
runner.run(["session", "stop", sessionId], { timeoutMs: 15_000 }).catch(() => {}),
|
||||
);
|
||||
return Promise.all(stops).then(() => {});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import type { KeyedExecutor } from "./queue";
|
||||
import { BskError, type BskRunner, parseBskJson, runWithSessionBusyRetry } from "./runner";
|
||||
import type { BskRunner } from "./runner";
|
||||
import type { SessionRegistry } from "./sessions";
|
||||
|
||||
/** One owned session's live observation record (wire-stable shape). */
|
||||
@@ -90,10 +90,6 @@ export class ObservationService {
|
||||
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
||||
private readonly captureTimers = new Map<string, unknown>();
|
||||
private readonly captureInFlight = new Set<string>();
|
||||
/** Captures intentionally cancelled to make way for foreground work. */
|
||||
private readonly capturePreempted = new Set<string>();
|
||||
/** Number of queued/running model-facing calls that currently outrank thumbnails. */
|
||||
private readonly foregroundDepth = new Map<string, number>();
|
||||
private readonly captureFailures = new Map<string, number>();
|
||||
private readonly lastActivity = new Map<string, number>();
|
||||
/** Ephemeral frame id → PNG bytes. Only the live ring members are present. */
|
||||
@@ -179,8 +175,6 @@ export class ObservationService {
|
||||
/** Drop a session (called from browser_session_stop). */
|
||||
removeSession(sessionId: string): void {
|
||||
this.cancelCapture(sessionId);
|
||||
this.foregroundDepth.delete(sessionId);
|
||||
this.capturePreempted.delete(sessionId);
|
||||
this.captureFailures.delete(sessionId);
|
||||
this.lastActivity.delete(sessionId);
|
||||
this.dropSessionFrames(sessionId);
|
||||
@@ -192,37 +186,6 @@ export class ObservationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give model-facing work priority over the best-effort thumbnail lane.
|
||||
* The first lease cancels a pending timer and gracefully interrupts an
|
||||
* active bsk screenshot; the last release schedules one fresh frame.
|
||||
*/
|
||||
acquireForeground(sessionId: string): () => void {
|
||||
if (!this.deps.options.enabled || this.disposed || !this.observations.has(sessionId)) {
|
||||
return () => {};
|
||||
}
|
||||
const depth = this.foregroundDepth.get(sessionId) ?? 0;
|
||||
this.foregroundDepth.set(sessionId, depth + 1);
|
||||
if (depth === 0) {
|
||||
this.cancelCapture(sessionId);
|
||||
if (this.deps.runner.killFor(`observation:${sessionId}`) > 0) {
|
||||
this.capturePreempted.add(sessionId);
|
||||
}
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = (this.foregroundDepth.get(sessionId) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
this.foregroundDepth.set(sessionId, remaining);
|
||||
return;
|
||||
}
|
||||
this.foregroundDepth.delete(sessionId);
|
||||
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/** Mark an action starting on a session (tool entry instrumentation). */
|
||||
beginAction(sessionId: string, action: string): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
@@ -250,7 +213,7 @@ export class ObservationService {
|
||||
if (error !== undefined) next.lastError = error;
|
||||
else delete next.lastError;
|
||||
this.put(next);
|
||||
if (!this.foregroundDepth.has(sessionId)) this.scheduleCapture(sessionId, 0);
|
||||
this.scheduleCapture(sessionId, 0);
|
||||
}
|
||||
|
||||
/** Record the settled page URL (navigate success / start with url). */
|
||||
@@ -279,48 +242,29 @@ export class ObservationService {
|
||||
* button — same end state as `browser_session_stop`). Never waits behind a
|
||||
* hung in-flight command: tool children are killed first so the session's
|
||||
* keyed queue drains immediately, and no further captures queue up. A
|
||||
* session the daemon already forgot stops idempotently — the goal state
|
||||
* (entry gone) is identical.
|
||||
* @returns false only for a foreign session; bsk failures reject so callers
|
||||
* can preserve the structured error instead of silently leaving a ghost.
|
||||
* session the daemon already forgot (dead strip entries) stops
|
||||
* idempotently — the goal state (entry gone) is identical.
|
||||
* @returns whether the session ended up stopped/removed.
|
||||
*/
|
||||
async stopSession(sessionId: string, signal?: AbortSignal): Promise<boolean> {
|
||||
async stopSession(sessionId: string): Promise<boolean> {
|
||||
if (!this.deps.registry.isOwned(sessionId)) return false;
|
||||
const releaseForeground = this.acquireForeground(sessionId);
|
||||
let actionError: string | undefined;
|
||||
this.beginAction(sessionId, "stopping");
|
||||
try {
|
||||
this.deps.runner.killFor(sessionId);
|
||||
const result = await this.deps.queue.run(
|
||||
sessionId,
|
||||
() =>
|
||||
runWithSessionBusyRetry(
|
||||
() =>
|
||||
this.deps.runner.run(["session", "stop", sessionId], {
|
||||
signal,
|
||||
timeoutMs: 30_000,
|
||||
tag: sessionId,
|
||||
}),
|
||||
signal,
|
||||
),
|
||||
signal,
|
||||
);
|
||||
if (result.aborted) throw abortError();
|
||||
this.cancelCapture(sessionId);
|
||||
this.deps.runner.killFor(sessionId);
|
||||
const result = await this.deps.queue.run(sessionId, () =>
|
||||
this.deps.runner.run(["session", "stop", sessionId], { timeoutMs: 30_000 }),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
parseBskJson(result, "session stop");
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) throw error;
|
||||
code = (JSON.parse(result.stdout) as { code?: string }).code;
|
||||
} catch {
|
||||
code = undefined;
|
||||
}
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
actionError = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.endAction(sessionId, actionError);
|
||||
releaseForeground();
|
||||
if (code !== "session_not_found") return false;
|
||||
}
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,8 +287,6 @@ export class ObservationService {
|
||||
for (const sessionId of [...this.captureTimers.keys()]) this.cancelCapture(sessionId);
|
||||
this.captureTimers.clear();
|
||||
this.captureInFlight.clear();
|
||||
this.capturePreempted.clear();
|
||||
this.foregroundDepth.clear();
|
||||
this.captureFailures.clear();
|
||||
this.lastActivity.clear();
|
||||
this.frames.clear();
|
||||
@@ -371,7 +313,6 @@ export class ObservationService {
|
||||
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
||||
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
if (this.foregroundDepth.has(sessionId)) return;
|
||||
const entry = this.observations.get(sessionId);
|
||||
if (entry === undefined || entry.dead === true) return;
|
||||
this.cancelCapture(sessionId);
|
||||
@@ -408,15 +349,12 @@ export class ObservationService {
|
||||
const outPath = join(tmpdir(), `bsk-obs-${this.scratchNamespace}-${sessionKey}.png`);
|
||||
let writtenPath = outPath;
|
||||
try {
|
||||
const result = await this.deps.queue.run(sessionId, () => {
|
||||
// A foreground lease may have arrived while this capture was queued.
|
||||
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
|
||||
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||
const result = await this.deps.queue.run(sessionId, () =>
|
||||
this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||
timeoutMs: 15_000,
|
||||
tag: `observation:${sessionId}`,
|
||||
});
|
||||
});
|
||||
if (result === undefined) return;
|
||||
}),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
@@ -424,12 +362,8 @@ export class ObservationService {
|
||||
} catch {
|
||||
code = undefined;
|
||||
}
|
||||
if (isSessionNotFoundCode(code)) {
|
||||
// Observation entries are owned sessions. If the daemon has already
|
||||
// forgotten one (external close / restart), reconcile both stores so
|
||||
// the PiP cannot retain a ghost strip or keep retrying screenshots.
|
||||
this.deps.registry.remove(sessionId);
|
||||
this.removeSession(sessionId);
|
||||
if (code === "session_not_found") {
|
||||
this.markDead(sessionId);
|
||||
return;
|
||||
}
|
||||
throw new Error(`screenshot exited ${result.code}`);
|
||||
@@ -444,17 +378,12 @@ export class ObservationService {
|
||||
this.setAvailable(true);
|
||||
this.publishFrame(sessionId, entry, data);
|
||||
} catch {
|
||||
// Foreground preemption is healthy scheduling, not browser/daemon
|
||||
// unavailability; do not let normal tool traffic trip capture backoff.
|
||||
if (!this.capturePreempted.delete(sessionId)) {
|
||||
// Silent by design: keep the previous frame, count toward backoff.
|
||||
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
|
||||
this.globalFailures += 1;
|
||||
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
|
||||
}
|
||||
// Silent by design: keep the previous frame, count toward backoff.
|
||||
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
|
||||
this.globalFailures += 1;
|
||||
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
|
||||
} finally {
|
||||
await unlink(writtenPath).catch(() => {});
|
||||
this.capturePreempted.delete(sessionId);
|
||||
this.captureInFlight.delete(sessionId);
|
||||
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId);
|
||||
}
|
||||
@@ -485,20 +414,14 @@ export class ObservationService {
|
||||
this.hashes.delete(sessionId);
|
||||
this.seqs.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionNotFoundCode(code: string | undefined): boolean {
|
||||
return code === "not_found" || code === "session_not_found";
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(error: unknown): boolean {
|
||||
return error instanceof BskError && isSessionNotFoundCode(error.code);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("tool call aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
/** Mark a session dead: grey it out, stop asking for frames, keep the entry for the strip. */
|
||||
private markDead(sessionId: string): void {
|
||||
const entry = this.observations.get(sessionId);
|
||||
if (entry === undefined || entry.dead === true) return;
|
||||
this.cancelCapture(sessionId);
|
||||
this.put({ ...entry, dead: true, action: "idle" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a bsk command label onto its observation action verb. */
|
||||
|
||||
@@ -64,17 +64,14 @@ export interface BskRunner {
|
||||
killFor(tag: string): number;
|
||||
}
|
||||
|
||||
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
|
||||
// Give that bounded reconciliation path time to settle before the hard kill.
|
||||
const KILL_GRACE_MS = 3000;
|
||||
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
||||
const KILL_GRACE_MS = 2000;
|
||||
|
||||
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
||||
const live = new Map<ChildProcess, string | undefined>();
|
||||
|
||||
function killChild(child: ChildProcess): void {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGINT");
|
||||
child.kill("SIGTERM");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, KILL_GRACE_MS);
|
||||
@@ -166,60 +163,6 @@ export function isCommandNotFound(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** True only for the daemon's transient per-session reconciliation window. */
|
||||
export function isSessionBusyResult(result: BskRunResult): boolean {
|
||||
if (result.code === 0) return false;
|
||||
try {
|
||||
const body = JSON.parse(result.stdout) as BskErrorBody;
|
||||
const reason =
|
||||
typeof body.data === "object" && body.data !== null && "reason" in body.data
|
||||
? (body.data as { reason?: unknown }).reason
|
||||
: undefined;
|
||||
return reason === "session_busy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry exactly once after the tiny daemon-settlement race that can follow a
|
||||
* graceful SIGINT cancellation. Other errors and persistent busy states stay
|
||||
* visible to callers.
|
||||
*/
|
||||
export async function runWithSessionBusyRetry(
|
||||
run: () => Promise<BskRunResult>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BskRunResult> {
|
||||
const first = await run();
|
||||
if (!isSessionBusyResult(first)) return first;
|
||||
await abortableDelay(SESSION_BUSY_RETRY_DELAY_MS, signal);
|
||||
return run();
|
||||
}
|
||||
|
||||
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(done, ms);
|
||||
timer.unref();
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
function done() {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("tool call aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Install guidance shown when the bsk CLI cannot be spawned. */
|
||||
export function bskInstallMessage(bskPath: string): string {
|
||||
return (
|
||||
@@ -239,7 +182,7 @@ export function parseBskJson(result: BskRunResult, commandLabel: string): unknow
|
||||
throw new BskError(`bsk ${commandLabel} timed out`, { timedOut: true });
|
||||
}
|
||||
const body = result.stdout.trim();
|
||||
// Killed by our own interrupt (SIGINT from killFor), not by the abort path:
|
||||
// Killed by our own interrupt (SIGTERM from killFor), not by the abort path:
|
||||
// say so instead of doubling the generic label into the message.
|
||||
if (result.code === null && !result.aborted && !result.timedOut) {
|
||||
throw new BskError(`bsk ${commandLabel} was interrupted (process killed)`);
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
*/
|
||||
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import type { Agent } from "@deepseek-ai/dsh-agent";
|
||||
import {
|
||||
BSK_SKILL_DESCRIPTION,
|
||||
BSK_SKILL_MARKDOWN,
|
||||
@@ -26,11 +25,6 @@ interface SkillsLike {
|
||||
}): () => void;
|
||||
}
|
||||
|
||||
/** Structural view of the live-agent registry (absent in bare compositions). */
|
||||
interface AgentsLike {
|
||||
list(): Agent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the browser skill as an embedded runtime skill. Returns the unregistration
|
||||
* disposer. A composition without the `skills` service (or with dsh-tool-skill
|
||||
@@ -49,54 +43,3 @@ export function registerBskSkill(ctx: Context): () => void {
|
||||
source: "bundled",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the DSH-specific browser skill into every exact agent scope.
|
||||
*
|
||||
* DSH merges skill layers nearest-first (`agent -> preset -> global`). A legacy
|
||||
* CLI skill installed at `~/.agents/skills/browser-skill` is discovered by the
|
||||
* preset filesystem provider and therefore shadows a plugin-level registration.
|
||||
* Registering the embedded skill through `agent.ctx` makes the DSH protocol
|
||||
* contract authoritative for that agent without touching the shared CLI skill.
|
||||
*
|
||||
* New agents are handled at `agent/session-start`, the first supported startup
|
||||
* injection point and still before the first prompt assembly. Existing agents
|
||||
* are registered immediately so plugin reloads take effect without recreating
|
||||
* the conversation. Returns a disposer for all plugin-owned registrations.
|
||||
*/
|
||||
export function armAgentScopedBskSkill(ctx: Context): () => void {
|
||||
const registrations = new Map<Agent, () => void>();
|
||||
let active = true;
|
||||
|
||||
const registerForAgent = (agent: Agent): void => {
|
||||
if (!active || registrations.has(agent)) return;
|
||||
registrations.set(agent, registerBskSkill(agent.ctx));
|
||||
};
|
||||
|
||||
let stopSessionStart = () => {};
|
||||
let stopDisposed = () => {};
|
||||
if (typeof ctx.on === "function") {
|
||||
stopSessionStart = ctx.on("agent/session-start", ({ agent }) => {
|
||||
registerForAgent(agent);
|
||||
});
|
||||
stopDisposed = ctx.on("agent/disposed", ({ agent }) => {
|
||||
// Agent-scoped effects have already unwound at this lifecycle edge. Drop
|
||||
// the retained disposer without invoking it against a disposed context.
|
||||
registrations.delete(agent);
|
||||
});
|
||||
}
|
||||
|
||||
const agents = ctx.get("agents") as AgentsLike | undefined | null;
|
||||
if (agents != null && typeof agents.list === "function") {
|
||||
for (const agent of agents.list()) registerForAgent(agent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
stopDisposed();
|
||||
stopSessionStart();
|
||||
for (const unregister of [...registrations.values()].reverse()) unregister();
|
||||
registrations.clear();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,13 +22,7 @@ import { trySaveScreenshot } from "./image";
|
||||
import { actionForLabel, type ObservationService } from "./observation";
|
||||
import { registerPhaseOneTools } from "./phase-one-tools";
|
||||
import type { KeyedExecutor } from "./queue";
|
||||
import {
|
||||
type BskRunner,
|
||||
bskInstallMessage,
|
||||
isCommandNotFound,
|
||||
parseBskJson,
|
||||
runWithSessionBusyRetry,
|
||||
} from "./runner";
|
||||
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
|
||||
import type { SessionRegistry } from "./sessions";
|
||||
import { SESSION_PARAM } from "./tool-params";
|
||||
|
||||
@@ -85,8 +79,6 @@ async function runBsk(
|
||||
observeSession?: string,
|
||||
runnerTimeoutMs?: number,
|
||||
): Promise<unknown> {
|
||||
const releaseForeground =
|
||||
observeSession !== undefined ? deps.observation.acquireForeground(observeSession) : undefined;
|
||||
// beginAction must fire only when the task actually starts inside the
|
||||
// per-session queue — marking it while still queued would overwrite the
|
||||
// in-flight action's label (and flash it idle on a queued abort).
|
||||
@@ -100,15 +92,11 @@ async function runBsk(
|
||||
began = true;
|
||||
deps.observation.beginAction(observeSession, actionForLabel(label));
|
||||
}
|
||||
return runWithSessionBusyRetry(
|
||||
() =>
|
||||
deps.runner.run(args, {
|
||||
signal: exec.signal,
|
||||
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
|
||||
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
||||
}),
|
||||
exec.signal,
|
||||
);
|
||||
return deps.runner.run(args, {
|
||||
signal: exec.signal,
|
||||
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
|
||||
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
||||
});
|
||||
};
|
||||
result =
|
||||
observeSession !== undefined
|
||||
@@ -133,7 +121,6 @@ async function runBsk(
|
||||
if (observeSession !== undefined && began) {
|
||||
deps.observation.endAction(observeSession, actionError);
|
||||
}
|
||||
releaseForeground?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,15 +267,22 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
}
|
||||
} catch (error) {
|
||||
// A half-initialized session must not leak: stop it before surfacing.
|
||||
// Reuse the same capture-preempting, idempotent path as explicit and
|
||||
// overlay stops; local ownership is dropped even when daemon cleanup
|
||||
// itself fails, because this start never becomes usable to the model.
|
||||
try {
|
||||
await deps.observation.stopSession(reply.session_id);
|
||||
} catch {
|
||||
registry.remove(reply.session_id);
|
||||
deps.observation.removeSession(reply.session_id);
|
||||
// The stop funnels through the session's queue (an in-flight command
|
||||
// — e.g. an observation capture — would otherwise make the daemon
|
||||
// refuse it), with one retry for good measure.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const stopped = await deps.queue.run(reply.session_id, () =>
|
||||
deps.runner.run(["session", "stop", reply.session_id], { timeoutMs: 30_000 }),
|
||||
);
|
||||
if (stopped.code === 0) break;
|
||||
} catch {
|
||||
// retry once, then give up quietly (registry/observation are
|
||||
// cleaned below either way; the daemon reaps orphans).
|
||||
}
|
||||
}
|
||||
registry.remove(reply.session_id);
|
||||
deps.observation.removeSession(reply.session_id);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
@@ -332,15 +326,9 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const sessionId = registry.resolveForStop(args.session);
|
||||
try {
|
||||
const stopped = await deps.observation.stopSession(sessionId, exec.signal);
|
||||
if (!stopped) throw new Error(`browser session ${sessionId} is not owned by this plugin`);
|
||||
} catch (error) {
|
||||
if (isCommandNotFound(error)) {
|
||||
throw new Error(bskInstallMessage(deps.config.bskPath));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await runBsk(deps, exec, ["session", "stop", sessionId], "session stop", sessionId);
|
||||
registry.remove(sessionId);
|
||||
deps.observation.removeSession(sessionId);
|
||||
return { stopped: sessionId };
|
||||
},
|
||||
presentCall: (args) => ({
|
||||
|
||||
@@ -78,7 +78,7 @@ function fakeRunner(
|
||||
if (opts.stopNotFound) {
|
||||
return {
|
||||
code: 4,
|
||||
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
|
||||
stdout: JSON.stringify({ code: "session_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: "not_found", message: "no such session" }),
|
||||
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
@@ -224,57 +224,6 @@ 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();
|
||||
@@ -402,7 +351,7 @@ describe("stopSession", () => {
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
|
||||
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||
expect(runner.killed).toEqual(["observation:s1", "s1"]);
|
||||
expect(runner.killed).toEqual(["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);
|
||||
@@ -431,13 +380,13 @@ describe("stopSession", () => {
|
||||
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||
});
|
||||
|
||||
it("rejects and keeps the entry when the stop itself fails", async () => {
|
||||
it("returns false 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")).rejects.toThrow(/boom/);
|
||||
await expect(service.stopSession("s1")).resolves.toBe(false);
|
||||
expect(registry.isOwned("s1")).toBe(true);
|
||||
expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
@@ -641,7 +590,7 @@ describe("HTTP/SSE interface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("availability and missing sessions", () => {
|
||||
describe("availability and dead sessions", () => {
|
||||
it("flips availability off after repeated global failures and back on success", async () => {
|
||||
const scheduler = fakeScheduler();
|
||||
const runner = fakeRunner({ screenshotFails: true });
|
||||
@@ -661,18 +610,23 @@ describe("availability and missing sessions", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("removes an owned ghost on not_found and stops asking for frames", async () => {
|
||||
it("marks a session dead on session_not_found and stops asking for frames", async () => {
|
||||
const scheduler = fakeScheduler();
|
||||
const runner = fakeRunner({ screenshotNotFound: true });
|
||||
const registry = new SessionRegistry(5);
|
||||
own(registry, "s1");
|
||||
const { service, events } = setup({ runner, scheduler, registry });
|
||||
const { service, events } = setup({ runner, scheduler });
|
||||
service.addSession("s1");
|
||||
scheduler.runNext();
|
||||
await waitFor(() => events.some((e) => e.type === "remove"));
|
||||
expect(service.getState()).toEqual([]);
|
||||
expect(registry.isOwned("s1")).toBe(false);
|
||||
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");
|
||||
expect(service.getState()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears lastError on the next action and keeps it on failure", () => {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BskRunResult,
|
||||
createBskRunner,
|
||||
isCommandNotFound,
|
||||
isSessionBusyResult,
|
||||
parseBskJson,
|
||||
runWithSessionBusyRetry,
|
||||
} from "../src/runner";
|
||||
import { type BskRunResult, createBskRunner, isCommandNotFound, parseBskJson } from "../src/runner";
|
||||
|
||||
/** Minimal fake ChildProcess driven by the test. */
|
||||
class FakeChild extends EventEmitter {
|
||||
@@ -70,7 +63,7 @@ describe("createBskRunner", () => {
|
||||
controller.abort();
|
||||
const result = await promise;
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(child.killedWith).toContain("SIGINT");
|
||||
expect(child.killedWith).toContain("SIGTERM");
|
||||
});
|
||||
|
||||
it("kills the child on timeout", async () => {
|
||||
@@ -87,7 +80,7 @@ describe("createBskRunner", () => {
|
||||
const promise = runner.run(["session", "list"]);
|
||||
runner.killAll();
|
||||
const result = await promise;
|
||||
expect(child.killedWith).toContain("SIGINT");
|
||||
expect(child.killedWith).toContain("SIGTERM");
|
||||
expect(result.code).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -147,51 +140,3 @@ 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,11 +1,8 @@
|
||||
// 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 { armAgentScopedBskSkill, registerBskSkill } from "../src/skill";
|
||||
import { registerBskSkill } from "../src/skill";
|
||||
|
||||
function fakeCtx(skills?: unknown) {
|
||||
return { get: (key: string) => (key === "skills" ? skills : undefined) } as never;
|
||||
@@ -103,88 +100,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"version_skew": "Protocol mismatch",
|
||||
"version_skew": "Connected",
|
||||
"disabled": "Connection off"
|
||||
},
|
||||
"stateDetail": {
|
||||
"disconnected": "Open BrowserSkill to connect.",
|
||||
"connecting": "Connecting to BrowserSkill…",
|
||||
"connected": "Ready for automation tasks.",
|
||||
"version_skew": "Some features may be unavailable."
|
||||
"version_skew": "Protocol versions differ. Please upgrade."
|
||||
},
|
||||
"stateBadge": {
|
||||
"disconnected": "Standby",
|
||||
"connecting": "Igniting",
|
||||
"connected": "Ready",
|
||||
"version_skew": "Action needed",
|
||||
"version_skew": "Upgradable",
|
||||
"disabled": "Off"
|
||||
},
|
||||
"connectionToggleTitle": "BrowserSkill connection",
|
||||
"controlHintsToggleTitle": "Control hints",
|
||||
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
|
||||
"controlHintsInfoLabel": "About control hints",
|
||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, daemon v{{daemonProtocol}}.",
|
||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, CLI protocol v{{cliProtocol}}. Protocol versions differ — please upgrade.",
|
||||
"upgradeAvailable": "Upgradable",
|
||||
"launcher": {
|
||||
"title": "Quick actions"
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
"disconnected": "未连接",
|
||||
"connecting": "连接中…",
|
||||
"connected": "已连接",
|
||||
"version_skew": "协议不一致",
|
||||
"version_skew": "已连接",
|
||||
"disabled": "连接已关闭"
|
||||
},
|
||||
"stateDetail": {
|
||||
"disconnected": "请先打开 BrowserSkill。",
|
||||
"connecting": "正在连接 BrowserSkill…",
|
||||
"connected": "可接收自动化任务。",
|
||||
"version_skew": "部分能力可能不可用。"
|
||||
"version_skew": "协议版本不同,请及时升级。"
|
||||
},
|
||||
"stateBadge": {
|
||||
"disconnected": "Standby",
|
||||
"connecting": "Igniting",
|
||||
"connected": "Ready",
|
||||
"version_skew": "Action needed",
|
||||
"version_skew": "可升级",
|
||||
"disabled": "Off"
|
||||
},
|
||||
"connectionToggleTitle": "BrowserSkill 连接",
|
||||
"controlHintsToggleTitle": "控制提示",
|
||||
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
|
||||
"controlHintsInfoLabel": "控制提示说明",
|
||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。",
|
||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},CLI 协议 v{{cliProtocol}}。协议版本不同,请及时升级。",
|
||||
"upgradeAvailable": "可升级",
|
||||
"launcher": {
|
||||
"title": "快捷功能"
|
||||
|
||||
Generated
-6
@@ -114,12 +114,6 @@ importers:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: ^0.1.0-rc.6
|
||||
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))
|
||||
'@deepseek-ai/dsh-scope':
|
||||
specifier: ^0.1.0-rc.6
|
||||
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))
|
||||
'@deepseek-ai/dsh-skill':
|
||||
specifier: ^0.1.0-rc.6
|
||||
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-llm@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))))(@deepseek-ai/dsh-scope@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: ^0.1.0-rc.6
|
||||
version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0)
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
||||
## Prerequisites
|
||||
|
||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||
|
||||
## Mandatory workflow
|
||||
@@ -299,7 +299,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
||||
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
|
||||
|
||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user