feat(protocol): bump to 1.1 and redefine record traces as state-linked v3

Replace page-indexed record traces with states/steps + FillCommit/hover/scroll,
and raise daemon/extension protocol floors together so old 1.0 peers are rejected.
This commit is contained in:
“haonan”
2026-08-13 19:31:35 +08:00
parent 37fbaacc94
commit 1508839bf9
23 changed files with 1000 additions and 753 deletions
@@ -65,6 +65,11 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol
} }
describe("performHandshake", () => { describe("performHandshake", () => {
it("advertises the Trace v3 compatibility boundary", () => {
expect(PROTOCOL_VERSION).toBe("1.1");
expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.1");
});
it("sends system.handshake with identity and both compat fields", async () => { it("sends system.handshake with identity and both compat fields", async () => {
let sentFrame: ProtocolFrame | null = null; let sentFrame: ProtocolFrame | null = null;
const transport = fakeTransport((req) => { const transport = fakeTransport((req) => {
@@ -74,9 +79,9 @@ describe("performHandshake", () => {
result: { result: {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.0", protocol_version: "1.1",
min_compatible_peer: "0.0.0", min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.0", min_compatible_protocol: "1.1",
}, },
}; };
}); });
@@ -102,7 +107,7 @@ describe("performHandshake", () => {
}, },
}); });
expect(outcome.result.min_compatible_peer).toBe("0.0.0"); expect(outcome.result.min_compatible_peer).toBe("0.0.0");
expect(outcome.result.min_compatible_protocol).toBe("1.0"); expect(outcome.result.min_compatible_protocol).toBe("1.1");
}); });
it("accepts legacy daemon reply with only min_compatible_peer", async () => { it("accepts legacy daemon reply with only min_compatible_peer", async () => {
@@ -129,8 +134,8 @@ describe("performHandshake", () => {
const response = { const response = {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.0", protocol_version: "1.1",
min_compatible_protocol: "1.0", min_compatible_protocol: "1.1",
} satisfies HandshakeResult; } satisfies HandshakeResult;
const transport = fakeTransport((req) => ({ const transport = fakeTransport((req) => ({
id: (req as { id: string }).id, id: (req as { id: string }).id,
@@ -145,7 +150,7 @@ describe("performHandshake", () => {
}); });
expect(outcome.result.min_compatible_peer).toBeUndefined(); expect(outcome.result.min_compatible_peer).toBeUndefined();
expect(outcome.result.min_compatible_protocol).toBe("1.0"); expect(outcome.result.min_compatible_protocol).toBe("1.1");
}); });
it("rejects when the daemon responds with an error", async () => { it("rejects when the daemon responds with an error", async () => {
@@ -178,9 +183,9 @@ describe("performHandshake", () => {
result: { result: {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.0", protocol_version: "1.1",
min_compatible_peer: "0.0.0", min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.0", min_compatible_protocol: "1.1",
}, },
}); });
emit({ emit({
@@ -188,14 +193,14 @@ describe("performHandshake", () => {
result: { result: {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.0", protocol_version: "1.1",
min_compatible_peer: "0.0.0", min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.0", min_compatible_protocol: "1.1",
}, },
}); });
await expect(pending).resolves.toMatchObject({ await expect(pending).resolves.toMatchObject({
result: { server: "browser-skill-daemon", protocol_version: "1.0" }, result: { server: "browser-skill-daemon", protocol_version: "1.1" },
}); });
}); });
+2 -2
View File
@@ -7,7 +7,7 @@ import type {
ResponseFrame, ResponseFrame,
} from "./types"; } from "./types";
export const PROTOCOL_VERSION = "1.0"; export const PROTOCOL_VERSION = "1.1";
/** /**
* Extension semver, injected at build time from `package.json` via * Extension semver, injected at build time from `package.json` via
* Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`). * Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`).
@@ -18,7 +18,7 @@ export const EXTENSION_VERSION: string =
* Lowest **protocol** version this extension accepts (e.g. `"1.0"`). * Lowest **protocol** version this extension accepts (e.g. `"1.0"`).
* Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`. * Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`.
*/ */
export const MIN_COMPATIBLE_PROTOCOL = "1.0"; export const MIN_COMPATIBLE_PROTOCOL = "1.1";
/** /**
* **Deprecated** — legacy app-semver floor for wire compat with old * **Deprecated** — legacy app-semver floor for wire compat with old
* daemons. New code sends `"0.0.0"`; compat decisions ignore this. * daemons. New code sends `"0.0.0"`; compat decisions ignore this.
+5 -5
View File
@@ -480,7 +480,7 @@ mod m2_tests {
fn fake_status(browsers: Vec<BrowserStatusEntry>, skew: Vec<VersionSkewEntry>) -> StatusResult { fn fake_status(browsers: Vec<BrowserStatusEntry>, skew: Vec<VersionSkewEntry>) -> StatusResult {
StatusResult { StatusResult {
daemon_version: env!("CARGO_PKG_VERSION").into(), daemon_version: env!("CARGO_PKG_VERSION").into(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
pid: 1, pid: 1,
uptime_secs: 0, uptime_secs: 0,
ws_port: 0, ws_port: 0,
@@ -545,7 +545,7 @@ mod m2_tests {
session_count: 0, session_count: 0,
connected_at_ms: 1, connected_at_ms: 1,
version_skew: false, version_skew: false,
extension_protocol_version: "1.0".into(), extension_protocol_version: "1.1".into(),
}], }],
Vec::new(), Vec::new(),
); );
@@ -566,7 +566,7 @@ mod m2_tests {
session_count: 0, session_count: 0,
connected_at_ms: 1, connected_at_ms: 1,
version_skew: true, version_skew: true,
extension_protocol_version: "1.1".into(), extension_protocol_version: "1.2".into(),
}], }],
vec![VersionSkewEntry { vec![VersionSkewEntry {
instance_id: "alpha".into(), instance_id: "alpha".into(),
@@ -574,8 +574,8 @@ mod m2_tests {
label: "Personal".into(), label: "Personal".into(),
server_version: env!("CARGO_PKG_VERSION").into(), server_version: env!("CARGO_PKG_VERSION").into(),
client_version: "0.0.9".into(), client_version: "0.0.9".into(),
server_protocol_version: "1.0".into(), server_protocol_version: "1.1".into(),
client_protocol_version: "1.1".into(), client_protocol_version: "1.2".into(),
}], }],
); );
let check = check_browsers_protocol_compatible(Some(&status)); let check = check_browsers_protocol_compatible(Some(&status));
+2 -2
View File
@@ -27,7 +27,7 @@ use crate::daemon::{
browsers::{BROWSER_LIVENESS_TICK, BROWSER_LIVENESS_TIMEOUT, EXTENSION_CONNECT_WAIT}, browsers::{BROWSER_LIVENESS_TICK, BROWSER_LIVENESS_TIMEOUT, EXTENSION_CONNECT_WAIT},
info as daemon_info, ipc, lockfile, paths, info as daemon_info, ipc, lockfile, paths,
sessions::{StopSessionError, forget_session, stop_session}, sessions::{StopSessionError, forget_session, stop_session},
state::DaemonState, state::{DaemonState, PROTOCOL_VERSION},
ws, ws,
}; };
@@ -299,7 +299,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
ws_port, ws_port,
sock_path: sock_path.clone(), sock_path: sock_path.clone(),
daemon_version: env!("CARGO_PKG_VERSION"), daemon_version: env!("CARGO_PKG_VERSION"),
protocol_version: "1.0", protocol_version: PROTOCOL_VERSION,
}; };
let handler = ipc::full_handler(status, Arc::clone(&state)); let handler = ipc::full_handler(status, Arc::clone(&state));
+3 -3
View File
@@ -16,9 +16,9 @@ use super::start::DaemonConfig;
use super::ws::WsHandle; use super::ws::WsHandle;
pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PROTOCOL_VERSION: &str = "1.0"; pub const PROTOCOL_VERSION: &str = "1.1";
/// Lowest **protocol** version peers must speak (e.g. `"1.0"`). /// Lowest **protocol** version peers must speak (e.g. `"1.1"`).
pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.0"; pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.1";
/// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer` /// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer`
/// is emitted for old extensions. New code ignores this on read. /// is emitted for old extensions. New code ignores this on read.
pub const LEGACY_MIN_COMPATIBLE_PEER: &str = "0.0.0"; pub const LEGACY_MIN_COMPATIBLE_PEER: &str = "0.0.0";
+2 -2
View File
@@ -112,14 +112,14 @@ async fn handshake_as_ext(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
+2 -2
View File
@@ -75,7 +75,7 @@ async fn handshake_as_ext(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
@@ -83,7 +83,7 @@ async fn handshake_as_ext(
}, },
label: "Test".into(), label: "Test".into(),
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
}; };
let req = RequestFrame { let req = RequestFrame {
id: "hs".into(), id: "hs".into(),
+33 -11
View File
@@ -108,12 +108,12 @@ async fn send_handshake_with_floors(
async fn handshake_ok_when_protocol_matches() { async fn handshake_ok_when_protocol_matches() {
let (handle, _sock) = spawn_daemon().await; let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await; let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake(&mut ws, "1.0", env!("CARGO_PKG_VERSION")).await; let resp = send_handshake(&mut ws, "1.1", env!("CARGO_PKG_VERSION")).await;
let result: HandshakeResult = match resp.body { let result: HandshakeResult = match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(), ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("expected ok handshake, got {e:?}"), ResponseBody::Err(e) => panic!("expected ok handshake, got {e:?}"),
}; };
assert_eq!(result.protocol_version, "1.0"); assert_eq!(result.protocol_version, "1.1");
assert_eq!( assert_eq!(
result result
.min_compatible_peer .min_compatible_peer
@@ -124,7 +124,7 @@ async fn handshake_ok_when_protocol_matches() {
); );
assert_eq!( assert_eq!(
result.min_compatible_protocol.as_deref(), result.min_compatible_protocol.as_deref(),
Some("1.0"), Some("1.1"),
"daemon must advertise protocol floor for new extensions" "daemon must advertise protocol floor for new extensions"
); );
handle.shutdown().await; handle.shutdown().await;
@@ -135,7 +135,7 @@ async fn handshake_ok_when_app_versions_differ_but_protocol_matches() {
let (handle, _sock) = spawn_daemon().await; let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await; let mut ws = open_ws(handle.ws_addr()).await;
let resp = let resp =
send_handshake_with_floors(&mut ws, "1.0", "9.9.9", Some("0.0.0"), Some("1.0")).await; send_handshake_with_floors(&mut ws, "1.1", "9.9.9", Some("0.0.0"), Some("1.1")).await;
match resp.body { match resp.body {
ResponseBody::Ok(_) => {} ResponseBody::Ok(_) => {}
other => panic!("expected ok when protocol matches, got {other:?}"), other => panic!("expected ok when protocol matches, got {other:?}"),
@@ -149,10 +149,10 @@ async fn handshake_skew_when_protocol_minor_differs() {
let mut ws = open_ws(handle.ws_addr()).await; let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors( let resp = send_handshake_with_floors(
&mut ws, &mut ws,
"1.1", "1.2",
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
Some("0.0.0"), Some("0.0.0"),
Some("1.0"), Some("1.1"),
) )
.await; .await;
match resp.body { match resp.body {
@@ -168,6 +168,28 @@ async fn handshake_skew_when_protocol_minor_differs() {
handle.shutdown().await; handle.shutdown().await;
} }
#[tokio::test]
async fn handshake_rejects_pre_trace_v3_extension() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors(
&mut ws,
"1.0",
env!("CARGO_PKG_VERSION"),
Some("0.0.0"),
Some("1.0"),
)
.await;
match resp.body {
ResponseBody::Err(e) => {
assert_eq!(e.code, ErrorCode::VersionTooOld);
assert!(e.message.contains("below local min_compatible_protocol"));
}
other => panic!("pre-Trace-v3 extension must be rejected, got {other:?}"),
}
handle.shutdown().await;
}
#[tokio::test] #[tokio::test]
async fn handshake_rejected_on_protocol_major_mismatch() { async fn handshake_rejected_on_protocol_major_mismatch() {
let (handle, _sock) = spawn_daemon().await; let (handle, _sock) = spawn_daemon().await;
@@ -210,7 +232,7 @@ async fn handshake_legacy_ext_without_protocol_floor_still_ok() {
let mut ws = open_ws(handle.ws_addr()).await; let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors( let resp = send_handshake_with_floors(
&mut ws, &mut ws,
"1.0", "1.1",
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
Some("0.1.0"), Some("0.1.0"),
None, None,
@@ -235,7 +257,7 @@ async fn status_surfaces_version_skew_for_skewed_browser() {
browser_name: "chrome".into(), browser_name: "chrome".into(),
browser_version: "131.0".into(), browser_version: "131.0".into(),
extension_version: "9.9.9".into(), extension_version: "9.9.9".into(),
extension_protocol_version: "1.1".into(), extension_protocol_version: "1.2".into(),
label: "Older".into(), label: "Older".into(),
sink: bsk::daemon::browsers::BrowserSink { tx }, sink: bsk::daemon::browsers::BrowserSink { tx },
pending: Mutex::new(bsk::daemon::browsers::Pending::default()), pending: Mutex::new(bsk::daemon::browsers::Pending::default()),
@@ -263,8 +285,8 @@ async fn status_surfaces_version_skew_for_skewed_browser() {
.iter() .iter()
.find(|s| s.instance_id == "skew-only-test") .find(|s| s.instance_id == "skew-only-test")
.expect("status must list our skew client"); .expect("status must list our skew client");
assert_eq!(skew.client_protocol_version, "1.1"); assert_eq!(skew.client_protocol_version, "1.2");
assert_eq!(skew.server_protocol_version, "1.0"); assert_eq!(skew.server_protocol_version, "1.1");
assert_eq!(skew.client_version, "9.9.9"); assert_eq!(skew.client_version, "9.9.9");
let entry = status let entry = status
.browsers .browsers
@@ -281,7 +303,7 @@ async fn handshake_rejects_when_local_below_peer_min_compatible_protocol() {
let mut ws = open_ws(handle.ws_addr()).await; let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors( let resp = send_handshake_with_floors(
&mut ws, &mut ws,
"1.0", "1.1",
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
Some("0.0.0"), Some("0.0.0"),
Some("99.0.0"), Some("99.0.0"),
+2 -2
View File
@@ -81,14 +81,14 @@ async fn handshake_as_ext(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
@@ -81,7 +81,7 @@ async fn handshake_as_ext(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
@@ -89,7 +89,7 @@ async fn handshake_as_ext(
}, },
label: "Test".into(), label: "Test".into(),
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
}; };
let req = RequestFrame { let req = RequestFrame {
id: "hs".into(), id: "hs".into(),
+4 -4
View File
@@ -84,14 +84,14 @@ async fn handshake_as_ext(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
@@ -400,14 +400,14 @@ async fn connect_second_ext(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: instance_id.into(), instance_id: instance_id.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "edge".into(), name: "edge".into(),
version: "130".into(), version: "130".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: label.into(), label: label.into(),
}; };
let hs = RequestFrame { let hs = RequestFrame {
+1 -1
View File
@@ -61,7 +61,7 @@ fn bsk_status_json_returns_structured_payload() {
assert!(parsed["pid"].as_u64().unwrap() > 0); assert!(parsed["pid"].as_u64().unwrap() > 0);
assert!(!parsed["daemon_version"].as_str().unwrap().is_empty()); assert!(!parsed["daemon_version"].as_str().unwrap().is_empty());
assert_eq!(parsed["protocol_version"], "1.0"); assert_eq!(parsed["protocol_version"], "1.1");
assert!(parsed["sock_path"].as_str().is_some()); assert!(parsed["sock_path"].as_str().is_some());
assert_eq!(parsed["browsers"], serde_json::json!([])); assert_eq!(parsed["browsers"], serde_json::json!([]));
assert_eq!(parsed["sessions"], serde_json::json!([])); assert_eq!(parsed["sessions"], serde_json::json!([]));
+2 -2
View File
@@ -73,14 +73,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
+2 -2
View File
@@ -76,14 +76,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
+2 -2
View File
@@ -75,14 +75,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
+2 -2
View File
@@ -85,14 +85,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: TEST_EXT_ID.into(), instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test".into(), label: "Test".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
+3 -3
View File
@@ -53,14 +53,14 @@ pub async fn send_handshake(
let params = HandshakeParams { let params = HandshakeParams {
client: "browser-skill-extension".into(), client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(), version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(), protocol_version: "1.1".into(),
instance_id: instance_id.into(), instance_id: instance_id.into(),
browser: BrowserPeerInfo { browser: BrowserPeerInfo {
name: "chrome".into(), name: "chrome".into(),
version: "131.0".into(), version: "131.0".into(),
}, },
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()), min_compatible_protocol: Some("1.1".into()),
label: "Test Chrome".into(), label: "Test Chrome".into(),
}; };
let req = RequestFrame { let req = RequestFrame {
@@ -91,7 +91,7 @@ async fn ws_handshake_registers_browser_in_state() {
let mut ws = connect_ext(handle.ws_addr(), &origin).await; let mut ws = connect_ext(handle.ws_addr(), &origin).await;
let result = send_handshake(&mut ws, TEST_EXT_ID).await; let result = send_handshake(&mut ws, TEST_EXT_ID).await;
assert_eq!(result.server, "browser-skill-daemon"); assert_eq!(result.server, "browser-skill-daemon");
assert_eq!(result.protocol_version, "1.0"); assert_eq!(result.protocol_version, "1.1");
let state = handle.state(); let state = handle.state();
let browsers = state.browsers.snapshot(); let browsers = state.browsers.snapshot();
@@ -11,6 +11,14 @@
} }
}, },
"definitions": { "definitions": {
"FillCommit": {
"type": "string",
"enum": [
"enter",
"suggestion",
"blur"
]
},
"KeyModifier": { "KeyModifier": {
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
"type": "string", "type": "string",
@@ -21,25 +29,32 @@
"shift" "shift"
] ]
}, },
"PageRef": { "NavigationCause": {
"description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "string",
"enum": [
"user_typed",
"link",
"form_submit",
"reload",
"history",
"script",
"browser"
]
},
"RecorderInfo": {
"type": "object", "type": "object",
"required": [ "required": [
"id", "bsk",
"url" "vom"
], ],
"properties": { "properties": {
"id": { "bsk": {
"type": "string" "type": "string"
}, },
"title": { "vom": {
"type": [ "type": "integer",
"string", "format": "uint32",
"null" "minimum": 0.0
]
},
"url": {
"type": "string"
} }
} }
}, },
@@ -68,21 +83,16 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"cause",
"id", "id",
"op", "op",
"page", "result",
"state",
"to" "to"
], ],
"properties": { "properties": {
"effect": { "cause": {
"anyOf": [ "$ref": "#/definitions/NavigationCause"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -95,8 +105,11 @@
"navigate" "navigate"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"to": { "to": {
@@ -110,20 +123,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -135,8 +139,11 @@
"click" "click"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -150,20 +157,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -175,8 +173,11 @@
"hover" "hover"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -188,22 +189,17 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"commit",
"id", "id",
"op", "op",
"page", "result",
"state",
"target", "target",
"value" "value"
], ],
"properties": { "properties": {
"effect": { "commit": {
"anyOf": [ "$ref": "#/definitions/FillCommit"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -216,15 +212,15 @@
"fill" "fill"
] ]
}, },
"page": {
"description": "Reference into `pages[]`.",
"type": "string"
},
"redacted": { "redacted": {
"type": [ "type": "boolean"
"boolean", },
"null" "result": {
] "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}, },
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
@@ -240,21 +236,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"selection", "state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -266,9 +252,8 @@
"select" "select"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
"type": "string"
}, },
"selection": { "selection": {
"type": "array", "type": "array",
@@ -276,6 +261,10 @@
"$ref": "#/definitions/SelectedOption" "$ref": "#/definitions/SelectedOption"
} }
}, },
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
},
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
} }
@@ -288,19 +277,10 @@
"id", "id",
"key", "key",
"op", "op",
"page" "result",
"state"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -324,8 +304,11 @@
"press" "press"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -339,48 +322,74 @@
] ]
} }
} }
},
{
"description": "Fields shared by every step variant (flattened in JSON).",
"type": "object",
"required": [
"id",
"op",
"result",
"state"
],
"properties": {
"id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"op": {
"type": "string",
"enum": [
"scroll"
]
},
"result": {
"$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}
}
} }
] ]
}, },
"StepEffect": { "StepResult": {
"description": "Observed navigation after a step (objective fact only).",
"type": "object", "type": "object",
"required": [ "required": [
"navigated_to" "state"
], ],
"properties": { "properties": {
"navigated_to": { "state": {
"description": "Reference into `pages[]` for the destination page.",
"type": "string" "type": "string"
} }
} }
}, },
"StopReason": {
"type": "string",
"enum": [
"user_finish",
"cli_stop"
]
},
"TargetDescriptor": { "TargetDescriptor": {
"description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", "description": "Stable semantic handle for an interacted element within a page observation.",
"type": "object", "type": "object",
"required": [
"tag"
],
"properties": { "properties": {
"ctx": {
"type": [
"string",
"null"
]
},
"name": { "name": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"name_attr": { "ref": {
"type": [
"string",
"null"
]
},
"nearby_label": {
"type": [
"string",
"null"
]
},
"placeholder": {
"type": [ "type": [
"string", "string",
"null" "null"
@@ -392,8 +401,8 @@
"null" "null"
] ]
}, },
"tag": { "unmatched": {
"type": "string" "type": "boolean"
} }
} }
}, },
@@ -402,42 +411,54 @@
"type": "object", "type": "object",
"required": [ "required": [
"entry", "entry",
"pages",
"recorded_at", "recorded_at",
"steps" "recorder",
"states",
"steps",
"stopped_by",
"version"
], ],
"properties": { "properties": {
"entry": { "entry": {
"$ref": "#/definitions/TraceEntry" "$ref": "#/definitions/TraceEntry"
}, },
"pages": {
"type": "array",
"items": {
"$ref": "#/definitions/PageRef"
}
},
"purpose": { "purpose": {
"description": "Optional user-provided goal from `--purpose` (metadata only).",
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"recorded_at": { "recorded_at": {
"description": "RFC 3339 timestamp when recording stopped.",
"type": "string" "type": "string"
}, },
"recorder": {
"$ref": "#/definitions/RecorderInfo"
},
"started_at": { "started_at": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"states": {
"type": "array",
"items": {
"$ref": "#/definitions/TraceState"
}
},
"steps": { "steps": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/Step" "$ref": "#/definitions/Step"
} }
},
"stopped_by": {
"$ref": "#/definitions/StopReason"
},
"version": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
} }
} }
}, },
@@ -452,6 +473,45 @@
"type": "string" "type": "string"
} }
} }
},
"TraceState": {
"description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.",
"type": "object",
"required": [
"id",
"url"
],
"properties": {
"body": {
"description": "Wire-only: full page observation (front matter + VOM body + annotations).",
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"page": {
"description": "Disk-only: filename under the bundle `pages/` directory.",
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"truncated": {
"type": "boolean"
},
"url": {
"type": "string"
}
}
} }
} }
} }
@@ -6,12 +6,26 @@
"session_id" "session_id"
], ],
"properties": { "properties": {
"max_page_tokens": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0.0
},
"purpose": { "purpose": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"redact_values": {
"type": [
"boolean",
"null"
]
},
"session_id": { "session_id": {
"type": "string" "type": "string"
}, },
@@ -11,6 +11,14 @@
} }
}, },
"definitions": { "definitions": {
"FillCommit": {
"type": "string",
"enum": [
"enter",
"suggestion",
"blur"
]
},
"KeyModifier": { "KeyModifier": {
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
"type": "string", "type": "string",
@@ -21,25 +29,32 @@
"shift" "shift"
] ]
}, },
"PageRef": { "NavigationCause": {
"description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "string",
"enum": [
"user_typed",
"link",
"form_submit",
"reload",
"history",
"script",
"browser"
]
},
"RecorderInfo": {
"type": "object", "type": "object",
"required": [ "required": [
"id", "bsk",
"url" "vom"
], ],
"properties": { "properties": {
"id": { "bsk": {
"type": "string" "type": "string"
}, },
"title": { "vom": {
"type": [ "type": "integer",
"string", "format": "uint32",
"null" "minimum": 0.0
]
},
"url": {
"type": "string"
} }
} }
}, },
@@ -68,21 +83,16 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"cause",
"id", "id",
"op", "op",
"page", "result",
"state",
"to" "to"
], ],
"properties": { "properties": {
"effect": { "cause": {
"anyOf": [ "$ref": "#/definitions/NavigationCause"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -95,8 +105,11 @@
"navigate" "navigate"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"to": { "to": {
@@ -110,20 +123,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -135,8 +139,11 @@
"click" "click"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -150,20 +157,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -175,8 +173,11 @@
"hover" "hover"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -188,22 +189,17 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"commit",
"id", "id",
"op", "op",
"page", "result",
"state",
"target", "target",
"value" "value"
], ],
"properties": { "properties": {
"effect": { "commit": {
"anyOf": [ "$ref": "#/definitions/FillCommit"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -216,15 +212,15 @@
"fill" "fill"
] ]
}, },
"page": {
"description": "Reference into `pages[]`.",
"type": "string"
},
"redacted": { "redacted": {
"type": [ "type": "boolean"
"boolean", },
"null" "result": {
] "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}, },
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
@@ -240,21 +236,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"selection", "state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -266,9 +252,8 @@
"select" "select"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
"type": "string"
}, },
"selection": { "selection": {
"type": "array", "type": "array",
@@ -276,6 +261,10 @@
"$ref": "#/definitions/SelectedOption" "$ref": "#/definitions/SelectedOption"
} }
}, },
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
},
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
} }
@@ -288,19 +277,10 @@
"id", "id",
"key", "key",
"op", "op",
"page" "result",
"state"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -324,8 +304,11 @@
"press" "press"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -339,48 +322,74 @@
] ]
} }
} }
},
{
"description": "Fields shared by every step variant (flattened in JSON).",
"type": "object",
"required": [
"id",
"op",
"result",
"state"
],
"properties": {
"id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"op": {
"type": "string",
"enum": [
"scroll"
]
},
"result": {
"$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}
}
} }
] ]
}, },
"StepEffect": { "StepResult": {
"description": "Observed navigation after a step (objective fact only).",
"type": "object", "type": "object",
"required": [ "required": [
"navigated_to" "state"
], ],
"properties": { "properties": {
"navigated_to": { "state": {
"description": "Reference into `pages[]` for the destination page.",
"type": "string" "type": "string"
} }
} }
}, },
"StopReason": {
"type": "string",
"enum": [
"user_finish",
"cli_stop"
]
},
"TargetDescriptor": { "TargetDescriptor": {
"description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", "description": "Stable semantic handle for an interacted element within a page observation.",
"type": "object", "type": "object",
"required": [
"tag"
],
"properties": { "properties": {
"ctx": {
"type": [
"string",
"null"
]
},
"name": { "name": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"name_attr": { "ref": {
"type": [
"string",
"null"
]
},
"nearby_label": {
"type": [
"string",
"null"
]
},
"placeholder": {
"type": [ "type": [
"string", "string",
"null" "null"
@@ -392,8 +401,8 @@
"null" "null"
] ]
}, },
"tag": { "unmatched": {
"type": "string" "type": "boolean"
} }
} }
}, },
@@ -402,42 +411,54 @@
"type": "object", "type": "object",
"required": [ "required": [
"entry", "entry",
"pages",
"recorded_at", "recorded_at",
"steps" "recorder",
"states",
"steps",
"stopped_by",
"version"
], ],
"properties": { "properties": {
"entry": { "entry": {
"$ref": "#/definitions/TraceEntry" "$ref": "#/definitions/TraceEntry"
}, },
"pages": {
"type": "array",
"items": {
"$ref": "#/definitions/PageRef"
}
},
"purpose": { "purpose": {
"description": "Optional user-provided goal from `--purpose` (metadata only).",
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"recorded_at": { "recorded_at": {
"description": "RFC 3339 timestamp when recording stopped.",
"type": "string" "type": "string"
}, },
"recorder": {
"$ref": "#/definitions/RecorderInfo"
},
"started_at": { "started_at": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"states": {
"type": "array",
"items": {
"$ref": "#/definitions/TraceState"
}
},
"steps": { "steps": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/Step" "$ref": "#/definitions/Step"
} }
},
"stopped_by": {
"$ref": "#/definitions/StopReason"
},
"version": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
} }
} }
}, },
@@ -452,6 +473,45 @@
"type": "string" "type": "string"
} }
} }
},
"TraceState": {
"description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.",
"type": "object",
"required": [
"id",
"url"
],
"properties": {
"body": {
"description": "Wire-only: full page observation (front matter + VOM body + annotations).",
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"page": {
"description": "Disk-only: filename under the bundle `pages/` directory.",
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"truncated": {
"type": "boolean"
},
"url": {
"type": "string"
}
}
} }
} }
} }
+191 -131
View File
@@ -5,45 +5,65 @@
"type": "object", "type": "object",
"required": [ "required": [
"entry", "entry",
"pages",
"recorded_at", "recorded_at",
"steps" "recorder",
"states",
"steps",
"stopped_by",
"version"
], ],
"properties": { "properties": {
"entry": { "entry": {
"$ref": "#/definitions/TraceEntry" "$ref": "#/definitions/TraceEntry"
}, },
"pages": {
"type": "array",
"items": {
"$ref": "#/definitions/PageRef"
}
},
"purpose": { "purpose": {
"description": "Optional user-provided goal from `--purpose` (metadata only).",
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"recorded_at": { "recorded_at": {
"description": "RFC 3339 timestamp when recording stopped.",
"type": "string" "type": "string"
}, },
"recorder": {
"$ref": "#/definitions/RecorderInfo"
},
"started_at": { "started_at": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"states": {
"type": "array",
"items": {
"$ref": "#/definitions/TraceState"
}
},
"steps": { "steps": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/Step" "$ref": "#/definitions/Step"
} }
},
"stopped_by": {
"$ref": "#/definitions/StopReason"
},
"version": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
} }
}, },
"definitions": { "definitions": {
"FillCommit": {
"type": "string",
"enum": [
"enter",
"suggestion",
"blur"
]
},
"KeyModifier": { "KeyModifier": {
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
"type": "string", "type": "string",
@@ -54,25 +74,32 @@
"shift" "shift"
] ]
}, },
"PageRef": { "NavigationCause": {
"description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "string",
"enum": [
"user_typed",
"link",
"form_submit",
"reload",
"history",
"script",
"browser"
]
},
"RecorderInfo": {
"type": "object", "type": "object",
"required": [ "required": [
"id", "bsk",
"url" "vom"
], ],
"properties": { "properties": {
"id": { "bsk": {
"type": "string" "type": "string"
}, },
"title": { "vom": {
"type": [ "type": "integer",
"string", "format": "uint32",
"null" "minimum": 0.0
]
},
"url": {
"type": "string"
} }
} }
}, },
@@ -101,21 +128,16 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"cause",
"id", "id",
"op", "op",
"page", "result",
"state",
"to" "to"
], ],
"properties": { "properties": {
"effect": { "cause": {
"anyOf": [ "$ref": "#/definitions/NavigationCause"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -128,8 +150,11 @@
"navigate" "navigate"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"to": { "to": {
@@ -143,20 +168,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -168,8 +184,11 @@
"click" "click"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -183,20 +202,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -208,8 +218,11 @@
"hover" "hover"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -221,22 +234,17 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"commit",
"id", "id",
"op", "op",
"page", "result",
"state",
"target", "target",
"value" "value"
], ],
"properties": { "properties": {
"effect": { "commit": {
"anyOf": [ "$ref": "#/definitions/FillCommit"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -249,15 +257,15 @@
"fill" "fill"
] ]
}, },
"page": {
"description": "Reference into `pages[]`.",
"type": "string"
},
"redacted": { "redacted": {
"type": [ "type": "boolean"
"boolean", },
"null" "result": {
] "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}, },
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
@@ -273,21 +281,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"selection", "state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -299,9 +297,8 @@
"select" "select"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
"type": "string"
}, },
"selection": { "selection": {
"type": "array", "type": "array",
@@ -309,6 +306,10 @@
"$ref": "#/definitions/SelectedOption" "$ref": "#/definitions/SelectedOption"
} }
}, },
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
},
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
} }
@@ -321,19 +322,10 @@
"id", "id",
"key", "key",
"op", "op",
"page" "result",
"state"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -357,8 +349,11 @@
"press" "press"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -372,48 +367,74 @@
] ]
} }
} }
},
{
"description": "Fields shared by every step variant (flattened in JSON).",
"type": "object",
"required": [
"id",
"op",
"result",
"state"
],
"properties": {
"id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"op": {
"type": "string",
"enum": [
"scroll"
]
},
"result": {
"$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}
}
} }
] ]
}, },
"StepEffect": { "StepResult": {
"description": "Observed navigation after a step (objective fact only).",
"type": "object", "type": "object",
"required": [ "required": [
"navigated_to" "state"
], ],
"properties": { "properties": {
"navigated_to": { "state": {
"description": "Reference into `pages[]` for the destination page.",
"type": "string" "type": "string"
} }
} }
}, },
"StopReason": {
"type": "string",
"enum": [
"user_finish",
"cli_stop"
]
},
"TargetDescriptor": { "TargetDescriptor": {
"description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", "description": "Stable semantic handle for an interacted element within a page observation.",
"type": "object", "type": "object",
"required": [
"tag"
],
"properties": { "properties": {
"ctx": {
"type": [
"string",
"null"
]
},
"name": { "name": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"name_attr": { "ref": {
"type": [
"string",
"null"
]
},
"nearby_label": {
"type": [
"string",
"null"
]
},
"placeholder": {
"type": [ "type": [
"string", "string",
"null" "null"
@@ -425,8 +446,8 @@
"null" "null"
] ]
}, },
"tag": { "unmatched": {
"type": "string" "type": "boolean"
} }
} }
}, },
@@ -441,6 +462,45 @@
"type": "string" "type": "string"
} }
} }
},
"TraceState": {
"description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.",
"type": "object",
"required": [
"id",
"url"
],
"properties": {
"body": {
"description": "Wire-only: full page observation (front matter + VOM body + annotations).",
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"page": {
"description": "Disk-only: filename under the bundle `pages/` directory.",
"type": [
"string",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"truncated": {
"type": "boolean"
},
"url": {
"type": "string"
}
}
} }
} }
} }
+115 -108
View File
@@ -7,21 +7,16 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"cause",
"id", "id",
"op", "op",
"page", "result",
"state",
"to" "to"
], ],
"properties": { "properties": {
"effect": { "cause": {
"anyOf": [ "$ref": "#/definitions/NavigationCause"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -34,8 +29,11 @@
"navigate" "navigate"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"to": { "to": {
@@ -49,20 +47,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -74,8 +63,11 @@
"click" "click"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -89,20 +81,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -114,8 +97,11 @@
"hover" "hover"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -127,22 +113,17 @@
"description": "Fields shared by every step variant (flattened in JSON).", "description": "Fields shared by every step variant (flattened in JSON).",
"type": "object", "type": "object",
"required": [ "required": [
"commit",
"id", "id",
"op", "op",
"page", "result",
"state",
"target", "target",
"value" "value"
], ],
"properties": { "properties": {
"effect": { "commit": {
"anyOf": [ "$ref": "#/definitions/FillCommit"
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
}, },
"id": { "id": {
"type": "integer", "type": "integer",
@@ -155,15 +136,15 @@
"fill" "fill"
] ]
}, },
"page": {
"description": "Reference into `pages[]`.",
"type": "string"
},
"redacted": { "redacted": {
"type": [ "type": "boolean"
"boolean", },
"null" "result": {
] "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}, },
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
@@ -179,21 +160,11 @@
"required": [ "required": [
"id", "id",
"op", "op",
"page", "result",
"selection", "state",
"target" "target"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -205,9 +176,8 @@
"select" "select"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
"type": "string"
}, },
"selection": { "selection": {
"type": "array", "type": "array",
@@ -215,6 +185,10 @@
"$ref": "#/definitions/SelectedOption" "$ref": "#/definitions/SelectedOption"
} }
}, },
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
},
"target": { "target": {
"$ref": "#/definitions/TargetDescriptor" "$ref": "#/definitions/TargetDescriptor"
} }
@@ -227,19 +201,10 @@
"id", "id",
"key", "key",
"op", "op",
"page" "result",
"state"
], ],
"properties": { "properties": {
"effect": {
"anyOf": [
{
"$ref": "#/definitions/StepEffect"
},
{
"type": "null"
}
]
},
"id": { "id": {
"type": "integer", "type": "integer",
"format": "uint32", "format": "uint32",
@@ -263,8 +228,11 @@
"press" "press"
] ]
}, },
"page": { "result": {
"description": "Reference into `pages[]`.", "$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string" "type": "string"
}, },
"target": { "target": {
@@ -278,9 +246,47 @@
] ]
} }
} }
},
{
"description": "Fields shared by every step variant (flattened in JSON).",
"type": "object",
"required": [
"id",
"op",
"result",
"state"
],
"properties": {
"id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"op": {
"type": "string",
"enum": [
"scroll"
]
},
"result": {
"$ref": "#/definitions/StepResult"
},
"state": {
"description": "Observation id immediately before this action.",
"type": "string"
}
}
} }
], ],
"definitions": { "definitions": {
"FillCommit": {
"type": "string",
"enum": [
"enter",
"suggestion",
"blur"
]
},
"KeyModifier": { "KeyModifier": {
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
"type": "string", "type": "string",
@@ -291,6 +297,18 @@
"shift" "shift"
] ]
}, },
"NavigationCause": {
"type": "string",
"enum": [
"user_typed",
"link",
"form_submit",
"reload",
"history",
"script",
"browser"
]
},
"SelectedOption": { "SelectedOption": {
"description": "One selected option (`select` op).", "description": "One selected option (`select` op).",
"type": "object", "type": "object",
@@ -309,45 +327,34 @@
} }
} }
}, },
"StepEffect": { "StepResult": {
"description": "Observed navigation after a step (objective fact only).",
"type": "object", "type": "object",
"required": [ "required": [
"navigated_to" "state"
], ],
"properties": { "properties": {
"navigated_to": { "state": {
"description": "Reference into `pages[]` for the destination page.",
"type": "string" "type": "string"
} }
} }
}, },
"TargetDescriptor": { "TargetDescriptor": {
"description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", "description": "Stable semantic handle for an interacted element within a page observation.",
"type": "object", "type": "object",
"required": [
"tag"
],
"properties": { "properties": {
"ctx": {
"type": [
"string",
"null"
]
},
"name": { "name": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
}, },
"name_attr": { "ref": {
"type": [
"string",
"null"
]
},
"nearby_label": {
"type": [
"string",
"null"
]
},
"placeholder": {
"type": [ "type": [
"string", "string",
"null" "null"
@@ -359,8 +366,8 @@
"null" "null"
] ]
}, },
"tag": { "unmatched": {
"type": "string" "type": "boolean"
} }
} }
} }
+213 -194
View File
@@ -1,35 +1,33 @@
//! Semantic user-action recording (`tool.record_start` / `stop` / `await`). //! Semantic user-action recording (`tool.record_start` / `stop` / `await`).
//! //!
//! Trace v2 is a **record-only** log of user actions: what was clicked, filled, //! Trace v3 is a **state-action-state** chain: each step binds to page
//! selected, and where navigation occurred. No LLM runs during recording, so //! observations (VOM) captured before and after the action.
//! variable-vs-constant classification is **not** stored — executing agents
//! infer that at run time from raw values and control names.
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::interaction::KeyModifier; use super::interaction::KeyModifier;
pub const TRACE_VERSION: u32 = 3;
pub const VOM_FORMAT_VERSION: u32 = 1;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Target // Target
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Stable semantic handle for an interacted element. /// Stable semantic handle for an interacted element within a page observation.
///
/// `name` and `nearby_label` are **untrusted page text**.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TargetDescriptor { pub struct TargetDescriptor {
#[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
pub element_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>, pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
pub tag: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub name_attr: Option<String>, pub ctx: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub placeholder: Option<String>, pub unmatched: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nearby_label: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -42,15 +40,54 @@ pub struct TraceEntry {
pub start_url: String, pub start_url: String,
} }
/// Page context dictionary entry — referenced by steps via `page` id.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PageRef { pub struct RecorderInfo {
pub bsk: String,
pub vom: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
UserFinish,
CliStop,
}
/// Page observation dictionary entry — referenced by steps via `state` / `result.state`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TraceState {
pub id: String, pub id: String,
pub url: String, pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>, pub title: Option<String>,
/// Wire-only: full page observation (front matter + VOM body + annotations).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// Disk-only: filename under the bundle `pages/` directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct StepResult {
pub state: String,
}
/// Fields shared by every step variant (flattened in JSON).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct StepCommon {
pub id: u32,
/// Observation id immediately before this action.
pub state: String,
pub result: StepResult,
}
// ---------------------------------------------------------------------------
// Step op-specific payloads
// ---------------------------------------------------------------------------
/// One selected option (`select` op). /// One selected option (`select` op).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SelectedOption { pub struct SelectedOption {
@@ -59,27 +96,26 @@ pub struct SelectedOption {
pub label: Option<String>, pub label: Option<String>,
} }
/// Observed navigation after a step (objective fact only). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")]
pub struct StepEffect { pub enum NavigationCause {
/// Reference into `pages[]` for the destination page. UserTyped,
pub navigated_to: String, Link,
FormSubmit,
Reload,
History,
Script,
Browser,
} }
/// Fields shared by every step variant (flattened in JSON). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")]
pub struct StepCommon { pub enum FillCommit {
pub id: u32, Enter,
/// Reference into `pages[]`. Suggestion,
pub page: String, Blur,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<StepEffect>,
} }
// ---------------------------------------------------------------------------
// Step op-specific payloads
// ---------------------------------------------------------------------------
/// One recorded user action — discriminated union by `op`. /// One recorded user action — discriminated union by `op`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "op", rename_all = "snake_case")] #[serde(tag = "op", rename_all = "snake_case")]
@@ -88,6 +124,7 @@ pub enum Step {
#[serde(flatten)] #[serde(flatten)]
common: StepCommon, common: StepCommon,
to: String, to: String,
cause: NavigationCause,
}, },
Click { Click {
#[serde(flatten)] #[serde(flatten)]
@@ -104,13 +141,15 @@ pub enum Step {
common: StepCommon, common: StepCommon,
target: TargetDescriptor, target: TargetDescriptor,
value: String, value: String,
#[serde(default, skip_serializing_if = "Option::is_none")] commit: FillCommit,
redacted: Option<bool>, #[serde(default, skip_serializing_if = "std::ops::Not::not")]
redacted: bool,
}, },
Select { Select {
#[serde(flatten)] #[serde(flatten)]
common: StepCommon, common: StepCommon,
target: TargetDescriptor, target: TargetDescriptor,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
selection: Vec<SelectedOption>, selection: Vec<SelectedOption>,
}, },
Press { Press {
@@ -122,6 +161,10 @@ pub enum Step {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<TargetDescriptor>, target: Option<TargetDescriptor>,
}, },
Scroll {
#[serde(flatten)]
common: StepCommon,
},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -131,15 +174,16 @@ pub enum Step {
/// Persisted user-action trace exported by `tool.record_stop` / `await`. /// Persisted user-action trace exported by `tool.record_stop` / `await`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Trace { pub struct Trace {
/// RFC 3339 timestamp when recording stopped. pub version: u32,
pub recorded_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
/// Optional user-provided goal from `--purpose` (metadata only).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>, pub purpose: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
pub recorded_at: String,
pub stopped_by: StopReason,
pub entry: TraceEntry, pub entry: TraceEntry,
pub pages: Vec<PageRef>, pub recorder: RecorderInfo,
pub states: Vec<TraceState>,
pub steps: Vec<Step>, pub steps: Vec<Step>,
} }
@@ -156,6 +200,10 @@ pub struct RecordStartParams {
pub url: Option<String>, pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>, pub purpose: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_page_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redact_values: Option<bool>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -195,81 +243,79 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
fn sample_common(id: u32) -> StepCommon { fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommon {
StepCommon { StepCommon {
id, id,
page: "p1".into(), state: state.into(),
effect: None, result: StepResult {
state: result_state.into(),
},
} }
} }
fn sample_target() -> TargetDescriptor { fn sample_target() -> TargetDescriptor {
TargetDescriptor { TargetDescriptor {
element_ref: Some("e21".into()),
role: Some("button".into()), role: Some("button".into()),
name: Some("发布".into()), name: Some("发布".into()),
tag: "button".into(), ctx: Some("金桔柠檬 6 号".into()),
name_attr: None, unmatched: false,
placeholder: None,
nearby_label: None,
} }
} }
fn sample_trace() -> Trace { fn sample_trace() -> Trace {
Trace { Trace {
recorded_at: "2026-07-17T09:01:10Z".into(), version: TRACE_VERSION,
started_at: Some("2026-07-17T09:00:00Z".into()), purpose: Some("把草稿商品发布上架".into()),
purpose: Some("发布一篇文章".into()), started_at: Some("2026-08-10T02:10:41.080Z".into()),
recorded_at: "2026-08-10T02:12:55.360Z".into(),
stopped_by: StopReason::UserFinish,
entry: TraceEntry { entry: TraceEntry {
start_url: "https://x.com/editor".into(), start_url: "https://example.com/".into(),
}, },
pages: vec![ recorder: RecorderInfo {
PageRef { bsk: "0.1.10".into(),
id: "p1".into(), vom: VOM_FORMAT_VERSION,
url: "https://x.com/editor".into(), },
title: Some("写文章".into()), states: vec![
TraceState {
id: "s1".into(),
url: "https://example.com/".into(),
title: Some("Example Domain".into()),
body: None,
page: Some("s1.vom.txt".into()),
truncated: false,
}, },
PageRef { TraceState {
id: "p2".into(), id: "s2".into(),
url: "https://x.com/p/99".into(), url: "https://shop.example.com/products?status=draft".into(),
title: None, title: Some("商品管理".into()),
body: None,
page: Some("s2.vom.txt".into()),
truncated: false,
}, },
], ],
steps: vec![ steps: vec![
Step::Fill { Step::Navigate {
common: sample_common(1), common: sample_common(1, "s1", "s2"),
target: TargetDescriptor { to: "https://shop.example.com/products?status=draft".into(),
role: Some("textbox".into()), cause: NavigationCause::UserTyped,
name: Some("标题".into()),
tag: "input".into(),
name_attr: None,
placeholder: None,
nearby_label: None,
},
value: "我的第一篇文章".into(),
redacted: None,
}, },
Step::Select { Step::Fill {
common: sample_common(3), common: sample_common(2, "s2", "s2"),
target: TargetDescriptor { target: TargetDescriptor {
role: Some("combobox".into()), element_ref: Some("e12".into()),
name: Some("分类".into()), role: Some("textbox".into()),
tag: "select".into(), name: Some("搜索商品".into()),
name_attr: None, ctx: None,
placeholder: None, unmatched: false,
nearby_label: None,
}, },
selection: vec![SelectedOption { value: "金桔柠檬".into(),
value: "tech".into(), commit: FillCommit::Enter,
label: Some("技术分享".into()), redacted: false,
}],
}, },
Step::Click { Step::Click {
common: StepCommon { common: sample_common(3, "s2", "s2"),
effect: Some(StepEffect {
navigated_to: "p2".into(),
}),
..sample_common(4)
},
target: sample_target(), target: sample_target(),
}, },
], ],
@@ -279,35 +325,37 @@ mod tests {
#[test] #[test]
fn step_click_round_trips() { fn step_click_round_trips() {
let step = Step::Click { let step = Step::Click {
common: sample_common(1), common: sample_common(1, "s1", "s2"),
target: sample_target(), target: sample_target(),
}; };
let v = serde_json::to_value(&step).unwrap(); let v = serde_json::to_value(&step).unwrap();
assert_eq!(v.get("op").and_then(|v| v.as_str()), Some("click")); assert_eq!(v.get("op").and_then(|v| v.as_str()), Some("click"));
assert_eq!(v.get("page").and_then(|v| v.as_str()), Some("p1")); assert_eq!(v.get("state").and_then(|v| v.as_str()), Some("s1"));
assert!(v.get("key").is_none()); assert_eq!(v["result"]["state"], "s2");
assert_eq!(v["target"]["ref"], "e21");
let round: Step = serde_json::from_value(v).unwrap(); let round: Step = serde_json::from_value(v).unwrap();
assert_eq!(round, step); assert_eq!(round, step);
} }
#[test] #[test]
fn step_fill_with_raw_value_round_trips() { fn step_fill_with_commit_round_trips() {
let step = Step::Fill { let step = Step::Fill {
common: sample_common(2), common: sample_common(2, "s2", "s3"),
target: TargetDescriptor { target: TargetDescriptor {
element_ref: Some("e12".into()),
role: Some("textbox".into()), role: Some("textbox".into()),
name: Some("服务名称".into()), name: Some("搜索商品".into()),
tag: "input".into(), ctx: None,
name_attr: Some("serviceName".into()), unmatched: false,
placeholder: None,
nearby_label: None,
}, },
value: "my-svc".into(), value: "browser skill".into(),
redacted: None, commit: FillCommit::Enter,
redacted: false,
}; };
let v = serde_json::to_value(&step).unwrap(); let v = serde_json::to_value(&step).unwrap();
assert_eq!(v["op"], "fill"); assert_eq!(v["op"], "fill");
assert_eq!(v["value"], "my-svc"); assert_eq!(v["commit"], "enter");
assert!(v.get("redacted").is_none());
let round: Step = serde_json::from_value(v).unwrap(); let round: Step = serde_json::from_value(v).unwrap();
assert_eq!(round, step); assert_eq!(round, step);
} }
@@ -315,17 +363,17 @@ mod tests {
#[test] #[test]
fn step_fill_password_is_redacted() { fn step_fill_password_is_redacted() {
let step = Step::Fill { let step = Step::Fill {
common: sample_common(1), common: sample_common(1, "s1", "s1"),
target: TargetDescriptor { target: TargetDescriptor {
element_ref: Some("e3".into()),
role: Some("textbox".into()), role: Some("textbox".into()),
name: Some("密码".into()), name: Some("密码".into()),
tag: "input".into(), ctx: None,
name_attr: None, unmatched: false,
placeholder: None,
nearby_label: None,
}, },
value: "***".into(), value: "***".into(),
redacted: Some(true), commit: FillCommit::Blur,
redacted: true,
}; };
let v = serde_json::to_value(&step).unwrap(); let v = serde_json::to_value(&step).unwrap();
assert_eq!(v["value"], "***"); assert_eq!(v["value"], "***");
@@ -333,131 +381,102 @@ mod tests {
} }
#[test] #[test]
fn step_select_uses_object_array() { fn step_navigate_with_cause_round_trips() {
let step = Step::Select {
common: sample_common(3),
target: TargetDescriptor {
role: Some("combobox".into()),
name: Some("分类".into()),
tag: "select".into(),
name_attr: None,
placeholder: None,
nearby_label: None,
},
selection: vec![SelectedOption {
value: "tech".into(),
label: Some("技术分享".into()),
}],
};
let v = serde_json::to_value(&step).unwrap();
assert_eq!(v["selection"][0]["value"], "tech");
assert_eq!(v["selection"][0]["label"], "技术分享");
let round: Step = serde_json::from_value(v).unwrap();
assert_eq!(round, step);
}
#[test]
fn step_navigate_round_trips() {
let step = Step::Navigate { let step = Step::Navigate {
common: sample_common(1), common: sample_common(1, "s1", "s2"),
to: "https://example.com".into(), to: "https://example.com".into(),
cause: NavigationCause::UserTyped,
}; };
let v = serde_json::to_value(&step).unwrap(); let v = serde_json::to_value(&step).unwrap();
assert_eq!(v["op"], "navigate"); assert_eq!(v["op"], "navigate");
assert_eq!(v["to"], "https://example.com"); assert_eq!(v["cause"], "user_typed");
let round: Step = serde_json::from_value(v).unwrap(); let round: Step = serde_json::from_value(v).unwrap();
assert_eq!(round, step); assert_eq!(round, step);
} }
#[test] #[test]
fn step_press_with_modifiers_round_trips() { fn trace_v3_round_trips() {
let step = Step::Press {
common: StepCommon {
effect: Some(StepEffect {
navigated_to: "p2".into(),
}),
..sample_common(2)
},
key: "Enter".into(),
modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]),
target: Some(sample_target()),
};
let v = serde_json::to_value(&step).unwrap();
assert_eq!(v["key"], "Enter");
assert_eq!(v["modifiers"], json!(["ctrl", "shift"]));
assert_eq!(v["effect"]["navigated_to"], "p2");
let round: Step = serde_json::from_value(v).unwrap();
assert_eq!(round, step);
}
#[test]
fn trace_record_only_round_trips() {
let trace = sample_trace(); let trace = sample_trace();
let v = serde_json::to_value(&trace).unwrap(); let v = serde_json::to_value(&trace).unwrap();
assert!(v.get("version").is_none()); assert_eq!(v.get("version").and_then(|v| v.as_u64()), Some(3));
assert_eq!( assert!(v.get("pages").is_none());
v.get("purpose").and_then(|v| v.as_str()), assert_eq!(v["recorder"]["vom"], 1);
Some("发布一篇文章") assert_eq!(v["states"].as_array().unwrap().len(), 2);
);
assert!(v.get("parameters").is_none());
assert!(v.get("site").is_none());
assert!(v.get("goal").is_none());
assert_eq!(
v.get("entry")
.and_then(|e| e.get("start_url"))
.and_then(|u| u.as_str()),
Some("https://x.com/editor")
);
let round: Trace = serde_json::from_value(v).unwrap(); let round: Trace = serde_json::from_value(v).unwrap();
assert_eq!(round, trace); assert_eq!(round, trace);
} }
#[test] #[test]
fn discriminated_union_click_ignores_extra_key_field() { fn default_fields_are_omitted() {
let bad = json!({ let step = Step::Click {
"op": "click", common: sample_common(1, "s1", "s1"),
"id": 1, target: TargetDescriptor {
"page": "p1", element_ref: Some("e1".into()),
"target": { "tag": "button", "name": "OK" }, role: Some("button".into()),
"key": "Enter" name: Some("OK".into()),
}); ctx: None,
let step: Step = serde_json::from_value(bad).unwrap(); unmatched: false,
assert!(matches!(step, Step::Click { .. })); },
};
let v = serde_json::to_value(&step).unwrap(); let v = serde_json::to_value(&step).unwrap();
assert!(v.get("key").is_none()); assert!(v.get("unmatched").is_none());
assert!(v["target"].get("ctx").is_none());
assert!(v["target"].get("unmatched").is_none());
}
#[test]
fn unmatched_target_serializes_flag() {
let step = Step::Click {
common: sample_common(1, "s1", "s2"),
target: TargetDescriptor {
element_ref: None,
role: Some("button".into()),
name: Some("发布".into()),
ctx: None,
unmatched: true,
},
};
let v = serde_json::to_value(&step).unwrap();
assert_eq!(v["target"]["unmatched"], true);
assert!(v["target"].get("ref").is_none());
} }
#[test] #[test]
fn extension_trace_deserializes() { fn extension_trace_deserializes() {
let v = json!({ let v = json!({
"version": 3,
"recorded_at": "2026-07-21T08:00:00Z", "recorded_at": "2026-07-21T08:00:00Z",
"started_at": "2026-07-21T07:59:00Z", "started_at": "2026-07-21T07:59:00Z",
"purpose": "demo", "purpose": "demo",
"stopped_by": "user_finish",
"entry": { "start_url": "https://example.com/editor" }, "entry": { "start_url": "https://example.com/editor" },
"pages": [ "recorder": { "bsk": "0.1.10", "vom": 1 },
{ "id": "p1", "url": "https://example.com/editor" }, "states": [
{ "id": "p2", "url": "https://example.com/p/99" } { "id": "s1", "url": "https://example.com/editor", "page": "s1.vom.txt" },
{ "id": "s2", "url": "https://example.com/p/99", "page": "s2.vom.txt" }
], ],
"steps": [ "steps": [
{ {
"op": "fill", "op": "fill",
"id": 1, "id": 1,
"page": "p1", "state": "s1",
"target": { "tag": "input", "role": "textbox", "name": "标题" }, "result": { "state": "s1" },
"value": "hello" "target": { "ref": "e1", "role": "textbox", "name": "标题" },
"value": "hello",
"commit": "blur"
}, },
{ {
"op": "click", "op": "click",
"id": 2, "id": 2,
"page": "p1", "state": "s1",
"target": { "tag": "button", "role": "button", "name": "发布" }, "result": { "state": "s2" },
"effect": { "navigated_to": "p2" } "target": { "ref": "e2", "role": "button", "name": "发布" }
} }
] ]
}); });
let trace: Trace = serde_json::from_value(v).unwrap(); let trace: Trace = serde_json::from_value(v).unwrap();
assert_eq!(trace.entry.start_url, "https://example.com/editor"); assert_eq!(trace.version, 3);
assert_eq!(trace.pages.len(), 2); assert_eq!(trace.states.len(), 2);
assert_eq!(trace.steps.len(), 2); assert_eq!(trace.steps.len(), 2);
} }
} }