diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd78fafd..13e37a35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. human control retains the existing `not_ready` behavior, including explicit holds during navigation. Status and wait report `control_policy: null` until the first successful policy negotiation. +- DSH plugin: a selector miss now lists the connected browsers in the error the model + sees, instead of only the `bsk browsers` hint, so a retry can name a browser that + is actually connected. ## [0.4.1] - 2026-11-02 diff --git a/packages/dsh-plugin-browserskill/src/runner.ts b/packages/dsh-plugin-browserskill/src/runner.ts index 5d0d3bca5..94813f4a2 100644 --- a/packages/dsh-plugin-browserskill/src/runner.ts +++ b/packages/dsh-plugin-browserskill/src/runner.ts @@ -17,6 +17,15 @@ export interface BskErrorBody { data?: unknown; } +/** One entry of the connected-browser list a selector miss carries in `data`. */ +interface BskBrowserCandidate { + instance_id?: unknown; + browser_name?: unknown; + browser_version?: unknown; + label?: unknown; + session_count?: unknown; +} + /** A failed `bsk` invocation (non-zero exit, timeout, or spawn failure). */ export class BskError extends Error { readonly code?: string; @@ -548,6 +557,47 @@ export function isNavigationLifecycleTimeoutPayload(value: unknown): value is { ); } +/** + * Render the connected browsers a selector miss reports in `data.browsers`. + * + * The CLI prints them as a table for a human; the JSON path carries the same + * list, and a caller that cannot see it has to probe every browser in turn. + * It goes into the message because that is the only channel the model reads, + * and it stays on ONE line because the action card keeps only the first line. + * + * Every other envelope renders nothing: `reason` drives the session-busy + * retry, and an empty list means a miss against an empty registry, which the + * CLI also leaves unrendered. + */ +function renderBrowserCandidates(data: unknown): string { + if (typeof data !== "object" || data === null) return ""; + const browsers = (data as { browsers?: unknown }).browsers; + if (!Array.isArray(browsers) || browsers.length === 0) return ""; + const rows = browsers.map(describeBrowser).filter((row) => row.length > 0); + return rows.length > 0 ? `; connected browsers: ${rows.join("; ")}` : ""; +} + +/** `alpha (chrome 131, label "Personal", 0 sessions)`, or "" without an id. */ +function describeBrowser(entry: unknown): string { + if (typeof entry !== "object" || entry === null) return ""; + const candidate = entry as BskBrowserCandidate; + const id = candidate.instance_id; + if (typeof id !== "string" || id.length === 0) return ""; + const parts: string[] = []; + const name = candidate.browser_name; + if (typeof name === "string" && name.length > 0) { + const version = candidate.browser_version; + parts.push(typeof version === "string" && version.length > 0 ? `${name} ${version}` : name); + } + const label = candidate.label; + if (typeof label === "string" && label.length > 0) parts.push(`label "${label}"`); + const sessions = candidate.session_count; + if (typeof sessions === "number") { + parts.push(`${sessions} session${sessions === 1 ? "" : "s"}`); + } + return parts.length > 0 ? `${id} (${parts.join(", ")})` : id; +} + /** * Interpret one finished run: throw `BskError` on timeout / non-zero exit * (parsing the CLI's JSON error envelope when present), otherwise parse and @@ -608,9 +658,14 @@ export function parseBskJson( } const message = parsed?.message ?? (result.stderr.trim() || body || `bsk ${commandLabel} failed`); + // A selector miss lists what is connected so the caller can pick a real + // instance or label instead of probing every browser in turn. Rendered + // before the hint, matching the order the CLI's own output uses. + const withCandidates = `${message}${renderBrowserCandidates(parsed?.data)}`; // Surface the envelope's actionable hint in the model-facing message; a // hint the model cannot see cannot be followed. - let withHint = parsed?.hint !== undefined ? `${message} (hint: ${parsed.hint})` : message; + let withHint = + parsed?.hint !== undefined ? `${withCandidates} (hint: ${parsed.hint})` : withCandidates; if (parsed?.code === "dialog_pending" && parsed.data !== undefined) withHint += `\nPending dialog and original operation: ${JSON.stringify(parsed.data)}`; throw new BskError(`bsk ${commandLabel} failed: ${withHint}`, { diff --git a/packages/dsh-plugin-browserskill/tests/runner.test.ts b/packages/dsh-plugin-browserskill/tests/runner.test.ts index 5df454232..af833df27 100644 --- a/packages/dsh-plugin-browserskill/tests/runner.test.ts +++ b/packages/dsh-plugin-browserskill/tests/runner.test.ts @@ -723,6 +723,69 @@ describe("parseBskJson", () => { expect(() => parseBskJson(result, "fill")).toThrow(hint); }); + it("lists the connected browsers a selector miss carries in data", () => { + const body = JSON.stringify({ + code: "not_found", + message: "requested browser is not connected", + hint: "run bsk browsers", + data: { + browsers: [ + { + instance_id: "alpha", + browser_name: "chrome", + browser_version: "131", + label: "Personal", + session_count: 0, + }, + { + instance_id: "beta", + browser_name: "edge", + browser_version: "130", + label: "", + session_count: 1, + }, + ], + }, + }); + let message = ""; + try { + parseBskJson({ ...base, code: 3, stdout: body }, "session start"); + expect.unreachable(); + } catch (error) { + message = (error as Error).message; + } + expect(message).toContain('alpha (chrome 131, label "Personal", 0 sessions)'); + expect(message).toContain("beta (edge 130, 1 session)"); + // summary -> candidates -> hint, the order the CLI's own output uses + expect(message.indexOf("not connected")).toBeLessThan(message.indexOf("connected browsers:")); + expect(message.indexOf("connected browsers:")).toBeLessThan(message.indexOf("hint:")); + // one line: the action card keeps only the first + expect(message.split("\n")).toHaveLength(1); + }); + + it("renders no candidate list for an empty browsers array", () => { + const body = JSON.stringify({ + code: "not_found", + message: "requested browser is not connected", + data: { browsers: [] }, + }); + expect(() => parseBskJson({ ...base, code: 3, stdout: body }, "session start")).toThrow( + "bsk session start failed: requested browser is not connected", + ); + }); + + it("leaves data that is not a candidate list out of the message", () => { + const body = JSON.stringify({ + code: "cdp_failed", + message: "fill could not verify the expected value", + data: { reason: "fill_value_mismatch" }, + hint: "observe the field", + }); + expect(() => parseBskJson({ ...base, code: 3, stdout: body }, "fill")).toThrow( + "bsk fill failed: fill could not verify the expected value (hint: observe the field)", + ); + }); + it("reports killed-by-interrupt children (null exit code) as interrupted", () => { expect(() => parseBskJson({ ...base, code: null }, "navigate")).toThrow(/interrupted/); });