Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
//! Shared JavaScript dialog observability types.
//!
//! Mirrors CDP `Page.javascriptDialogOpening` / `Page.handleJavaScriptDialog`.
//! Dialogs are surfaced as in-band data on tool results — not RPC errors.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Native JS dialog kind reported by CDP.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum JavaScriptDialogType {
Alert,
Confirm,
Prompt,
#[serde(rename = "beforeunload")]
BeforeUnload,
}
/// How the extension resolved the dialog so CDP could continue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum JavaScriptDialogHandledAction {
Accepted,
Dismissed,
}
impl JavaScriptDialogType {
pub fn as_str(self) -> &'static str {
match self {
Self::Alert => "alert",
Self::Confirm => "confirm",
Self::Prompt => "prompt",
Self::BeforeUnload => "beforeunload",
}
}
}
impl JavaScriptDialogHandledAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Dismissed => "dismissed",
}
}
}
/// One observed + handled JavaScript dialog during a tool call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct JavaScriptDialogInfo {
pub tab_id: i64,
#[serde(rename = "type")]
pub dialog_type: JavaScriptDialogType,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_browser_handler: Option<bool>,
pub handled: JavaScriptDialogHandledAction,
/// Monotonic per-tab sequence for ordering within a session.
pub sequence: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn dialog_info_round_trips() {
let info = JavaScriptDialogInfo {
tab_id: 4,
dialog_type: JavaScriptDialogType::Alert,
message: "hello".into(),
url: Some("https://example.com/".into()),
default_prompt: None,
has_browser_handler: Some(false),
handled: JavaScriptDialogHandledAction::Accepted,
sequence: 1,
};
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["type"], json!("alert"));
assert_eq!(v["handled"], json!("accepted"));
let round: JavaScriptDialogInfo = serde_json::from_value(v).unwrap();
assert_eq!(round, info);
}
#[test]
fn beforeunload_serialises_as_beforeunload() {
let v = serde_json::to_value(JavaScriptDialogType::BeforeUnload).unwrap();
assert_eq!(v, json!("beforeunload"));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Human-in-loop tool (`tool.request_help`).
//!
//! Lets an agent pause and ask the human to complete an in-page step
//! (captcha, login, confirmation). The extension brings the target tab
//! to the foreground, highlights the requested components, and blocks
//! until the user clicks Continue / Cancel (or the call times out).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// One component the user is asked to look at / interact with. Exactly
/// one of `ref` / `selector` should be supplied (mirrors the
/// click/fill convention). When neither matches a live element the
/// overlay still shows the prompt and reports the miss via
/// [`ResolvedTarget`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct HelpTarget {
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RequestHelpParams {
pub session_id: String,
/// Target tab. Defaults to the Agent Window's active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Message shown to the user explaining what to do.
pub prompt: String,
/// Optional custom title for the help overlay. Falls back to the
/// extension's default localized title when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional components to scroll to + flash-highlight.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub targets: Option<Vec<HelpTarget>>,
/// Hard upper bound on the wait. Defaults to 300000 (5 min),
/// enforced daemon-side via the generic tool timeout.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum HelpOutcome {
/// User completed the step and clicked Continue.
Continued,
/// User declined / aborted via the overlay's Cancel button.
Cancelled,
/// The wait expired before the user acted.
TimedOut,
/// The page navigated while waiting (full reload or SPA URL change).
Navigated,
}
impl HelpOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Continued => "continued",
Self::Cancelled => "cancelled",
Self::TimedOut => "timed_out",
Self::Navigated => "navigated",
}
}
}
/// Per-target resolution status echoed back so the agent can tell which
/// `ref` / `selector` actually matched a live element.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ResolvedTarget {
pub matched: bool,
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RequestHelpResult {
pub outcome: HelpOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_targets: Option<Vec<ResolvedTarget>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn target_serialises_ref_field_name() {
let t = HelpTarget {
ref_: Some("@e3".into()),
selector: None,
};
let v = serde_json::to_value(&t).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("selector").is_none());
}
#[test]
fn target_accepts_legacy_ref_alias() {
let t: HelpTarget = serde_json::from_value(json!({ "ref_": "e1" })).unwrap();
assert_eq!(t.ref_.as_deref(), Some("e1"));
}
#[test]
fn params_omit_optional_fields() {
let p = RequestHelpParams {
session_id: "abcd".into(),
tab_id: None,
prompt: "log in".into(),
title: None,
targets: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
assert!(v.get("title").is_none());
assert!(v.get("targets").is_none());
assert!(v.get("timeout_ms").is_none());
let round: RequestHelpParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn outcome_serialises_snake_case() {
assert_eq!(
serde_json::to_value(HelpOutcome::TimedOut).unwrap(),
json!("timed_out")
);
assert_eq!(
serde_json::to_value(HelpOutcome::Continued).unwrap(),
json!("continued")
);
assert_eq!(
serde_json::to_value(HelpOutcome::Cancelled).unwrap(),
json!("cancelled")
);
assert_eq!(
serde_json::to_value(HelpOutcome::Navigated).unwrap(),
json!("navigated")
);
}
#[test]
fn result_round_trips() {
let r = RequestHelpResult {
outcome: HelpOutcome::Continued,
note: Some("done".into()),
tab_id: 7,
resolved_targets: Some(vec![ResolvedTarget {
matched: true,
ref_: Some("@e1".into()),
selector: None,
}]),
};
let v = serde_json::to_value(&r).unwrap();
let round: RequestHelpResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
}
@@ -0,0 +1,316 @@
//! DOM interaction tools (`tool.click`, `tool.fill`, `tool.press`,
//! `tool.select`).
//!
//! Element-targeted tools accept either a snapshot `ref` (`@e<N>` form,
//! normalised against the session's RefStore) **or** a CSS selector
//! resolved at call time. Modifiers / mouse buttons are encoded as
//! lowercase JSON strings so the same wire shape works for CLI flags and
//! the extension's CDP bridge.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
/// Keyboard modifier flags. Multiple flags may be combined; the
/// extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4,
/// shift=8`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum KeyModifier {
Alt,
Ctrl,
Meta,
Shift,
}
/// Mouse button selector for `tool.click`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
#[default]
Left,
Middle,
Right,
}
// ---------------------------------------------------------------------------
// click
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ClickParams {
pub session_id: String,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Mutually exclusive with `selector` (caller must supply exactly
/// one). Accepts both `"e3"` and `"@e3"`.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// Target tab. Defaults to the Agent Window's active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub button: Option<MouseButton>,
/// Number of consecutive mouse presses (double-click = 2).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub click_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modifiers: Option<Vec<KeyModifier>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ClickResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
/// Viewport-relative click coordinates (CSS pixels). Reported so
/// agents can correlate with a follow-up `tool.screenshot`.
pub x: f64,
pub y: f64,
/// Native JS dialogs observed and auto-handled during this call.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// fill
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FillParams {
pub session_id: String,
pub value: String,
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Clear the field before typing. Defaults to `true`; pass `false`
/// to append instead of replacing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clear_before: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FillResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
/// UTF-16 code-unit length of the value that was finally typed
/// (matches what `input.value.length` would report in the page).
pub value_length: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// press
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PressParams {
pub session_id: String,
/// Logical key name. Accepts CDP `key` strings (`Enter`, `Escape`,
/// `ArrowDown`, single characters like `a`), or a compound
/// expression such as `Ctrl+A` / `Meta+Shift+P`. Modifiers in the
/// compound form combine with anything supplied via `modifiers`.
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modifiers: Option<Vec<KeyModifier>>,
/// Optional target to focus before dispatching the key.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Hold the key down for this many milliseconds between `keyDown`
/// and `keyUp`. Useful for testing long-press handlers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hold_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PressResult {
pub tab_id: i64,
pub key: String,
pub code: String,
#[serde(default)]
pub modifiers: Vec<KeyModifier>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// select
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SelectParams {
pub session_id: String,
/// Option `value` strings to set as the final selection. For a
/// single-select `<select>` exactly one value is required; for
/// `<select multiple>` the list replaces the current selection
/// (an empty list clears all selections).
pub values: Vec<String>,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Mutually exclusive with `selector`. Accepts both `"e3"` and
/// `"@e3"`.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
/// CSS selector resolved against the live DOM. Mutually exclusive
/// with `ref_`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// Target tab. Defaults to the Agent Window's active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Maximum time the daemon waits for the tool call before timing out.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SelectResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
/// Whether the target `<select>` had the `multiple` attribute.
pub multiple: bool,
/// Final selected option `value` attributes after the call.
pub selected_values: Vec<String>,
/// Visible labels of the selected options (same order as
/// `selected_values`).
pub selected_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn click_params_serialise_ref_field_name() {
let p = ClickParams {
session_id: "abcd".into(),
ref_: Some("@e3".into()),
selector: None,
tab_id: Some(42),
button: Some(MouseButton::Left),
click_count: Some(1),
modifiers: Some(vec![KeyModifier::Ctrl]),
timeout_ms: Some(5_000),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("ref_").is_none());
let round: ClickParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn click_params_accept_legacy_ref_alias() {
let p: ClickParams = serde_json::from_value(json!({
"session_id": "a",
"ref_": "e1",
"selector": null,
}))
.unwrap();
assert_eq!(p.ref_.as_deref(), Some("e1"));
}
#[test]
fn modifiers_render_as_lowercase_strings() {
let v = serde_json::to_value(KeyModifier::Ctrl).unwrap();
assert_eq!(v, json!("ctrl"));
let v = serde_json::to_value(KeyModifier::Meta).unwrap();
assert_eq!(v, json!("meta"));
}
#[test]
fn press_result_round_trips() {
let r = PressResult {
tab_id: 5,
key: "a".into(),
code: "KeyA".into(),
modifiers: vec![KeyModifier::Ctrl, KeyModifier::Shift],
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: PressResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn fill_params_default_clear_before_is_omitted() {
let p = FillParams {
session_id: "abcd".into(),
value: "hello".into(),
ref_: Some("@e1".into()),
selector: None,
tab_id: None,
clear_before: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("clear_before").is_none());
}
#[test]
fn select_params_round_trips_values() {
let p = SelectParams {
session_id: "abcd".into(),
values: vec!["us".into(), "ca".into()],
ref_: Some("@e3".into()),
selector: None,
tab_id: Some(12),
timeout_ms: Some(5_000),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("values").cloned(), Some(json!(["us", "ca"])));
let round: SelectParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Typed params/results for the 21 `tool.*` RPC methods (§7).
pub mod dialog;
pub mod human_loop;
pub mod interaction;
pub mod navigation;
pub mod observation;
pub mod script;
pub mod session;
pub mod tabs;
pub mod waits;
pub use dialog::*;
pub use human_loop::*;
pub use interaction::*;
pub use navigation::*;
pub use observation::*;
pub use script::*;
pub use session::*;
pub use tabs::*;
pub use waits::*;
+230
View File
@@ -0,0 +1,230 @@
//! Navigation tools (`tool.navigate*`, `tool.reload`).
//!
//! M7 expands each method with `wait_until`, `timeout_ms`, and a
//! richer result shape that lets the daemon / CLI know which lifecycle
//! phase actually fired (so a `--wait-until=load` that times out still
//! reports `reached: "commit"` instead of an empty success). All
//! `tab_id` fields are optional and resolve to the requesting
//! session's Agent Window active tab when omitted (design §7).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
/// Page-lifecycle checkpoint a navigation should wait on.
///
/// Mapping to CDP `Page.lifecycleEvent` names:
/// * `Load` → `load`
/// * `DomContentLoaded` → `DOMContentLoaded`
/// * `NetworkIdle` → `networkIdle`
/// * `Commit` → `commit` (earliest event after the navigation actually
/// commits to a new document)
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum WaitUntil {
#[default]
Load,
DomContentLoaded,
NetworkIdle,
Commit,
}
// ---------------------------------------------------------------------------
// navigate
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateParams {
pub session_id: String,
pub url: String,
/// Target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Lifecycle phase to wait on. Defaults to [`WaitUntil::Load`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
/// Hard upper bound on the wait; the extension also enforces its
/// own AbortController hookup (M7 stops listening for further
/// lifecycle events once exceeded). Defaults to 30s if omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
/// Outcome of a navigation. `reached` is the lifecycle name the
/// extension actually observed before returning — either the requested
/// `wait_until` value, or `"timeout"` when the wait expired before
/// that event fired.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateResult {
pub tab_id: i64,
pub url: String,
/// Tab URL after the navigation settles. May differ from `url` if
/// the server redirected (e.g. http → https) or `wait_until=commit`
/// raced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// navigate_back / navigate_forward
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateBackParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateBackResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateForwardParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct NavigateForwardResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// reload
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ReloadParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
/// When `true`, bypass the HTTP cache (CDP `Page.reload(ignoreCache=true)`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hard: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ReloadResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub reached: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wait_until_round_trips_through_lowercase() {
for v in [
WaitUntil::Load,
WaitUntil::DomContentLoaded,
WaitUntil::NetworkIdle,
WaitUntil::Commit,
] {
let s = serde_json::to_string(&v).unwrap();
let parsed: WaitUntil = serde_json::from_str(&s).unwrap();
assert_eq!(v, parsed);
}
assert_eq!(
serde_json::to_string(&WaitUntil::Commit).unwrap(),
"\"commit\""
);
assert_eq!(
serde_json::to_string(&WaitUntil::DomContentLoaded).unwrap(),
"\"domcontentloaded\"",
);
}
#[test]
fn navigate_params_omit_tab_id_when_none() {
let p = NavigateParams {
session_id: "abcd".into(),
url: "https://example.com/".into(),
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(30_000),
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
let round: NavigateParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn navigate_result_round_trips_with_optional_fields() {
let r = NavigateResult {
tab_id: 7,
url: "https://example.com/".into(),
final_url: Some("https://www.example.com/".into()),
reached: "load".into(),
error_text: None,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: NavigateResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn reload_params_accept_hard_flag() {
let raw = json!({
"session_id": "abcd",
"hard": true,
});
let parsed: ReloadParams = serde_json::from_value(raw).unwrap();
assert_eq!(parsed.hard, Some(true));
}
}
@@ -0,0 +1,230 @@
//! Read-only observation tools (`tool.snapshot`, `tool.get_html`, `tool.screenshot`).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
// ---------------------------------------------------------------------------
// snapshot
// ---------------------------------------------------------------------------
/// Parameters for `tool.snapshot` — produce an indented accessibility
/// tree for one tab plus a fresh `@e<N>` ref-store on the extension
/// side (design §4 / §7).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SnapshotParams {
pub session_id: String,
/// Optional target tab. Defaults to the Agent Window's currently
/// active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Cap the depth of the rendered tree (defense against very deep
/// pages). Implementation-defined default; agents typically use
/// `Some(16)` for "browse and read" prompts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_depth: Option<u32>,
/// Soft cap on rendered tokens (approximate; extension uses a
/// best-effort heuristic based on character count).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SnapshotResult {
/// Indented aria-snapshot text. Refs are rendered as `@e<N>` so the
/// agent can copy them into subsequent `tool.click` / `tool.fill`
/// selectors.
pub text: String,
/// Number of `@e<N>` refs registered for this session by this
/// snapshot. Equivalent to the size of the new ref-store map.
pub ref_count: u32,
/// Tab the snapshot was actually computed for (after resolving the
/// optional `tab_id` default).
pub tab_id: i64,
/// Whether the rendered tree was truncated because of `max_depth` /
/// `max_tokens`. Agents may re-run with looser caps.
#[serde(default)]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// get_html
// ---------------------------------------------------------------------------
/// Parameters for `tool.get_html`. Either give a tab and (optionally) a
/// snapshot ref to scope the dump to one subtree, or leave both empty
/// to fall back to the Agent Window's active tab and the document
/// root.
///
/// The wire field is `"ref"` (a Rust keyword); the Rust binding lives
/// in [`Self::ref_`] and uses serde rename / alias so JSON stays clean.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetHtmlParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Accepts both `"e3"` and `"@e3"`. When given the extension calls
/// `DOM.getOuterHTML({ backendNodeId })` for the matching node;
/// otherwise it fetches the document element.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
/// Truncate the HTML to at most this many bytes. Optional;
/// extension applies a sensible default (`524288` = 512 KiB) when
/// omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GetHtmlResult {
pub html: String,
/// True when `html` was truncated to honour `max_bytes`. Agents
/// should treat the missing tail as elided.
#[serde(default)]
pub truncated: bool,
/// Total byte length of the original (un-truncated) HTML payload.
pub byte_size: u64,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
// ---------------------------------------------------------------------------
// screenshot
// ---------------------------------------------------------------------------
/// Parameters for `tool.screenshot`. Omit `ref` to capture the visible
/// tab; pass an `@e<N>` ref from the last `tool.snapshot` to crop to
/// that element.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScreenshotParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Optional `@e<N>` ref allocated by the last `tool.snapshot`.
/// Accepts both `"e3"` and `"@e3"`.
#[serde(
rename = "ref",
alias = "ref_",
default,
skip_serializing_if = "Option::is_none"
)]
pub ref_: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScreenshotResult {
/// Base64-encoded PNG payload (no `data:` prefix).
pub image_base64: String,
/// Pixel width parsed from the PNG IHDR. May be `0` when parsing
/// fails (extension only logs a warning in that case so the agent
/// still gets the image).
#[serde(default)]
pub width: u32,
/// Pixel height (see [`Self::width`]).
#[serde(default)]
pub height: u32,
/// Always `"png"` in v0.1; reserved for future JPEG / WebP support.
pub format: String,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn get_html_params_serialises_with_ref_field_name() {
let p = GetHtmlParams {
session_id: "abcd".into(),
tab_id: Some(7),
ref_: Some("@e3".into()),
max_bytes: Some(1024),
};
let v = serde_json::to_value(&p).unwrap();
// Wire field name is `ref`, never `ref_`.
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("ref_").is_none());
let round: GetHtmlParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn get_html_params_accepts_both_ref_aliases() {
let from_wire: GetHtmlParams =
serde_json::from_value(json!({ "session_id": "a", "ref": "e1" })).unwrap();
assert_eq!(from_wire.ref_.as_deref(), Some("e1"));
let from_alias: GetHtmlParams =
serde_json::from_value(json!({ "session_id": "a", "ref_": "e2" })).unwrap();
assert_eq!(from_alias.ref_.as_deref(), Some("e2"));
}
#[test]
fn snapshot_result_round_trips_with_ref_count() {
let r = SnapshotResult {
text: "root\n @e1 button \"submit\"\n".into(),
ref_count: 1,
tab_id: 42,
truncated: false,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["ref_count"], 1);
assert_eq!(v["text"], "root\n @e1 button \"submit\"\n");
let round: SnapshotResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn screenshot_params_serialises_with_ref_field_name() {
let p = ScreenshotParams {
session_id: "abcd".into(),
tab_id: Some(7),
ref_: Some("@e3".into()),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v.get("ref").and_then(|v| v.as_str()), Some("@e3"));
assert!(v.get("ref_").is_none());
let round: ScreenshotParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn screenshot_params_accepts_both_ref_aliases() {
let from_wire: ScreenshotParams =
serde_json::from_value(json!({ "session_id": "a", "ref": "e1" })).unwrap();
assert_eq!(from_wire.ref_.as_deref(), Some("e1"));
let from_alias: ScreenshotParams =
serde_json::from_value(json!({ "session_id": "a", "ref_": "e2" })).unwrap();
assert_eq!(from_alias.ref_.as_deref(), Some("e2"));
}
#[test]
fn screenshot_result_round_trips_with_image_fields() {
let r = ScreenshotResult {
image_base64: "iVBORw0KGgo=".into(),
width: 800,
height: 600,
format: "png".into(),
tab_id: 7,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: ScreenshotResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
}
+131
View File
@@ -0,0 +1,131 @@
//! `tool.evaluate` (design §4 / §7).
//!
//! M9.1 expands the M1 placeholder into the wire shape the extension
//! reports back from `Runtime.evaluate`: the call is sandboxed to the
//! session's Agent Window (borrowed tabs included), `tab_id` is
//! optional and defaults to the Agent Window's active tab, and JS
//! exceptions thrown by the evaluated expression are surfaced as
//! `ok: false` payloads (NOT structured RPC errors) so an agent can
//! inspect the `throw` text in-band.
//!
//! `value` is always a `serde_json::Value` — when the underlying CDP
//! result is unserialisable (`Infinity`, `NaN`, `BigInt`, …) the
//! extension stringifies it before returning so the wire payload stays
//! self-describing.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluateParams {
pub session_id: String,
pub expression: String,
/// Target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Await the expression when it resolves to a Promise (CDP
/// `Runtime.evaluate(awaitPromise=...)`). Defaults to `true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub await_promise: Option<bool>,
/// Ask CDP to serialise the result by value (vs. returning a
/// `RemoteObject` id we cannot use). Defaults to `true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub return_by_value: Option<bool>,
/// Hard upper bound on the evaluation. Defaults to 30s.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
/// Outcome of an `evaluate` call.
///
/// * `ok: true` → expression evaluated successfully; `value` holds the
/// JSON-serialised return value (`null` is meaningful and distinct
/// from "missing").
/// * `ok: false` → the expression threw; `error.text` holds the
/// stringified `exceptionDetails.text` / `exception.description`.
/// The RPC itself still succeeds — the daemon does NOT map JS throws
/// to top-level RPC errors so agents can act on the throw text.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluateResult {
pub ok: bool,
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<EvaluateError>,
/// Native JS dialogs observed and auto-handled during this call.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluateError {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn params_omit_optional_fields() {
let p = EvaluateParams {
session_id: "abcd".into(),
expression: "1+1".into(),
tab_id: None,
await_promise: None,
return_by_value: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
assert!(v.get("await_promise").is_none());
assert!(v.get("return_by_value").is_none());
assert!(v.get("timeout_ms").is_none());
let round: EvaluateParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn result_ok_with_value_round_trips() {
let r = EvaluateResult {
ok: true,
tab_id: 7,
value: Some(json!(2)),
error: None,
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v.get("value"), Some(&json!(2)));
assert!(v.get("error").is_none());
let round: EvaluateResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn result_throw_carries_error_text_and_position() {
let r = EvaluateResult {
ok: false,
tab_id: 7,
value: None,
error: Some(EvaluateError {
text: "Uncaught Error: boom".into(),
line: Some(1),
column: Some(6),
}),
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
assert!(v.get("value").is_none());
let round: EvaluateResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Session-scoped tools (`tool.session_*`).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ErrorCode;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStartParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_instance_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStartResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_window_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStopParams {
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ReturnFailure {
pub tab_id: i64,
pub code: ErrorCode,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionStopResult {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub returned_tab_ids: Vec<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub return_failures: Vec<ReturnFailure>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn session_stop_result_round_trips_auto_return_payload() {
let result: SessionStopResult = serde_json::from_value(json!({
"returned_tab_ids": [7, 8],
"return_failures": [
{ "tab_id": 9, "code": "cdp_failed", "message": "move failed" }
]
}))
.unwrap();
assert_eq!(result.returned_tab_ids, vec![7, 8]);
assert_eq!(result.return_failures[0].tab_id, 9);
assert_eq!(result.return_failures[0].code, ErrorCode::CdpFailed);
let encoded = serde_json::to_value(result).unwrap();
assert_eq!(encoded["returned_tab_ids"], json!([7, 8]));
assert_eq!(encoded["return_failures"][0]["code"], "cdp_failed");
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Tab tools (`tool.tab_*`).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// View scope for [`TabListParams`] (§6 sandbox table).
///
/// * `User` — tabs that live in any window other than an Agent Window.
/// * `Agent` — tabs in the requesting session's Agent Window only.
/// * `All` — both of the above; never reveals other sessions' Agent
/// Windows (cross-session isolation per §6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum TabScope {
User,
Agent,
#[default]
All,
}
/// A single tab entry returned by [`TabListResult`].
///
/// `scope` reports how the requesting session sees this tab: tabs owned
/// by the session's own Agent Window are tagged `agent`; everything
/// else falls into `user`. (Other sessions' Agent Windows are filtered
/// out before they reach this struct.)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabInfo {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
/// Where the tab sits relative to the requesting session: `user` or
/// `agent`. `all` is a request-side filter only and never appears
/// on an individual tab entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<TabScope>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabListParams {
#[serde(default)]
pub scope: TabScope,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabListResult {
pub tabs: Vec<TabInfo>,
}
// ---------------------------------------------------------------------------
// tab_create (M8.1)
// ---------------------------------------------------------------------------
/// Params for `tool.tab_create`. The new tab is always created inside
/// the requesting session's Agent Window (design §6 sandbox rule —
/// agents never spawn tabs in user windows).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCreateParams {
pub session_id: String,
/// Destination URL. Defaults to `chrome://newtab` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Focus the new tab? Defaults to `true` (matches `chrome.tabs.create`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
/// Insertion index within the Agent Window's tab strip. Omit to
/// append at the end.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCreateResult {
pub tab_id: i64,
pub window_id: i64,
/// Best-effort URL of the newly created tab. May be empty if Chrome
/// has not yet committed the navigation (e.g. pending load).
#[serde(default)]
pub url: String,
}
// ---------------------------------------------------------------------------
// tab_close (M8.1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCloseParams {
pub tab_id: i64,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabCloseResult {
pub tab_id: i64,
}
// ---------------------------------------------------------------------------
// tab_select (M8.1)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabSelectParams {
pub tab_id: i64,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabSelectResult {
pub tab_id: i64,
pub window_id: i64,
}
// ---------------------------------------------------------------------------
// tab_borrow (M8.2)
// ---------------------------------------------------------------------------
/// Params for `tool.tab_borrow`. Moves a *user* tab into the
/// requesting session's Agent Window, recording its original window /
/// index so `tab_return` (or session_stop) can put it back.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabBorrowParams {
pub tab_id: i64,
pub session_id: String,
/// Whether to wait for an inline user confirmation overlay (M10
/// will introduce the UI). Currently ignored: the M8 stub always
/// proceeds when `false` and forwards `true` to a no-op approver.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirm: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabBorrowResult {
pub tab_id: i64,
pub original_window_id: i64,
pub original_index: i32,
pub agent_window_id: i64,
}
// ---------------------------------------------------------------------------
// tab_return (M8.3)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabReturnParams {
pub tab_id: i64,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TabReturnResult {
pub tab_id: i64,
pub returned_to_window_id: i64,
pub returned_to_index: i32,
/// True when the original window had been closed and we fell back
/// to either the last focused normal window or a newly-created
/// window. Lets the CLI surface "tab returned to a different
/// window" hints.
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub fallback: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tab_info_scope_is_omitted_when_none() {
let info = TabInfo {
tab_id: 1,
title: Some("hi".into()),
url: None,
window_id: Some(2),
active: Some(true),
scope: None,
};
let v = serde_json::to_value(&info).unwrap();
assert!(v.get("scope").is_none());
let round: TabInfo = serde_json::from_value(v).unwrap();
assert_eq!(round, info);
}
#[test]
fn tab_info_scope_renders_as_lowercase_enum() {
let info = TabInfo {
tab_id: 1,
title: None,
url: None,
window_id: None,
active: None,
scope: Some(TabScope::Agent),
};
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["scope"], "agent");
// Round-trip from upstream JSON (no `title` / `url` keys).
let from_json: TabInfo = serde_json::from_value(json!({
"tab_id": 1,
"scope": "user"
}))
.unwrap();
assert_eq!(from_json.scope, Some(TabScope::User));
}
#[test]
fn tab_create_params_default_optional_fields() {
let v = json!({ "session_id": "aa11" });
let p: TabCreateParams = serde_json::from_value(v).unwrap();
assert_eq!(p.session_id, "aa11");
assert!(p.url.is_none());
assert!(p.active.is_none());
assert!(p.index.is_none());
}
#[test]
fn tab_create_params_round_trip_full() {
let p = TabCreateParams {
session_id: "aa11".into(),
url: Some("https://example.com/".into()),
active: Some(false),
index: Some(3),
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["url"], "https://example.com/");
assert_eq!(v["active"], false);
assert_eq!(v["index"], 3);
let round: TabCreateParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn tab_create_result_serialises_url_even_when_empty() {
let r = TabCreateResult {
tab_id: 7,
window_id: 100,
url: String::new(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["tab_id"], 7);
assert_eq!(v["url"], "");
}
#[test]
fn tab_close_select_result_round_trips() {
let c: TabCloseResult = serde_json::from_value(json!({ "tab_id": 9 })).unwrap();
assert_eq!(c.tab_id, 9);
let s: TabSelectResult =
serde_json::from_value(json!({ "tab_id": 9, "window_id": 100 })).unwrap();
assert_eq!(s.window_id, 100);
}
#[test]
fn tab_borrow_params_confirm_defaults_to_none() {
let p: TabBorrowParams =
serde_json::from_value(json!({ "tab_id": 1, "session_id": "aa11" })).unwrap();
assert!(p.confirm.is_none());
}
#[test]
fn tab_borrow_result_round_trip() {
let r = TabBorrowResult {
tab_id: 9,
original_window_id: 200,
original_index: 4,
agent_window_id: 100,
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["original_window_id"], 200);
assert_eq!(v["original_index"], 4);
let round: TabBorrowResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn tab_return_result_omits_fallback_when_false() {
let r = TabReturnResult {
tab_id: 9,
returned_to_window_id: 200,
returned_to_index: 4,
fallback: false,
};
let v = serde_json::to_value(&r).unwrap();
assert!(v.get("fallback").is_none());
let r2 = TabReturnResult {
fallback: true,
..r
};
let v2 = serde_json::to_value(&r2).unwrap();
assert_eq!(v2["fallback"], true);
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Timing helpers (`tool.wait_for_navigation`, `tool.wait_ms`).
//!
//! M9.2: `wait_for_navigation` mirrors the `navigate` wire shape — it
//! waits on a CDP `Page.lifecycleEvent` and reports back via `reached`
//! / `error_text` so a timeout still tells the caller which lifecycle
//! phase the page actually reached. `wait_until` defaults to `load`.
//!
//! M9.3: `wait_ms` is a pure daemon-side sleep (no extension hop, no
//! session needed). The result echoes the requested duration so the
//! caller can confirm a 0ms wait still went through the IPC layer.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::JavaScriptDialogInfo;
use super::navigation::WaitUntil;
// ---------------------------------------------------------------------------
// wait_for_navigation
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitForNavigationParams {
pub session_id: String,
/// Target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Lifecycle phase to wait on. Defaults to [`WaitUntil::Load`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_until: Option<WaitUntil>,
/// Hard upper bound on the wait. Defaults to 30s.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u32>,
}
/// Outcome of a wait_for_navigation. `reached` is the wire name of the
/// lifecycle phase the extension actually observed before returning —
/// either the requested `wait_until`, or `"timeout"` when the wait
/// expired before that event fired.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitForNavigationResult {
pub tab_id: i64,
pub reached: WaitForNavigationReached,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dialogs: Vec<JavaScriptDialogInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum WaitForNavigationReached {
#[serde(rename = "load")]
Load,
#[serde(rename = "domcontentloaded")]
DomContentLoaded,
#[serde(rename = "networkidle")]
NetworkIdle,
#[serde(rename = "commit")]
Commit,
#[serde(rename = "timeout")]
Timeout,
}
impl WaitForNavigationReached {
pub fn as_str(self) -> &'static str {
match self {
Self::Load => "load",
Self::DomContentLoaded => "domcontentloaded",
Self::NetworkIdle => "networkidle",
Self::Commit => "commit",
Self::Timeout => "timeout",
}
}
}
// ---------------------------------------------------------------------------
// wait_ms
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitMsParams {
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct WaitMsResult {
pub waited_ms: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn wait_for_navigation_params_omit_optional_fields() {
let p = WaitForNavigationParams {
session_id: "abcd".into(),
tab_id: None,
wait_until: None,
timeout_ms: None,
};
let v = serde_json::to_value(&p).unwrap();
assert!(v.get("tab_id").is_none());
assert!(v.get("wait_until").is_none());
assert!(v.get("timeout_ms").is_none());
let round: WaitForNavigationParams = serde_json::from_value(v).unwrap();
assert_eq!(round, p);
}
#[test]
fn wait_for_navigation_result_round_trips_timeout() {
let r = WaitForNavigationResult {
tab_id: 9,
reached: WaitForNavigationReached::Timeout,
error_text: Some("timed out waiting for lifecycle \"load\"".into()),
dialogs: vec![],
};
let v = serde_json::to_value(&r).unwrap();
let round: WaitForNavigationResult = serde_json::from_value(v).unwrap();
assert_eq!(round, r);
}
#[test]
fn wait_for_navigation_result_rejects_unknown_reached_value() {
let res = serde_json::from_value::<WaitForNavigationResult>(json!({
"tab_id": 9,
"reached": "painted"
}));
assert!(res.is_err());
}
#[test]
fn wait_ms_round_trips() {
let params: WaitMsParams = serde_json::from_value(json!({ "duration_ms": 250 })).unwrap();
assert_eq!(params.duration_ms, 250);
let result = WaitMsResult { waited_ms: 250 };
let v = serde_json::to_value(&result).unwrap();
assert_eq!(v, json!({ "waited_ms": 250 }));
}
}