//! Single source of truth for the human-readable summary, repair //! hint, and exit code attached to every [`ErrorCode`] variant. //! //! Keeping the table in one place means: //! * the renderer in [`super::error::render`] never invents //! ad-hoc copy or exit codes, //! * `bsk-cli` subcommands that surface their own bespoke //! `eprintln!("error: ...")` lines are obvious anomalies during //! review, and //! * the M10.3 unit tests can lock in `ErrorCode → exit_code` so a //! silent regression bumps a test failure rather than escaping //! into shell scripts that depend on the documented contract. //! //! Exit code mapping follows design §3.1: //! * `0` — success (never produced by this module), //! * `1` — user error (parameters / sandbox / missing entity), //! * `2` — protocol or transport error (incl. `cancelled`), //! * `3` — browser / CDP failure, //! * `4` — timeout, //! * `5` — version mismatch. //! //! Strings are in English: the CLI is consumed by agents and other //! automated tooling, so all user-facing copy uses English. Command //! names, flags, field names, and `ErrorCode` literal values are //! also English so the wire / CLI contract stays unambiguous. There //! is no i18n framework wired up yet; if we ever introduce one, the //! `summary` and `hint` strings here are the obvious extraction //! candidates (one key per `ErrorCode` variant). //! //! `cancelled` is intentionally not in the design's enumerated table //! but currently lands on `2` to keep parity with M3.5 behaviour and //! the existing exit-code unit test. Tests that want to discriminate //! cancellation should match on `ErrorCode::Cancelled` directly. use bsk_protocol::ErrorCode; /// Stable `RpcError.data.reason` values emitted by the extension for /// finer-grained CLI rendering. Kept as string literals on the wire so /// older peers can ignore unknown reasons and fall back to code-based copy. pub mod reason { pub const AGENT_WINDOW_SCOPE: &str = "agent_window_scope"; pub const ELEMENT_NOT_VISIBLE: &str = "element_not_visible"; pub const REF_CAPABILITY_DENIED: &str = "ref_capability_denied"; pub const REF_NOT_FOUND: &str = "ref_not_found"; pub const SELECTOR_NOT_FOUND: &str = "selector_not_found"; pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable"; pub const FILL_VALUE_INVALID: &str = "fill_value_invalid"; pub const FILL_TARGET_CHANGED: &str = "fill_target_changed"; pub const FILL_FOCUS_LOST: &str = "fill_focus_lost"; pub const FILL_VALUE_MISMATCH: &str = "fill_value_mismatch"; pub const FILL_FAILED: &str = "fill_failed"; pub const TARGET_NOT_SELECT: &str = "target_not_select"; pub const OPTION_NOT_FOUND: &str = "option_not_found"; pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count"; pub const TAB_NOT_ACTIVE: &str = "tab_not_active"; pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied"; pub const BORROW_CONFLICT: &str = "borrow_conflict"; pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed"; pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed"; pub const FILE_INPUT_NOT_ACTIVATED: &str = "file_input_not_activated"; pub const SET_FILE_INPUT_FAILED: &str = "set_file_input_failed"; pub const UPLOAD_MECHANISM_UNSUPPORTED: &str = "upload_mechanism_unsupported"; pub const FILE_DROP_TARGET_UNAVAILABLE: &str = "file_drop_target_unavailable"; pub const FILE_DROP_FAILED: &str = "file_drop_failed"; pub const DOWNLOAD_CAPTURE_FAILED: &str = "download_capture_failed"; pub const TRANSFER_OUTCOME_UNKNOWN: &str = "transfer_outcome_unknown"; pub const TRANSFER_TIMEOUT: &str = "transfer_timeout"; pub const SESSION_BUSY: &str = crate::rpc_reason::SESSION_BUSY; pub const RECORD_START_PAGE_UNREACHABLE: &str = "record_start_page_unreachable"; } /// Per-code rendering metadata. The `summary` is what the CLI prints /// after the leading `error: ` token; the `hint` (when present) is /// what we print on the next line as `hint: ...`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RenderInfo { pub summary: &'static str, pub hint: Option<&'static str>, pub exit_code: u8, } /// Look up the rendering info for a given error code. pub fn info_for(code: ErrorCode) -> RenderInfo { match code { ErrorCode::UnknownMethod => RenderInfo { summary: "daemon does not recognise this RPC method", hint: Some( "upgrade both bsk CLI and the browser-skill extension; CLI and daemon may be on mismatched protocol versions", ), // Design §3.1 maps version / protocol incompatibility to // exit 5 and the design notes call out `unknown_method` // as the canonical "method-shape mismatch" path (review // I4: was previously 1 which is reserved for user-input // errors). exit_code: 5, }, ErrorCode::Unsupported => RenderInfo { summary: "this operation is not supported by the current build", hint: Some( "check `bsk --version` and the bsk changelog to confirm whether the feature is available", ), exit_code: 1, }, ErrorCode::InvalidParams => RenderInfo { summary: "invalid command parameters", hint: Some( "check your command arguments; run `bsk --help` to see the expected format", ), exit_code: 1, }, ErrorCode::NotFound => RenderInfo { summary: "requested resource does not exist", hint: Some( "the session, tab, or browser may have stopped; run `bsk session list` / `bsk browsers` to see current state", ), exit_code: 1, }, ErrorCode::PermissionDenied => RenderInfo { summary: "operation denied by the Agent Window sandbox", hint: Some( "tabs outside an Agent Window must first be borrowed via `bsk tab borrow --session `", ), exit_code: 1, }, ErrorCode::Timeout => RenderInfo { summary: "operation timed out", hint: Some( "retry the command; if timeouts persist, check `bsk logs` and confirm the browser is still responding", ), exit_code: 4, }, ErrorCode::CdpFailed => RenderInfo { summary: "browser rejected the underlying CDP call", hint: Some( "confirm the tab is still in a loaded state and retry; reloading the tab usually resets a stuck DevTools session", ), exit_code: 3, }, ErrorCode::ProtocolError => RenderInfo { summary: "protocol error communicating with bsk daemon", hint: Some( "ensure CLI and daemon protocol versions match — check `protocol version` in `bsk status`", ), exit_code: 2, }, ErrorCode::Cancelled => RenderInfo { summary: "operation cancelled", hint: Some( "the previous command was interrupted (Ctrl-C or a remote `cancel` request)", ), exit_code: 2, }, ErrorCode::UserAborted => RenderInfo { summary: "operation interrupted by user", hint: Some( "the user requested an interrupt (e.g. via the agent window's stop button); rerun if this was unintended", ), exit_code: 2, }, ErrorCode::VersionTooOld => RenderInfo { summary: "peer version is too old to communicate with this build", hint: Some( "upgrade both bsk CLI and the browser-skill extension so both sides satisfy `min_compatible_protocol`", ), exit_code: 5, }, ErrorCode::MultipleBrowsersOnline => RenderInfo { summary: "multiple browsers are online", hint: Some( "use `--browser ` to target a specific browser (run `bsk browsers` to list online browsers)", ), exit_code: 1, }, ErrorCode::NoBrowserConnected => RenderInfo { summary: "no browser is connected to the daemon", hint: Some( "open the browser-skill extension in your browser and wait for the popup to show \"connected\"", ), exit_code: 1, }, } } /// Convenience accessor used by [`super::error::render`] when it /// needs only the exit code. pub fn exit_code_for(code: ErrorCode) -> u8 { info_for(code).exit_code } /// Convenience accessor used by [`super::error::render`] when it /// needs only the hint. pub fn hint_for_code(code: ErrorCode) -> Option<&'static str> { info_for(code).hint } /// Convenience accessor for the human summary; useful when the CLI /// wants a friendly first line independent of the daemon's /// machine-oriented message. pub fn summary_for(code: ErrorCode) -> &'static str { info_for(code).summary } /// Extract `data.reason` when present and a non-empty string. pub fn reason_for_data(data: Option<&serde_json::Value>) -> Option<&str> { data?.get("reason")?.as_str().filter(|s| !s.is_empty()) } /// Look up rendering info using `(code, data.reason)` when a reason- /// specific override exists; otherwise fall back to [`info_for`]. pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> RenderInfo { let base = info_for(code); let Some(reason) = reason_for_data(data) else { return base; }; match (code, reason) { (ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo { summary: "Chrome blocked CDP access to another extension's content in this tab", hint: Some( "a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate ` to leave this page; reconnecting BrowserSkill alone does not remove the restriction", ), exit_code: base.exit_code, }, (_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo { summary: "the file transfer outcome could not be confirmed", hint: Some( "do not retry the transfer: the browser may already have applied it; inspect the page or stop safely", ), exit_code: base.exit_code, }, (ErrorCode::Timeout, reason::TRANSFER_TIMEOUT) => RenderInfo { summary: "the file transfer timed out after browser dispatch", hint: Some( "do not retry when effect_state is unknown or committed; inspect the page before taking another action", ), exit_code: base.exit_code, }, (ErrorCode::PermissionDenied, reason::ELEMENT_NOT_VISIBLE) => RenderInfo { summary: "target element has no visible geometry", hint: Some( "rerun snapshot and choose a visible child ref, or wait/scroll/reload before retrying", ), exit_code: base.exit_code, }, (ErrorCode::PermissionDenied, reason::BORROW_CONFLICT) => RenderInfo { summary: "tab is borrowed by another session", hint: Some( "return the tab from the borrowing session via `bsk tab return --session ` or stop that session", ), exit_code: base.exit_code, }, (ErrorCode::NotFound, reason::REF_NOT_FOUND) => RenderInfo { summary: "snapshot ref was not found for this tab", hint: Some("rerun `bsk snapshot` for the current tab and use one of the returned refs"), exit_code: base.exit_code, }, (ErrorCode::NotFound, reason::SELECTOR_NOT_FOUND) => RenderInfo { summary: "selector did not match any element", hint: Some("verify the CSS selector or wait for the element to appear before retrying"), exit_code: base.exit_code, }, (ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo { summary: "target element is not fillable", hint: Some( "observe the page and choose an enabled, editable text input, textarea, or contenteditable; use the appropriate interaction for other control types", ), exit_code: base.exit_code, }, (ErrorCode::InvalidParams, reason::FILL_VALUE_INVALID) => RenderInfo { summary: "the requested text does not fit the field constraints", hint: Some( "check the field's input type and maxlength; use a compatible value without silently truncating or changing the user's intended data", ), exit_code: base.exit_code, }, (ErrorCode::CdpFailed, reason::FILL_TARGET_CHANGED) => RenderInfo { summary: "the fill target changed during the action", hint: Some( "observe the current page and field value; if the intended result is missing, use a fresh editable target before retrying", ), exit_code: base.exit_code, }, (ErrorCode::CdpFailed, reason::FILL_FOCUS_LOST) => RenderInfo { summary: "the page moved focus away from the fill target", hint: Some( "observe the page for a dialog or rerender and resolve it before retrying; fill manages field focus and does not require bringing the browser to the foreground", ), exit_code: base.exit_code, }, (ErrorCode::CdpFailed, reason::FILL_VALUE_MISMATCH) => RenderInfo { summary: "the fill result could not be confirmed", hint: Some( "observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent; otherwise correct the remaining difference. Do not blindly repeat fill or immediately request human help", ), exit_code: base.exit_code, }, (ErrorCode::CdpFailed, reason::FILL_FAILED) => RenderInfo { summary: "fill encountered a browser or page-script error", hint: Some( "observe the page before retrying because the field may already have changed; retry only if the intended result is missing and the target is still available", ), exit_code: base.exit_code, }, (ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo { summary: "snapshot ref does not support this operation", hint: Some( "visual surface refs are screenshot-only; inspect `bsk screenshot --ref --session ` with image-understanding capability", ), exit_code: base.exit_code, }, (ErrorCode::InvalidParams, reason::TARGET_NOT_SELECT) => RenderInfo { summary: "target element is not a ref or selector from the latest snapshot, or use `bsk click` / `bsk fill` for other controls", ), exit_code: base.exit_code, }, (ErrorCode::InvalidParams, reason::OPTION_NOT_FOUND) => RenderInfo { summary: "option value not found in ; repeat `--value` only for "); assert!(info.hint.unwrap().contains("native "); assert!(info.hint.unwrap().contains("--value")); } #[test] fn single_select_value_count_overrides_invalid_params_copy() { let data = serde_json::json!({ "reason": reason::SINGLE_SELECT_VALUE_COUNT }); let info = info_for_error(ErrorCode::InvalidParams, Some(&data)); assert_eq!( info.summary, "single-select requires exactly one option value" ); assert!(info.hint.unwrap().contains("exactly one")); } }