diff --git a/apps/extension/src/transport/__tests__/handshake.test.ts b/apps/extension/src/transport/__tests__/handshake.test.ts index b511453..add3476 100644 --- a/apps/extension/src/transport/__tests__/handshake.test.ts +++ b/apps/extension/src/transport/__tests__/handshake.test.ts @@ -65,6 +65,11 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol } 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 () => { let sentFrame: ProtocolFrame | null = null; const transport = fakeTransport((req) => { @@ -74,9 +79,9 @@ describe("performHandshake", () => { result: { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.0", + protocol_version: "1.1", 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_protocol).toBe("1.0"); + expect(outcome.result.min_compatible_protocol).toBe("1.1"); }); it("accepts legacy daemon reply with only min_compatible_peer", async () => { @@ -129,8 +134,8 @@ describe("performHandshake", () => { const response = { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.0", - min_compatible_protocol: "1.0", + protocol_version: "1.1", + min_compatible_protocol: "1.1", } satisfies HandshakeResult; const transport = fakeTransport((req) => ({ id: (req as { id: string }).id, @@ -145,7 +150,7 @@ describe("performHandshake", () => { }); 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 () => { @@ -178,9 +183,9 @@ describe("performHandshake", () => { result: { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.0", + protocol_version: "1.1", min_compatible_peer: "0.0.0", - min_compatible_protocol: "1.0", + min_compatible_protocol: "1.1", }, }); emit({ @@ -188,14 +193,14 @@ describe("performHandshake", () => { result: { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.0", + protocol_version: "1.1", min_compatible_peer: "0.0.0", - min_compatible_protocol: "1.0", + min_compatible_protocol: "1.1", }, }); await expect(pending).resolves.toMatchObject({ - result: { server: "browser-skill-daemon", protocol_version: "1.0" }, + result: { server: "browser-skill-daemon", protocol_version: "1.1" }, }); }); diff --git a/apps/extension/src/transport/handshake.ts b/apps/extension/src/transport/handshake.ts index 3da56fd..b727f27 100644 --- a/apps/extension/src/transport/handshake.ts +++ b/apps/extension/src/transport/handshake.ts @@ -7,7 +7,7 @@ import type { ResponseFrame, } from "./types"; -export const PROTOCOL_VERSION = "1.0"; +export const PROTOCOL_VERSION = "1.1"; /** * Extension semver, injected at build time from `package.json` via * 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"`). * 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 * daemons. New code sends `"0.0.0"`; compat decisions ignore this. diff --git a/crates/bsk-cli/src/cli/doctor.rs b/crates/bsk-cli/src/cli/doctor.rs index fa33718..2eb8ec8 100644 --- a/crates/bsk-cli/src/cli/doctor.rs +++ b/crates/bsk-cli/src/cli/doctor.rs @@ -480,7 +480,7 @@ mod m2_tests { fn fake_status(browsers: Vec, skew: Vec) -> StatusResult { StatusResult { daemon_version: env!("CARGO_PKG_VERSION").into(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), pid: 1, uptime_secs: 0, ws_port: 0, @@ -545,7 +545,7 @@ mod m2_tests { session_count: 0, connected_at_ms: 1, version_skew: false, - extension_protocol_version: "1.0".into(), + extension_protocol_version: "1.1".into(), }], Vec::new(), ); @@ -566,7 +566,7 @@ mod m2_tests { session_count: 0, connected_at_ms: 1, version_skew: true, - extension_protocol_version: "1.1".into(), + extension_protocol_version: "1.2".into(), }], vec![VersionSkewEntry { instance_id: "alpha".into(), @@ -574,8 +574,8 @@ mod m2_tests { label: "Personal".into(), server_version: env!("CARGO_PKG_VERSION").into(), client_version: "0.0.9".into(), - server_protocol_version: "1.0".into(), - client_protocol_version: "1.1".into(), + server_protocol_version: "1.1".into(), + client_protocol_version: "1.2".into(), }], ); let check = check_browsers_protocol_compatible(Some(&status)); diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index 582cb4c..4bee196 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -27,7 +27,7 @@ use crate::daemon::{ browsers::{BROWSER_LIVENESS_TICK, BROWSER_LIVENESS_TIMEOUT, EXTENSION_CONNECT_WAIT}, info as daemon_info, ipc, lockfile, paths, sessions::{StopSessionError, forget_session, stop_session}, - state::DaemonState, + state::{DaemonState, PROTOCOL_VERSION}, ws, }; @@ -299,7 +299,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> { ws_port, sock_path: sock_path.clone(), daemon_version: env!("CARGO_PKG_VERSION"), - protocol_version: "1.0", + protocol_version: PROTOCOL_VERSION, }; let handler = ipc::full_handler(status, Arc::clone(&state)); diff --git a/crates/bsk-cli/src/daemon/state.rs b/crates/bsk-cli/src/daemon/state.rs index 7ea9cee..e9abced 100644 --- a/crates/bsk-cli/src/daemon/state.rs +++ b/crates/bsk-cli/src/daemon/state.rs @@ -16,9 +16,9 @@ use super::start::DaemonConfig; use super::ws::WsHandle; pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub const PROTOCOL_VERSION: &str = "1.0"; -/// Lowest **protocol** version peers must speak (e.g. `"1.0"`). -pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.0"; +pub const PROTOCOL_VERSION: &str = "1.1"; +/// Lowest **protocol** version peers must speak (e.g. `"1.1"`). +pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.1"; /// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer` /// is emitted for old extensions. New code ignores this on read. pub const LEGACY_MIN_COMPATIBLE_PEER: &str = "0.0.0"; diff --git a/crates/bsk-cli/tests/browser_wait.rs b/crates/bsk-cli/tests/browser_wait.rs index 16513e0..26f7620 100644 --- a/crates/bsk-cli/tests/browser_wait.rs +++ b/crates/bsk-cli/tests/browser_wait.rs @@ -112,14 +112,14 @@ async fn handshake_as_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { diff --git a/crates/bsk-cli/tests/cancel_forwarding.rs b/crates/bsk-cli/tests/cancel_forwarding.rs index e8a0aaf..4399ef4 100644 --- a/crates/bsk-cli/tests/cancel_forwarding.rs +++ b/crates/bsk-cli/tests/cancel_forwarding.rs @@ -75,7 +75,7 @@ async fn handshake_as_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), @@ -83,7 +83,7 @@ async fn handshake_as_ext( }, label: "Test".into(), 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 { id: "hs".into(), diff --git a/crates/bsk-cli/tests/handshake_compat.rs b/crates/bsk-cli/tests/handshake_compat.rs index a5e91b0..934a0eb 100644 --- a/crates/bsk-cli/tests/handshake_compat.rs +++ b/crates/bsk-cli/tests/handshake_compat.rs @@ -108,12 +108,12 @@ async fn send_handshake_with_floors( async fn handshake_ok_when_protocol_matches() { let (handle, _sock) = spawn_daemon().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 { ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(), 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!( result .min_compatible_peer @@ -124,7 +124,7 @@ async fn handshake_ok_when_protocol_matches() { ); assert_eq!( result.min_compatible_protocol.as_deref(), - Some("1.0"), + Some("1.1"), "daemon must advertise protocol floor for new extensions" ); 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 mut ws = open_ws(handle.ws_addr()).await; 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 { ResponseBody::Ok(_) => {} 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 resp = send_handshake_with_floors( &mut ws, - "1.1", + "1.2", env!("CARGO_PKG_VERSION"), Some("0.0.0"), - Some("1.0"), + Some("1.1"), ) .await; match resp.body { @@ -168,6 +168,28 @@ async fn handshake_skew_when_protocol_minor_differs() { 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] async fn handshake_rejected_on_protocol_major_mismatch() { 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 resp = send_handshake_with_floors( &mut ws, - "1.0", + "1.1", env!("CARGO_PKG_VERSION"), Some("0.1.0"), None, @@ -235,7 +257,7 @@ async fn status_surfaces_version_skew_for_skewed_browser() { browser_name: "chrome".into(), browser_version: "131.0".into(), extension_version: "9.9.9".into(), - extension_protocol_version: "1.1".into(), + extension_protocol_version: "1.2".into(), label: "Older".into(), sink: bsk::daemon::browsers::BrowserSink { tx }, pending: Mutex::new(bsk::daemon::browsers::Pending::default()), @@ -263,8 +285,8 @@ async fn status_surfaces_version_skew_for_skewed_browser() { .iter() .find(|s| s.instance_id == "skew-only-test") .expect("status must list our skew client"); - assert_eq!(skew.client_protocol_version, "1.1"); - assert_eq!(skew.server_protocol_version, "1.0"); + assert_eq!(skew.client_protocol_version, "1.2"); + assert_eq!(skew.server_protocol_version, "1.1"); assert_eq!(skew.client_version, "9.9.9"); let entry = status .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 resp = send_handshake_with_floors( &mut ws, - "1.0", + "1.1", env!("CARGO_PKG_VERSION"), Some("0.0.0"), Some("99.0.0"), diff --git a/crates/bsk-cli/tests/per_session_queue.rs b/crates/bsk-cli/tests/per_session_queue.rs index 79b5355..4f66d1e 100644 --- a/crates/bsk-cli/tests/per_session_queue.rs +++ b/crates/bsk-cli/tests/per_session_queue.rs @@ -81,14 +81,14 @@ async fn handshake_as_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { diff --git a/crates/bsk-cli/tests/session_user_interrupt.rs b/crates/bsk-cli/tests/session_user_interrupt.rs index 9c5288c..7529273 100644 --- a/crates/bsk-cli/tests/session_user_interrupt.rs +++ b/crates/bsk-cli/tests/session_user_interrupt.rs @@ -81,7 +81,7 @@ async fn handshake_as_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), @@ -89,7 +89,7 @@ async fn handshake_as_ext( }, label: "Test".into(), 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 { id: "hs".into(), diff --git a/crates/bsk-cli/tests/sessions_ipc.rs b/crates/bsk-cli/tests/sessions_ipc.rs index 69c87c9..aaf895b 100644 --- a/crates/bsk-cli/tests/sessions_ipc.rs +++ b/crates/bsk-cli/tests/sessions_ipc.rs @@ -84,14 +84,14 @@ async fn handshake_as_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { @@ -400,14 +400,14 @@ async fn connect_second_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: instance_id.into(), browser: BrowserPeerInfo { name: "edge".into(), version: "130".into(), }, 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(), }; let hs = RequestFrame { diff --git a/crates/bsk-cli/tests/status_cmd.rs b/crates/bsk-cli/tests/status_cmd.rs index 0e32914..18513e2 100644 --- a/crates/bsk-cli/tests/status_cmd.rs +++ b/crates/bsk-cli/tests/status_cmd.rs @@ -61,7 +61,7 @@ fn bsk_status_json_returns_structured_payload() { assert!(parsed["pid"].as_u64().unwrap() > 0); 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_eq!(parsed["browsers"], serde_json::json!([])); assert_eq!(parsed["sessions"], serde_json::json!([])); diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index d40bb9c..97fa635 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -73,14 +73,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { diff --git a/crates/bsk-cli/tests/tools_m7_ipc.rs b/crates/bsk-cli/tests/tools_m7_ipc.rs index ee74b5e..095584a 100644 --- a/crates/bsk-cli/tests/tools_m7_ipc.rs +++ b/crates/bsk-cli/tests/tools_m7_ipc.rs @@ -76,14 +76,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { diff --git a/crates/bsk-cli/tests/tools_m8_ipc.rs b/crates/bsk-cli/tests/tools_m8_ipc.rs index 14e7c72..4bdce78 100644 --- a/crates/bsk-cli/tests/tools_m8_ipc.rs +++ b/crates/bsk-cli/tests/tools_m8_ipc.rs @@ -75,14 +75,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { diff --git a/crates/bsk-cli/tests/tools_m9_ipc.rs b/crates/bsk-cli/tests/tools_m9_ipc.rs index 2f8b46d..7937c27 100644 --- a/crates/bsk-cli/tests/tools_m9_ipc.rs +++ b/crates/bsk-cli/tests/tools_m9_ipc.rs @@ -85,14 +85,14 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; let req = RequestFrame { diff --git a/crates/bsk-cli/tests/ws_handshake.rs b/crates/bsk-cli/tests/ws_handshake.rs index c6e2da1..300a8f0 100644 --- a/crates/bsk-cli/tests/ws_handshake.rs +++ b/crates/bsk-cli/tests/ws_handshake.rs @@ -53,14 +53,14 @@ pub async fn send_handshake( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.0".into(), + protocol_version: "1.1".into(), instance_id: instance_id.into(), browser: BrowserPeerInfo { name: "chrome".into(), version: "131.0".into(), }, 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(), }; 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 result = send_handshake(&mut ws, TEST_EXT_ID).await; 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 browsers = state.browsers.snapshot(); diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index 348edc8..a5c77a1 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -11,6 +11,14 @@ } }, "definitions": { + "FillCommit": { + "type": "string", + "enum": [ + "enter", + "suggestion", + "blur" + ] + }, "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`).", "type": "string", @@ -21,25 +29,32 @@ "shift" ] }, - "PageRef": { - "description": "Page context dictionary entry — referenced by steps via `page` id.", + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "RecorderInfo": { "type": "object", "required": [ - "id", - "url" + "bsk", + "vom" ], "properties": { - "id": { + "bsk": { "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "url": { - "type": "string" + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } } }, @@ -68,21 +83,16 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", - "page", + "result", + "state", "to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "cause": { + "$ref": "#/definitions/NavigationCause" }, "id": { "type": "integer", @@ -95,8 +105,11 @@ "navigate" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "to": { @@ -110,20 +123,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -135,8 +139,11 @@ "click" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -150,20 +157,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -175,8 +173,11 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -188,22 +189,17 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "commit", "id", "op", - "page", + "result", + "state", "target", "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -216,15 +212,15 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { "$ref": "#/definitions/TargetDescriptor" @@ -240,21 +236,11 @@ "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -266,9 +252,8 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResult" }, "selection": { "type": "array", @@ -276,6 +261,10 @@ "$ref": "#/definitions/SelectedOption" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { "$ref": "#/definitions/TargetDescriptor" } @@ -288,19 +277,10 @@ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -324,8 +304,11 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "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": { - "description": "Observed navigation after a step (objective fact only).", + "StepResult": { "type": "object", "required": [ - "navigated_to" + "state" ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", + "state": { "type": "string" } } }, + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, "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", - "required": [ - "tag" - ], "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, "name": { "type": [ "string", "null" ] }, - "name_attr": { - "type": [ - "string", - "null" - ] - }, - "nearby_label": { - "type": [ - "string", - "null" - ] - }, - "placeholder": { + "ref": { "type": [ "string", "null" @@ -392,8 +401,8 @@ "null" ] }, - "tag": { - "type": "string" + "unmatched": { + "type": "boolean" } } }, @@ -402,42 +411,54 @@ "type": "object", "required": [ "entry", - "pages", "recorded_at", - "steps" + "recorder", + "states", + "steps", + "stopped_by", + "version" ], "properties": { "entry": { "$ref": "#/definitions/TraceEntry" }, - "pages": { - "type": "array", - "items": { - "$ref": "#/definitions/PageRef" - } - }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" ] }, "recorded_at": { - "description": "RFC 3339 timestamp when recording stopped.", "type": "string" }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, "started_at": { "type": [ "string", "null" ] }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceState" + } + }, "steps": { "type": "array", "items": { "$ref": "#/definitions/Step" } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } } }, @@ -452,6 +473,45 @@ "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" + } + } } } } diff --git a/crates/bsk-protocol/schema/tool_record_start_params.json b/crates/bsk-protocol/schema/tool_record_start_params.json index 859ebf8..8f902a9 100644 --- a/crates/bsk-protocol/schema/tool_record_start_params.json +++ b/crates/bsk-protocol/schema/tool_record_start_params.json @@ -6,12 +6,26 @@ "session_id" ], "properties": { + "max_page_tokens": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, "purpose": { "type": [ "string", "null" ] }, + "redact_values": { + "type": [ + "boolean", + "null" + ] + }, "session_id": { "type": "string" }, diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index c46ba69..56d1ba2 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -11,6 +11,14 @@ } }, "definitions": { + "FillCommit": { + "type": "string", + "enum": [ + "enter", + "suggestion", + "blur" + ] + }, "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`).", "type": "string", @@ -21,25 +29,32 @@ "shift" ] }, - "PageRef": { - "description": "Page context dictionary entry — referenced by steps via `page` id.", + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "RecorderInfo": { "type": "object", "required": [ - "id", - "url" + "bsk", + "vom" ], "properties": { - "id": { + "bsk": { "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "url": { - "type": "string" + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } } }, @@ -68,21 +83,16 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", - "page", + "result", + "state", "to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "cause": { + "$ref": "#/definitions/NavigationCause" }, "id": { "type": "integer", @@ -95,8 +105,11 @@ "navigate" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "to": { @@ -110,20 +123,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -135,8 +139,11 @@ "click" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -150,20 +157,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -175,8 +173,11 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -188,22 +189,17 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "commit", "id", "op", - "page", + "result", + "state", "target", "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -216,15 +212,15 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { "$ref": "#/definitions/TargetDescriptor" @@ -240,21 +236,11 @@ "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -266,9 +252,8 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResult" }, "selection": { "type": "array", @@ -276,6 +261,10 @@ "$ref": "#/definitions/SelectedOption" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { "$ref": "#/definitions/TargetDescriptor" } @@ -288,19 +277,10 @@ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -324,8 +304,11 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "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": { - "description": "Observed navigation after a step (objective fact only).", + "StepResult": { "type": "object", "required": [ - "navigated_to" + "state" ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", + "state": { "type": "string" } } }, + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, "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", - "required": [ - "tag" - ], "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, "name": { "type": [ "string", "null" ] }, - "name_attr": { - "type": [ - "string", - "null" - ] - }, - "nearby_label": { - "type": [ - "string", - "null" - ] - }, - "placeholder": { + "ref": { "type": [ "string", "null" @@ -392,8 +401,8 @@ "null" ] }, - "tag": { - "type": "string" + "unmatched": { + "type": "boolean" } } }, @@ -402,42 +411,54 @@ "type": "object", "required": [ "entry", - "pages", "recorded_at", - "steps" + "recorder", + "states", + "steps", + "stopped_by", + "version" ], "properties": { "entry": { "$ref": "#/definitions/TraceEntry" }, - "pages": { - "type": "array", - "items": { - "$ref": "#/definitions/PageRef" - } - }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" ] }, "recorded_at": { - "description": "RFC 3339 timestamp when recording stopped.", "type": "string" }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, "started_at": { "type": [ "string", "null" ] }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceState" + } + }, "steps": { "type": "array", "items": { "$ref": "#/definitions/Step" } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } } }, @@ -452,6 +473,45 @@ "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" + } + } } } } diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 0a31d67..f17a028 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -5,45 +5,65 @@ "type": "object", "required": [ "entry", - "pages", "recorded_at", - "steps" + "recorder", + "states", + "steps", + "stopped_by", + "version" ], "properties": { "entry": { "$ref": "#/definitions/TraceEntry" }, - "pages": { - "type": "array", - "items": { - "$ref": "#/definitions/PageRef" - } - }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" ] }, "recorded_at": { - "description": "RFC 3339 timestamp when recording stopped.", "type": "string" }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, "started_at": { "type": [ "string", "null" ] }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceState" + } + }, "steps": { "type": "array", "items": { "$ref": "#/definitions/Step" } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } }, "definitions": { + "FillCommit": { + "type": "string", + "enum": [ + "enter", + "suggestion", + "blur" + ] + }, "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`).", "type": "string", @@ -54,25 +74,32 @@ "shift" ] }, - "PageRef": { - "description": "Page context dictionary entry — referenced by steps via `page` id.", + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "RecorderInfo": { "type": "object", "required": [ - "id", - "url" + "bsk", + "vom" ], "properties": { - "id": { + "bsk": { "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "url": { - "type": "string" + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } } }, @@ -101,21 +128,16 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", - "page", + "result", + "state", "to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "cause": { + "$ref": "#/definitions/NavigationCause" }, "id": { "type": "integer", @@ -128,8 +150,11 @@ "navigate" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "to": { @@ -143,20 +168,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -168,8 +184,11 @@ "click" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -183,20 +202,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -208,8 +218,11 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -221,22 +234,17 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "commit", "id", "op", - "page", + "result", + "state", "target", "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -249,15 +257,15 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { "$ref": "#/definitions/TargetDescriptor" @@ -273,21 +281,11 @@ "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -299,9 +297,8 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResult" }, "selection": { "type": "array", @@ -309,6 +306,10 @@ "$ref": "#/definitions/SelectedOption" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { "$ref": "#/definitions/TargetDescriptor" } @@ -321,19 +322,10 @@ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -357,8 +349,11 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "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": { - "description": "Observed navigation after a step (objective fact only).", + "StepResult": { "type": "object", "required": [ - "navigated_to" + "state" ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", + "state": { "type": "string" } } }, + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, "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", - "required": [ - "tag" - ], "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, "name": { "type": [ "string", "null" ] }, - "name_attr": { - "type": [ - "string", - "null" - ] - }, - "nearby_label": { - "type": [ - "string", - "null" - ] - }, - "placeholder": { + "ref": { "type": [ "string", "null" @@ -425,8 +446,8 @@ "null" ] }, - "tag": { - "type": "string" + "unmatched": { + "type": "boolean" } } }, @@ -441,6 +462,45 @@ "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" + } + } } } } diff --git a/crates/bsk-protocol/schema/trace_step.json b/crates/bsk-protocol/schema/trace_step.json index 69dbec9..79f5e1e 100644 --- a/crates/bsk-protocol/schema/trace_step.json +++ b/crates/bsk-protocol/schema/trace_step.json @@ -7,21 +7,16 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", - "page", + "result", + "state", "to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "cause": { + "$ref": "#/definitions/NavigationCause" }, "id": { "type": "integer", @@ -34,8 +29,11 @@ "navigate" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "to": { @@ -49,20 +47,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -74,8 +63,11 @@ "click" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -89,20 +81,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -114,8 +97,11 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { @@ -127,22 +113,17 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "commit", "id", "op", - "page", + "result", + "state", "target", "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -155,15 +136,15 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { "$ref": "#/definitions/TargetDescriptor" @@ -179,21 +160,11 @@ "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -205,9 +176,8 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResult" }, "selection": { "type": "array", @@ -215,6 +185,10 @@ "$ref": "#/definitions/SelectedOption" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { "$ref": "#/definitions/TargetDescriptor" } @@ -227,19 +201,10 @@ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -263,8 +228,11 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "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": { + "FillCommit": { + "type": "string", + "enum": [ + "enter", + "suggestion", + "blur" + ] + }, "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`).", "type": "string", @@ -291,6 +297,18 @@ "shift" ] }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, "SelectedOption": { "description": "One selected option (`select` op).", "type": "object", @@ -309,45 +327,34 @@ } } }, - "StepEffect": { - "description": "Observed navigation after a step (objective fact only).", + "StepResult": { "type": "object", "required": [ - "navigated_to" + "state" ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", + "state": { "type": "string" } } }, "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", - "required": [ - "tag" - ], "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, "name": { "type": [ "string", "null" ] }, - "name_attr": { - "type": [ - "string", - "null" - ] - }, - "nearby_label": { - "type": [ - "string", - "null" - ] - }, - "placeholder": { + "ref": { "type": [ "string", "null" @@ -359,8 +366,8 @@ "null" ] }, - "tag": { - "type": "string" + "unmatched": { + "type": "boolean" } } } diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index 6dc2e09..5598a51 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -1,35 +1,33 @@ //! Semantic user-action recording (`tool.record_start` / `stop` / `await`). //! -//! Trace v2 is a **record-only** log of user actions: what was clicked, filled, -//! selected, and where navigation occurred. No LLM runs during recording, so -//! variable-vs-constant classification is **not** stored — executing agents -//! infer that at run time from raw values and control names. +//! Trace v3 is a **state-action-state** chain: each step binds to page +//! observations (VOM) captured before and after the action. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::interaction::KeyModifier; +pub const TRACE_VERSION: u32 = 3; +pub const VOM_FORMAT_VERSION: u32 = 1; + // --------------------------------------------------------------------------- // Target // --------------------------------------------------------------------------- -/// Stable semantic handle for an interacted element. -/// -/// `name` and `nearby_label` are **untrusted page text**. +/// Stable semantic handle for an interacted element within a page observation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct TargetDescriptor { + #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")] + pub element_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub role: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, - pub tag: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub name_attr: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub placeholder: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nearby_label: Option, + pub ctx: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unmatched: bool, } // --------------------------------------------------------------------------- @@ -42,15 +40,54 @@ pub struct TraceEntry { pub start_url: String, } -/// Page context dictionary entry — referenced by steps via `page` id. #[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 url: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, + /// Wire-only: full page observation (front matter + VOM body + annotations). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Disk-only: filename under the bundle `pages/` directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, + #[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). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SelectedOption { @@ -59,27 +96,26 @@ pub struct SelectedOption { pub label: Option, } -/// Observed navigation after a step (objective fact only). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepEffect { - /// Reference into `pages[]` for the destination page. - pub navigated_to: String, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NavigationCause { + UserTyped, + Link, + FormSubmit, + Reload, + History, + Script, + Browser, } -/// Fields shared by every step variant (flattened in JSON). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepCommon { - pub id: u32, - /// Reference into `pages[]`. - pub page: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effect: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FillCommit { + Enter, + Suggestion, + Blur, } -// --------------------------------------------------------------------------- -// Step op-specific payloads -// --------------------------------------------------------------------------- - /// One recorded user action — discriminated union by `op`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "op", rename_all = "snake_case")] @@ -88,6 +124,7 @@ pub enum Step { #[serde(flatten)] common: StepCommon, to: String, + cause: NavigationCause, }, Click { #[serde(flatten)] @@ -104,13 +141,15 @@ pub enum Step { common: StepCommon, target: TargetDescriptor, value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - redacted: Option, + commit: FillCommit, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + redacted: bool, }, Select { #[serde(flatten)] common: StepCommon, target: TargetDescriptor, + #[serde(default, skip_serializing_if = "Vec::is_empty")] selection: Vec, }, Press { @@ -122,6 +161,10 @@ pub enum Step { #[serde(default, skip_serializing_if = "Option::is_none")] target: Option, }, + Scroll { + #[serde(flatten)] + common: StepCommon, + }, } // --------------------------------------------------------------------------- @@ -131,15 +174,16 @@ pub enum Step { /// Persisted user-action trace exported by `tool.record_stop` / `await`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct Trace { - /// RFC 3339 timestamp when recording stopped. - pub recorded_at: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub started_at: Option, - /// Optional user-provided goal from `--purpose` (metadata only). + pub version: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub purpose: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + pub recorded_at: String, + pub stopped_by: StopReason, pub entry: TraceEntry, - pub pages: Vec, + pub recorder: RecorderInfo, + pub states: Vec, pub steps: Vec, } @@ -156,6 +200,10 @@ pub struct RecordStartParams { pub url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub purpose: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_page_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub redact_values: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -195,81 +243,79 @@ mod tests { use super::*; use serde_json::json; - fn sample_common(id: u32) -> StepCommon { + fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommon { StepCommon { id, - page: "p1".into(), - effect: None, + state: state.into(), + result: StepResult { + state: result_state.into(), + }, } } fn sample_target() -> TargetDescriptor { TargetDescriptor { + element_ref: Some("e21".into()), role: Some("button".into()), name: Some("发布".into()), - tag: "button".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + ctx: Some("金桔柠檬 6 号".into()), + unmatched: false, } } fn sample_trace() -> Trace { Trace { - recorded_at: "2026-07-17T09:01:10Z".into(), - started_at: Some("2026-07-17T09:00:00Z".into()), - purpose: Some("发布一篇文章".into()), + version: TRACE_VERSION, + 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 { - start_url: "https://x.com/editor".into(), + start_url: "https://example.com/".into(), }, - pages: vec![ - PageRef { - id: "p1".into(), - url: "https://x.com/editor".into(), - title: Some("写文章".into()), + recorder: RecorderInfo { + bsk: "0.1.10".into(), + vom: VOM_FORMAT_VERSION, + }, + 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 { - id: "p2".into(), - url: "https://x.com/p/99".into(), - title: None, + TraceState { + id: "s2".into(), + url: "https://shop.example.com/products?status=draft".into(), + title: Some("商品管理".into()), + body: None, + page: Some("s2.vom.txt".into()), + truncated: false, }, ], steps: vec![ - Step::Fill { - common: sample_common(1), - target: TargetDescriptor { - role: Some("textbox".into()), - name: Some("标题".into()), - tag: "input".into(), - name_attr: None, - placeholder: None, - nearby_label: None, - }, - value: "我的第一篇文章".into(), - redacted: None, + Step::Navigate { + common: sample_common(1, "s1", "s2"), + to: "https://shop.example.com/products?status=draft".into(), + cause: NavigationCause::UserTyped, }, - Step::Select { - common: sample_common(3), + Step::Fill { + common: sample_common(2, "s2", "s2"), target: TargetDescriptor { - role: Some("combobox".into()), - name: Some("分类".into()), - tag: "select".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + element_ref: Some("e12".into()), + role: Some("textbox".into()), + name: Some("搜索商品".into()), + ctx: None, + unmatched: false, }, - selection: vec![SelectedOption { - value: "tech".into(), - label: Some("技术分享".into()), - }], + value: "金桔柠檬".into(), + commit: FillCommit::Enter, + redacted: false, }, Step::Click { - common: StepCommon { - effect: Some(StepEffect { - navigated_to: "p2".into(), - }), - ..sample_common(4) - }, + common: sample_common(3, "s2", "s2"), target: sample_target(), }, ], @@ -279,35 +325,37 @@ mod tests { #[test] fn step_click_round_trips() { let step = Step::Click { - common: sample_common(1), + common: sample_common(1, "s1", "s2"), target: sample_target(), }; 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("page").and_then(|v| v.as_str()), Some("p1")); - assert!(v.get("key").is_none()); + assert_eq!(v.get("state").and_then(|v| v.as_str()), Some("s1")); + assert_eq!(v["result"]["state"], "s2"); + assert_eq!(v["target"]["ref"], "e21"); let round: Step = serde_json::from_value(v).unwrap(); assert_eq!(round, step); } #[test] - fn step_fill_with_raw_value_round_trips() { + fn step_fill_with_commit_round_trips() { let step = Step::Fill { - common: sample_common(2), + common: sample_common(2, "s2", "s3"), target: TargetDescriptor { + element_ref: Some("e12".into()), role: Some("textbox".into()), - name: Some("服务名称".into()), - tag: "input".into(), - name_attr: Some("serviceName".into()), - placeholder: None, - nearby_label: None, + name: Some("搜索商品".into()), + ctx: None, + unmatched: false, }, - value: "my-svc".into(), - redacted: None, + value: "browser skill".into(), + commit: FillCommit::Enter, + redacted: false, }; let v = serde_json::to_value(&step).unwrap(); 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(); assert_eq!(round, step); } @@ -315,17 +363,17 @@ mod tests { #[test] fn step_fill_password_is_redacted() { let step = Step::Fill { - common: sample_common(1), + common: sample_common(1, "s1", "s1"), target: TargetDescriptor { + element_ref: Some("e3".into()), role: Some("textbox".into()), name: Some("密码".into()), - tag: "input".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + ctx: None, + unmatched: false, }, value: "***".into(), - redacted: Some(true), + commit: FillCommit::Blur, + redacted: true, }; let v = serde_json::to_value(&step).unwrap(); assert_eq!(v["value"], "***"); @@ -333,131 +381,102 @@ mod tests { } #[test] - fn step_select_uses_object_array() { - 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() { + fn step_navigate_with_cause_round_trips() { let step = Step::Navigate { - common: sample_common(1), + common: sample_common(1, "s1", "s2"), to: "https://example.com".into(), + cause: NavigationCause::UserTyped, }; let v = serde_json::to_value(&step).unwrap(); 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(); assert_eq!(round, step); } #[test] - fn step_press_with_modifiers_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() { + fn trace_v3_round_trips() { let trace = sample_trace(); let v = serde_json::to_value(&trace).unwrap(); - assert!(v.get("version").is_none()); - assert_eq!( - v.get("purpose").and_then(|v| v.as_str()), - Some("发布一篇文章") - ); - 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") - ); + assert_eq!(v.get("version").and_then(|v| v.as_u64()), Some(3)); + assert!(v.get("pages").is_none()); + assert_eq!(v["recorder"]["vom"], 1); + assert_eq!(v["states"].as_array().unwrap().len(), 2); let round: Trace = serde_json::from_value(v).unwrap(); assert_eq!(round, trace); } #[test] - fn discriminated_union_click_ignores_extra_key_field() { - let bad = json!({ - "op": "click", - "id": 1, - "page": "p1", - "target": { "tag": "button", "name": "OK" }, - "key": "Enter" - }); - let step: Step = serde_json::from_value(bad).unwrap(); - assert!(matches!(step, Step::Click { .. })); + fn default_fields_are_omitted() { + let step = Step::Click { + common: sample_common(1, "s1", "s1"), + target: TargetDescriptor { + element_ref: Some("e1".into()), + role: Some("button".into()), + name: Some("OK".into()), + ctx: None, + unmatched: false, + }, + }; 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] fn extension_trace_deserializes() { let v = json!({ + "version": 3, "recorded_at": "2026-07-21T08:00:00Z", "started_at": "2026-07-21T07:59:00Z", "purpose": "demo", + "stopped_by": "user_finish", "entry": { "start_url": "https://example.com/editor" }, - "pages": [ - { "id": "p1", "url": "https://example.com/editor" }, - { "id": "p2", "url": "https://example.com/p/99" } + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [ + { "id": "s1", "url": "https://example.com/editor", "page": "s1.vom.txt" }, + { "id": "s2", "url": "https://example.com/p/99", "page": "s2.vom.txt" } ], "steps": [ { "op": "fill", "id": 1, - "page": "p1", - "target": { "tag": "input", "role": "textbox", "name": "标题" }, - "value": "hello" + "state": "s1", + "result": { "state": "s1" }, + "target": { "ref": "e1", "role": "textbox", "name": "标题" }, + "value": "hello", + "commit": "blur" }, { "op": "click", "id": 2, - "page": "p1", - "target": { "tag": "button", "role": "button", "name": "发布" }, - "effect": { "navigated_to": "p2" } + "state": "s1", + "result": { "state": "s2" }, + "target": { "ref": "e2", "role": "button", "name": "发布" } } ] }); let trace: Trace = serde_json::from_value(v).unwrap(); - assert_eq!(trace.entry.start_url, "https://example.com/editor"); - assert_eq!(trace.pages.len(), 2); + assert_eq!(trace.version, 3); + assert_eq!(trace.states.len(), 2); assert_eq!(trace.steps.len(), 2); } }