From f98198949a8a79661312b81431b31ba9c21e2939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Mon, 17 Aug 2026 19:52:05 +0800 Subject: [PATCH 1/7] feat(protocol): negotiate Trace v3 with v2 compatibility Define Trace v3 wire types and generated schemas while keeping protocol 1.1 peers compatible with 1.0. Default record start still produces Trace v2, and only an explicit trace_version=3 request enables the new shape. Refs #90 Co-authored-by: Cursor --- .../__tests__/connection-controller.test.ts | 22 +- .../src/lib/__tests__/trace-reducer.test.ts | 8 +- apps/extension/src/lib/trace-reducer.ts | 8 +- .../src/transport/__tests__/handshake.test.ts | 23 +- apps/extension/src/transport/handshake.ts | 2 +- apps/extension/src/transport/types.ts | 104 +++- crates/bsk-cli/src/cli/doctor.rs | 10 +- crates/bsk-cli/src/cli/record.rs | 15 +- crates/bsk-cli/src/daemon/start.rs | 4 +- crates/bsk-cli/src/daemon/state.rs | 2 +- crates/bsk-cli/tests/browser_wait.rs | 4 +- crates/bsk-cli/tests/cancel_forwarding.rs | 4 +- crates/bsk-cli/tests/handshake_compat.rs | 45 +- crates/bsk-cli/tests/per_session_queue.rs | 4 +- .../bsk-cli/tests/session_user_interrupt.rs | 4 +- crates/bsk-cli/tests/sessions_ipc.rs | 8 +- crates/bsk-cli/tests/status_cmd.rs | 2 +- crates/bsk-cli/tests/tools_ipc.rs | 4 +- crates/bsk-cli/tests/tools_m7_ipc.rs | 4 +- crates/bsk-cli/tests/tools_m8_ipc.rs | 4 +- crates/bsk-cli/tests/tools_m9_ipc.rs | 4 +- crates/bsk-cli/tests/ws_handshake.rs | 6 +- .../schema/tool_record_await_result.json | 551 ++++++++++++++++-- .../schema/tool_record_start_params.json | 23 + .../schema/tool_record_stop_result.json | 551 ++++++++++++++++-- crates/bsk-protocol/schema/trace.json | 314 ++++++---- crates/bsk-protocol/schema/trace_step.json | 215 +++---- crates/bsk-protocol/schema/trace_v2.json | 405 +++++++++++++ crates/bsk-protocol/src/bin/dump-schema.rs | 1 + crates/bsk-protocol/src/tools/mod.rs | 1 + crates/bsk-protocol/src/tools/record.rs | 537 +++++++++++------ crates/bsk-protocol/src/tools/record_v2.rs | 215 +++++++ 32 files changed, 2503 insertions(+), 601 deletions(-) create mode 100644 crates/bsk-protocol/schema/trace_v2.json create mode 100644 crates/bsk-protocol/src/tools/record_v2.rs diff --git a/apps/extension/src/lib/__tests__/connection-controller.test.ts b/apps/extension/src/lib/__tests__/connection-controller.test.ts index 6d5b578b..53d34af5 100644 --- a/apps/extension/src/lib/__tests__/connection-controller.test.ts +++ b/apps/extension/src/lib/__tests__/connection-controller.test.ts @@ -27,19 +27,19 @@ function handshake( describe("computeConnectedState (protocol-based compat)", () => { it("returns connected when protocol strings match", () => { - expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({ + expect(computeConnectedState(handshake("1.1", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({ kind: "connected", }); }); it("returns version_skew when daemon protocol minor is newer", () => { - expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({ + expect(computeConnectedState(handshake("1.2", "1.0"))).toEqual({ kind: "version_skew", }); }); it("returns version_skew when daemon protocol string differs but floor is satisfied", () => { - expect(computeConnectedState(handshake("1", "1.0"))).toEqual({ + expect(computeConnectedState(handshake("1.1.0", "1.0"))).toEqual({ kind: "version_skew", }); }); @@ -53,7 +53,7 @@ describe("computeConnectedState (protocol-based compat)", () => { }); it("rejects when extension is below daemon min_compatible_protocol", () => { - const result = computeConnectedState(handshake("1.0", "1.5")); + const result = computeConnectedState(handshake("1.1", "1.5")); expect(result.kind).toBe("rejected"); if (result.kind === "rejected") { expect(result.reason).toContain("min_compatible_protocol"); @@ -65,7 +65,7 @@ describe("computeConnectedState (protocol-based compat)", () => { const result = computeConnectedState({ server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.0", + protocol_version: "1.1", min_compatible_peer: "0.1.0", }); expect(result).toEqual({ kind: "connected" }); @@ -79,8 +79,14 @@ describe("computeConnectedState (protocol-based compat)", () => { } }); + it("returns version_skew when daemon protocol is 1.0 and floor is satisfied", () => { + expect(computeConnectedState(handshake("1.0", "1.0"))).toEqual({ + kind: "version_skew", + }); + }); + it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => { - const result = computeConnectedState(handshake("1.0", "not-a-protocol")); + const result = computeConnectedState(handshake("1.1", "not-a-protocol")); expect(result.kind).toBe("rejected"); if (result.kind === "rejected") { expect(result.reason).toContain("daemon min_compatible_protocol"); @@ -242,11 +248,11 @@ describe("ConnectionController connectionEnabled", () => { const second = transport.send.mock.calls[1]?.[0] as { id: string }; expect(second.id).not.toBe(first.id); - transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") }); + transport.emitMessage({ id: first.id, result: handshake("1.1", "1.0") }); await Promise.resolve(); expect(controller.snapshot().state).not.toBe("connected"); - transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") }); + transport.emitMessage({ id: second.id, result: handshake("1.1", "1.0") }); await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected")); }); }); diff --git a/apps/extension/src/lib/__tests__/trace-reducer.test.ts b/apps/extension/src/lib/__tests__/trace-reducer.test.ts index f5c6db51..b1153be9 100644 --- a/apps/extension/src/lib/__tests__/trace-reducer.test.ts +++ b/apps/extension/src/lib/__tests__/trace-reducer.test.ts @@ -151,7 +151,7 @@ describe("reduceTraceSteps", () => { }); }); - it("keeps hover steps before menu clicks", () => { + it("drops hover steps unsupported by historical v2 clients", () => { const { steps } = reduceTraceSteps( [ { @@ -167,11 +167,11 @@ describe("reduceTraceSteps", () => { ], "https://example.com/app", ); - expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps.map((s) => s.op)).toEqual(["click"]); expect(steps[0]).toMatchObject({ - op: "hover", + op: "click", page: "p1", - target: { name: "Account" }, + target: { name: "Profile" }, }); }); diff --git a/apps/extension/src/lib/trace-reducer.ts b/apps/extension/src/lib/trace-reducer.ts index 8f83fff3..2d5b0df2 100644 --- a/apps/extension/src/lib/trace-reducer.ts +++ b/apps/extension/src/lib/trace-reducer.ts @@ -18,6 +18,7 @@ export function shouldRecordPress( } function shouldIncludeDraft(step: DraftTraceStep): boolean { + if (step.op === "hover") return false; if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false; return true; } @@ -139,12 +140,7 @@ function toV2Step( effectForNavigation(step.navigated_to, urlToId), ); case "hover": - return { - op: "hover", - id, - page, - target: step.target, - }; + return null; case "fill": return { op: "fill", diff --git a/apps/extension/src/transport/__tests__/handshake.test.ts b/apps/extension/src/transport/__tests__/handshake.test.ts index b511453c..6196f344 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 protocol compatibility boundary", () => { + expect(PROTOCOL_VERSION).toBe("1.1"); + expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.0"); + }); + it("sends system.handshake with identity and both compat fields", async () => { let sentFrame: ProtocolFrame | null = null; const transport = fakeTransport((req) => { @@ -74,7 +79,7 @@ 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", }, @@ -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 3da56fda..a869ca52 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`). diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index d53825a1..e855f67a 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -657,9 +657,62 @@ export interface EmulateResult { } // -------------------------------------------------------------------------- -// Semantic record payloads — mirror bsk-protocol record.rs (Trace v2) +// Semantic record payloads — mirror bsk-protocol record.rs // -------------------------------------------------------------------------- +export const TRACE_VERSION = 3; +export const TRACE_VERSION_V2 = 2; +export const DEFAULT_TRACE_VERSION = 2; +export const VOM_FORMAT_VERSION = 1; + +export interface TargetDescriptorV3 { + ref?: string; + role?: string; + name?: string; + ctx?: string; + unmatched?: boolean; +} + +export interface RecorderInfo { + bsk: string; + vom: number; +} + +export type StopReason = "user_finish" | "cli_stop"; + +export interface TraceState { + id: string; + url: string; + title?: string; + /** Wire-only: full page observation text. */ + body?: string; + /** Disk-only: filename under the bundle `pages/` directory. */ + page?: string; + truncated?: boolean; +} + +export interface StepResult { + state: string; +} + +export interface StepCommonV3 { + id: number; + state: string; + result: StepResult; +} + +export type NavigationCause = + | "user_typed" + | "link" + | "form_submit" + | "reload" + | "history" + | "script" + | "browser"; + +export type FillCommit = "enter" | "suggestion" | "blur"; + +/** Legacy v2 target shape retained for existing record producers. */ export interface TargetDescriptor { role?: string; name?: string; @@ -740,7 +793,6 @@ export type DraftTraceStep = export type Step = | ({ op: "navigate" } & StepCommon & { to: string }) | ({ op: "click" } & StepCommon & { target: TargetDescriptor }) - | ({ op: "hover" } & StepCommon & { target: TargetDescriptor }) | ({ op: "fill" } & StepCommon & { target: TargetDescriptor; value: string; @@ -756,6 +808,9 @@ export type Step = target?: TargetDescriptor; }); +export type TargetDescriptorV2 = TargetDescriptor; +export type StepV2 = Step; + export interface Trace { recorded_at: string; started_at?: string; @@ -765,11 +820,52 @@ export interface Trace { steps: Step[]; } +export type TraceV2 = Trace; + +export type StepV3 = + | ({ op: "navigate" } & StepCommonV3 & { to: string; cause: NavigationCause }) + | ({ op: "click" } & StepCommonV3 & { target: TargetDescriptorV3 }) + | ({ op: "hover" } & StepCommonV3 & { target: TargetDescriptorV3 }) + | ({ op: "fill" } & StepCommonV3 & { + target: TargetDescriptorV3; + value: string; + commit: FillCommit; + redacted?: boolean; + }) + | ({ op: "select" } & StepCommonV3 & { + target: TargetDescriptorV3; + selection: SelectedOption[]; + }) + | ({ op: "press" } & StepCommonV3 & { + key: string; + modifiers?: KeyModifier[]; + target?: TargetDescriptorV3; + }) + | ({ op: "scroll" } & StepCommonV3); + +export interface TraceV3 { + version: number; + recorded_at: string; + started_at?: string; + purpose?: string; + stopped_by: StopReason; + entry: TraceEntry; + recorder: RecorderInfo; + states: TraceState[]; + steps: StepV3[]; +} + +export type RecordedTrace = TraceV2 | TraceV3; + export interface RecordStartParams { session_id: string; tab_id?: number; url?: string; purpose?: string; + max_page_tokens?: number; + redact_values?: boolean; + /** Omitted means v2; `3` requests a state-linked v3 trace. */ + trace_version?: number; } export interface RecordStartResult { @@ -782,7 +878,7 @@ export interface RecordStopParams { } export interface RecordStopResult { - trace: Trace; + trace: RecordedTrace; } export interface RecordAwaitParams { @@ -791,5 +887,5 @@ export interface RecordAwaitParams { } export interface RecordAwaitResult { - trace: Trace; + trace: RecordedTrace; } diff --git a/crates/bsk-cli/src/cli/doctor.rs b/crates/bsk-cli/src/cli/doctor.rs index 3d0c57a9..2a839012 100644 --- a/crates/bsk-cli/src/cli/doctor.rs +++ b/crates/bsk-cli/src/cli/doctor.rs @@ -486,7 +486,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, @@ -571,7 +571,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(), ); @@ -592,7 +592,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(), @@ -600,8 +600,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/cli/record.rs b/crates/bsk-cli/src/cli/record.rs index ec72d9a2..7cee47f9 100644 --- a/crates/bsk-cli/src/cli/record.rs +++ b/crates/bsk-cli/src/cli/record.rs @@ -8,7 +8,7 @@ use anyhow::Context; use bsk_protocol::Method; use bsk_protocol::tools::{ RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams, - RecordStopResult, Trace, + RecordStopResult, RecordedTrace, }; use clap::{Args, Subcommand}; @@ -98,6 +98,9 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> tab_id: args.tab_id, url: args.url, purpose: args.purpose.clone(), + max_page_tokens: None, + redact_values: None, + trace_version: None, }; let start_result = business_rpc::call::( info.sock_path.clone(), @@ -190,7 +193,7 @@ fn record_await_ipc_timeout(timeout_ms: u32) -> Duration { .unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15)) } -fn write_trace_file(output: &PathBuf, trace: &Trace) -> Result<(), CliError> { +fn write_trace_file(output: &PathBuf, trace: &RecordedTrace) -> Result<(), CliError> { let json = serde_json::to_string_pretty(trace) .context("serialize trace JSON") .map_err(CliError::Local)?; @@ -207,7 +210,7 @@ fn write_trace_file(output: &PathBuf, trace: &Trace) -> Result<(), CliError> { Ok(()) } -fn render_finish(trace: &Trace, output: &PathBuf, format: Format) -> Result<(), CliError> { +fn render_finish(trace: &RecordedTrace, output: &PathBuf, format: Format) -> Result<(), CliError> { match format { Format::Json => { println!( @@ -221,7 +224,11 @@ fn render_finish(trace: &Trace, output: &PathBuf, format: Format) -> Result<(), ); } Format::Human => { - println!("saved {} steps to {}", trace.steps.len(), output.display()); + let step_count = match trace { + RecordedTrace::V2(trace) => trace.steps.len(), + RecordedTrace::V3(trace) => trace.steps.len(), + }; + println!("saved {step_count} steps to {}", output.display()); } } Ok(()) diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index fdd46a69..241eb855 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -26,7 +26,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, }; @@ -297,7 +297,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 a1b5d117..ad3b48bd 100644 --- a/crates/bsk-cli/src/daemon/state.rs +++ b/crates/bsk-cli/src/daemon/state.rs @@ -16,7 +16,7 @@ use super::start::DaemonConfig; use super::ws::WsHandle; 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"`). pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.0"; /// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer` diff --git a/crates/bsk-cli/tests/browser_wait.rs b/crates/bsk-cli/tests/browser_wait.rs index 16513e0f..26f7620f 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 c48113ce..1abd8f9b 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 a5e91b01..7c8fd507 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 @@ -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,31 @@ async fn handshake_skew_when_protocol_minor_differs() { handle.shutdown().await; } +#[tokio::test] +async fn handshake_skew_when_protocol_1_0_peer() { + 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::Ok(_) => {} + other => panic!("protocol 1.0 peer should connect with skew, got {other:?}"), + } + let state = handle.state(); + let client = state + .browsers + .get(&bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into())) + .expect("browser registered"); + assert!(client.version_skew); + handle.shutdown().await; +} + #[tokio::test] async fn handshake_rejected_on_protocol_major_mismatch() { let (handle, _sock) = spawn_daemon().await; @@ -210,7 +235,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 +260,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 +288,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 +306,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 70df3a7d..c1baa74c 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 9c5288cc..7529273a 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 e6d77a9d..9411b00c 100644 --- a/crates/bsk-cli/tests/sessions_ipc.rs +++ b/crates/bsk-cli/tests/sessions_ipc.rs @@ -86,14 +86,14 @@ async fn handshake_as_ext(ws: &mut TestWs) -> 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 { @@ -758,14 +758,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 0e329146..18513e2e 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 d40bb9ca..97fa635a 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 ee74b5e5..095584ab 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 14e7c72a..4bdce78e 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 2f8b46d9..7937c27d 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 c6e2da14..300a8f0e 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 348edc8d..622e2704 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -7,10 +7,18 @@ ], "properties": { "trace": { - "$ref": "#/definitions/Trace" + "$ref": "#/definitions/RecordedTrace" } }, "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,6 +29,18 @@ "shift" ] }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, "PageRef": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", @@ -43,6 +63,33 @@ } } }, + "RecordedTrace": { + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" + }, + { + "$ref": "#/definitions/Trace" + } + ] + }, + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, "SelectedOption": { "description": "One selected option (`select` op).", "type": "object", @@ -61,6 +108,24 @@ } } }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "Step": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ @@ -68,22 +133,256 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", - "page", + "result", + "state", "to" ], "properties": { - "effect": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOption" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/TargetDescriptor" }, { "type": "null" } ] - }, + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state" + ], + "properties": { "id": { "type": "integer", "format": "uint32", @@ -92,32 +391,61 @@ "op": { "type": "string", "enum": [ - "navigate" + "scroll" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResult" }, - "to": { + "state": { + "description": "Observation id immediately before this action.", "type": "string" } } - }, + } + ] + }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "StepResult": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", "page", - "target" + "to" ], "properties": { "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -132,20 +460,20 @@ "op": { "type": "string", "enum": [ - "click" + "navigate" ] }, "page": { "description": "Reference into `pages[]`.", "type": "string" }, - "target": { - "$ref": "#/definitions/TargetDescriptor" + "to": { + "type": "string" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -157,7 +485,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -172,7 +500,7 @@ "op": { "type": "string", "enum": [ - "hover" + "click" ] }, "page": { @@ -180,12 +508,12 @@ "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -198,7 +526,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -227,7 +555,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -235,7 +563,7 @@ } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -248,7 +576,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -273,16 +601,16 @@ "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV2" } }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -294,7 +622,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -331,7 +659,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -342,21 +670,48 @@ } ] }, - "StepEffect": { - "description": "Observed navigation after a step (objective fact only).", + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, + "TargetDescriptor": { + "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", - "required": [ - "navigated_to" - ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", - "type": "string" + "ctx": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "unmatched": { + "type": "boolean" } } }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", "required": [ "tag" @@ -402,42 +757,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 +819,88 @@ "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" + } + } + }, + "TraceV2": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRef" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + } } } } diff --git a/crates/bsk-protocol/schema/tool_record_start_params.json b/crates/bsk-protocol/schema/tool_record_start_params.json index 859ebf82..e81c2618 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" }, @@ -22,6 +36,15 @@ ], "format": "int64" }, + "trace_version": { + "description": "Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, "url": { "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 c46ba691..49d84c02 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -7,10 +7,18 @@ ], "properties": { "trace": { - "$ref": "#/definitions/Trace" + "$ref": "#/definitions/RecordedTrace" } }, "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,6 +29,18 @@ "shift" ] }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, "PageRef": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", @@ -43,6 +63,33 @@ } } }, + "RecordedTrace": { + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" + }, + { + "$ref": "#/definitions/Trace" + } + ] + }, + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, "SelectedOption": { "description": "One selected option (`select` op).", "type": "object", @@ -61,6 +108,24 @@ } } }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "Step": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ @@ -68,22 +133,256 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", - "page", + "result", + "state", "to" ], "properties": { - "effect": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOption" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptor" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResult" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/TargetDescriptor" }, { "type": "null" } ] - }, + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state" + ], + "properties": { "id": { "type": "integer", "format": "uint32", @@ -92,32 +391,61 @@ "op": { "type": "string", "enum": [ - "navigate" + "scroll" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResult" }, - "to": { + "state": { + "description": "Observation id immediately before this action.", "type": "string" } } - }, + } + ] + }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "StepResult": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", "page", - "target" + "to" ], "properties": { "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -132,20 +460,20 @@ "op": { "type": "string", "enum": [ - "click" + "navigate" ] }, "page": { "description": "Reference into `pages[]`.", "type": "string" }, - "target": { - "$ref": "#/definitions/TargetDescriptor" + "to": { + "type": "string" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -157,7 +485,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -172,7 +500,7 @@ "op": { "type": "string", "enum": [ - "hover" + "click" ] }, "page": { @@ -180,12 +508,12 @@ "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -198,7 +526,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -227,7 +555,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -235,7 +563,7 @@ } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -248,7 +576,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -273,16 +601,16 @@ "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV2" } }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", @@ -294,7 +622,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -331,7 +659,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -342,21 +670,48 @@ } ] }, - "StepEffect": { - "description": "Observed navigation after a step (objective fact only).", + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, + "TargetDescriptor": { + "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", - "required": [ - "navigated_to" - ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", - "type": "string" + "ctx": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "unmatched": { + "type": "boolean" } } }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", "required": [ "tag" @@ -402,42 +757,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 +819,88 @@ "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" + } + } + }, + "TraceV2": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRef" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + } } } } diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 0a31d67f..f17a0280 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": { - "name": { - "type": [ - "string", - "null" - ] - }, - "name_attr": { + "ctx": { "type": [ "string", "null" ] }, - "nearby_label": { + "name": { "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 69dbec90..79f5e1e7 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": { - "name": { - "type": [ - "string", - "null" - ] - }, - "name_attr": { + "ctx": { "type": [ "string", "null" ] }, - "nearby_label": { + "name": { "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/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json new file mode 100644 index 00000000..22b0b328 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -0,0 +1,405 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRef" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + }, + "definitions": { + "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", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "PageRef": { + "description": "Page context dictionary entry — referenced by steps via `page` id.", + "type": "object", + "required": [ + "id", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + } + }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + }, + "TraceEntry": { + "description": "Recording entry point — first URL the flow starts from.", + "type": "object", + "required": [ + "start_url" + ], + "properties": { + "start_url": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index de9b7f25..2071a057 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -113,6 +113,7 @@ fn main() { dump!(RequestHelpParams, "tool_request_help_params"); dump!(RequestHelpResult, "tool_request_help_result"); + dump!(TraceV2, "trace_v2"); dump!(Trace, "trace"); dump!(Step, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index a9c04dbb..67e8f53f 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -9,6 +9,7 @@ pub mod navigation; pub mod network; pub mod observation; pub mod record; +pub mod record_v2; pub mod script; pub mod session; pub mod tabs; diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index 6dc2e097..74cd6343 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -1,35 +1,39 @@ //! 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 use crate::record_v2::{ + PageRef, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, +}; + +pub const TRACE_VERSION: u32 = 3; +pub const TRACE_VERSION_V2: u32 = 2; +pub const DEFAULT_TRACE_VERSION: u32 = 2; +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,44 +46,82 @@ 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, -} - -/// One selected option (`select` op). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct SelectedOption { - pub value: String, + /// Wire-only: full page observation (front matter + VOM body + annotations). #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, + 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, } -/// 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, +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, - /// Reference into `pages[]`. - pub page: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effect: Option, + /// 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 { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NavigationCause { + UserTyped, + Link, + FormSubmit, + Reload, + History, + Script, + Browser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FillCommit { + Enter, + Suggestion, + Blur, +} + /// One recorded user action — discriminated union by `op`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "op", rename_all = "snake_case")] @@ -88,6 +130,7 @@ pub enum Step { #[serde(flatten)] common: StepCommon, to: String, + cause: NavigationCause, }, Click { #[serde(flatten)] @@ -104,13 +147,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 +167,10 @@ pub enum Step { #[serde(default, skip_serializing_if = "Option::is_none")] target: Option, }, + Scroll { + #[serde(flatten)] + common: StepCommon, + }, } // --------------------------------------------------------------------------- @@ -131,15 +180,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 +206,13 @@ 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, + /// Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_version: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -169,9 +226,100 @@ pub struct RecordStopParams { pub session_id: String, } +/// Wire trace payload — v2 (legacy `pages[]`) or v3 (`version: 3`, `states[]`). +#[derive(Debug, Clone, PartialEq)] +pub enum RecordedTrace { + V2(TraceV2), + V3(Trace), +} + +impl RecordedTrace { + pub fn classify_value(v: &serde_json::Value) -> Result { + if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) { + if ver == u64::from(TRACE_VERSION) { + return serde_json::from_value(v.clone()) + .map(RecordedTrace::V3) + .map_err(|e| e.to_string()); + } + return Err(format!("unsupported trace version {ver}")); + } + if v.get("pages").is_some() && v.get("states").is_none() { + return serde_json::from_value(v.clone()) + .map(RecordedTrace::V2) + .map_err(|e| e.to_string()); + } + if v.get("states").is_some() { + return serde_json::from_value(v.clone()) + .map(RecordedTrace::V3) + .map_err(|e| e.to_string()); + } + Err("ambiguous or unparseable trace".into()) + } + + pub fn is_v3(&self) -> bool { + matches!(self, RecordedTrace::V3(_)) + } + + pub fn as_v3(&self) -> Option<&Trace> { + match self { + RecordedTrace::V3(t) => Some(t), + RecordedTrace::V2(_) => None, + } + } + + pub fn as_v2(&self) -> Option<&TraceV2> { + match self { + RecordedTrace::V2(t) => Some(t), + RecordedTrace::V3(_) => None, + } + } +} + +impl Serialize for RecordedTrace { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + RecordedTrace::V2(t) => t.serialize(serializer), + RecordedTrace::V3(t) => t.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RecordedTrace { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + Self::classify_value(&value).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for RecordedTrace { + fn schema_name() -> String { + "RecordedTrace".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + one_of: Some(vec![ + generator.subschema_for::(), + generator.subschema_for::(), + ]), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordStopResult { - pub trace: Trace, + pub trace: RecordedTrace, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -183,7 +331,7 @@ pub struct RecordAwaitParams { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordAwaitResult { - pub trace: Trace, + pub trace: RecordedTrace, } // --------------------------------------------------------------------------- @@ -195,81 +343,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(), + }, + recorder: RecorderInfo { + bsk: "0.1.10".into(), + vom: VOM_FORMAT_VERSION, }, - pages: vec![ - PageRef { - id: "p1".into(), - 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 { - 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::Navigate { + common: sample_common(1, "s1", "s2"), + to: "https://shop.example.com/products?status=draft".into(), + cause: NavigationCause::UserTyped, + }, Step::Fill { - common: sample_common(1), + common: sample_common(2, "s2", "s2"), target: TargetDescriptor { + element_ref: Some("e12".into()), role: Some("textbox".into()), - name: Some("标题".into()), - tag: "input".into(), - name_attr: None, - placeholder: None, - nearby_label: None, - }, - value: "我的第一篇文章".into(), - redacted: None, - }, - 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, + 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 +425,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 +463,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 +481,140 @@ 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()), - }], + fn step_navigate_with_cause_round_trips() { + let step = Step::Navigate { + 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["selection"][0]["value"], "tech"); - assert_eq!(v["selection"][0]["label"], "技术分享"); + assert_eq!(v["op"], "navigate"); + assert_eq!(v["cause"], "user_typed"); let round: Step = serde_json::from_value(v).unwrap(); assert_eq!(round, step); } #[test] - fn step_navigate_round_trips() { - let step = Step::Navigate { - common: sample_common(1), - to: "https://example.com".into(), + fn trace_v3_round_trips() { + let trace = sample_trace(); + let v = serde_json::to_value(&trace).unwrap(); + 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 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_eq!(v["op"], "navigate"); - assert_eq!(v["to"], "https://example.com"); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + assert!(v.get("unmatched").is_none()); + assert!(v["target"].get("ctx").is_none()); + assert!(v["target"].get("unmatched").is_none()); } #[test] - fn step_press_with_modifiers_round_trips() { - let step = Step::Press { - common: StepCommon { - effect: Some(StepEffect { - navigated_to: "p2".into(), - }), - ..sample_common(2) + 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, }, - 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); + assert_eq!(v["target"]["unmatched"], true); + assert!(v["target"].get("ref").is_none()); } #[test] - fn trace_record_only_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") - ); - let round: Trace = serde_json::from_value(v).unwrap(); - assert_eq!(round, trace); + fn recorded_trace_classifies_v2_and_v3() { + let v2 = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [] + }); + match RecordedTrace::classify_value(&v2).unwrap() { + RecordedTrace::V2(_) => {} + other => panic!("expected v2, got {other:?}"), + } + + let v3 = json!({ + "version": 3, + "recorded_at": "2026-07-21T08:00:00Z", + "stopped_by": "user_finish", + "entry": { "start_url": "https://example.com/" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [], + "steps": [] + }); + match RecordedTrace::classify_value(&v3).unwrap() { + RecordedTrace::V3(t) => assert_eq!(t.version, 3), + other => panic!("expected v3, got {other:?}"), + } } #[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 { .. })); - let v = serde_json::to_value(&step).unwrap(); - assert!(v.get("key").is_none()); + fn recorded_trace_schema_includes_v2_and_v3() { + let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + let variants = schema["definitions"]["RecordedTrace"]["oneOf"] + .as_array() + .expect("RecordedTrace schema should use oneOf"); + + assert_eq!(variants.len(), 2); } #[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); } } diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs new file mode 100644 index 00000000..77b3a37d --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -0,0 +1,215 @@ +//! Trace v2 — record-only user-action log with `pages[]` and step `page` refs. +//! +//! Legacy wire format: no top-level `version` field. New peers omit +//! `trace_version` on `record_start` to request this shape. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::interaction::KeyModifier; +use super::record::TraceEntry; + +/// Stable semantic handle for an interacted element (v2). +/// +/// `name` and `nearby_label` are **untrusted page text**. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetDescriptorV2 { + #[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, +} + +/// Page context dictionary entry — referenced by steps via `page` id. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PageRef { + pub id: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// One selected option (`select` op). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SelectedOptionV2 { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +/// Observed navigation after a step (objective fact only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepEffectV2 { + /// Reference into `pages[]` for the destination page. + pub navigated_to: String, +} + +/// Fields shared by every v2 step variant (flattened in JSON). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepCommonV2 { + pub id: u32, + /// Reference into `pages[]`. + pub page: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effect: Option, +} + +/// One recorded user action — discriminated union by `op` (v2). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum StepV2 { + Navigate { + #[serde(flatten)] + common: StepCommonV2, + to: String, + }, + Click { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + }, + Fill { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + redacted: Option, + }, + Select { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + selection: Vec, + }, + Press { + #[serde(flatten)] + common: StepCommonV2, + key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + modifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, +} + +/// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TraceV2 { + /// RFC 3339 timestamp when recording stopped. + pub recorded_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, + pub entry: TraceEntry, + pub pages: Vec, + pub steps: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_common(id: u32) -> StepCommonV2 { + StepCommonV2 { + id, + page: "p1".into(), + effect: None, + } + } + + fn sample_target() -> TargetDescriptorV2 { + TargetDescriptorV2 { + role: Some("button".into()), + name: Some("发布".into()), + tag: "button".into(), + name_attr: None, + placeholder: None, + nearby_label: None, + } + } + + #[test] + fn trace_v2_has_no_version_field() { + let trace = TraceV2 { + recorded_at: "2026-07-17T09:01:10Z".into(), + started_at: None, + purpose: None, + entry: TraceEntry { + start_url: "https://example.com/".into(), + }, + pages: vec![PageRef { + id: "p1".into(), + url: "https://example.com/".into(), + title: None, + }], + steps: vec![StepV2::Click { + common: sample_common(1), + target: sample_target(), + }], + }; + let v = serde_json::to_value(&trace).unwrap(); + assert!(v.get("version").is_none()); + assert!(v.get("pages").is_some()); + assert!(v.get("states").is_none()); + } + + #[test] + fn extension_v2_trace_deserializes() { + let v = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "started_at": "2026-07-21T07:59:00Z", + "purpose": "demo", + "entry": { "start_url": "https://example.com/editor" }, + "pages": [ + { "id": "p1", "url": "https://example.com/editor" }, + { "id": "p2", "url": "https://example.com/p/99" } + ], + "steps": [ + { + "op": "fill", + "id": 1, + "page": "p1", + "target": { "tag": "input", "role": "textbox", "name": "标题" }, + "value": "hello" + }, + { + "op": "click", + "id": 2, + "page": "p1", + "target": { "tag": "button", "role": "button", "name": "发布" }, + "effect": { "navigated_to": "p2" } + } + ] + }); + let trace: TraceV2 = serde_json::from_value(v).unwrap(); + assert_eq!(trace.pages.len(), 2); + assert_eq!(trace.steps.len(), 2); + } + + #[test] + fn historical_v2_rejects_hover_steps() { + let value = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [{ + "op": "hover", + "id": 1, + "page": "p1", + "target": { "tag": "button", "name": "结束" } + }] + }); + + assert!(serde_json::from_value::(value).is_err()); + } +} From 524516cb56e738e3ce50ec19f8e91f2b7952b928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Mon, 17 Aug 2026 20:26:42 +0800 Subject: [PATCH 2/7] fix(protocol): align Trace v2/v3 classification and hover steps Require Trace v3 version const 3, reject mixed pages/states payloads, and align TypeScript selection optionality with the generated schema. Trace v2 on main already records hover steps, so the frozen v2 schema must accept them instead of dropping them during reduction. Co-authored-by: Cursor --- .../src/lib/__tests__/trace-reducer.test.ts | 8 +- apps/extension/src/lib/trace-reducer.ts | 8 +- apps/extension/src/transport/types.ts | 5 +- .../schema/tool_record_await_result.json | 49 ++++++++++- .../schema/tool_record_stop_result.json | 49 ++++++++++- crates/bsk-protocol/schema/trace.json | 4 +- crates/bsk-protocol/schema/trace_v2.json | 41 +++++++++ crates/bsk-protocol/src/tools/record.rs | 84 ++++++++++++++++--- crates/bsk-protocol/src/tools/record_v2.rs | 17 +++- 9 files changed, 234 insertions(+), 31 deletions(-) diff --git a/apps/extension/src/lib/__tests__/trace-reducer.test.ts b/apps/extension/src/lib/__tests__/trace-reducer.test.ts index b1153be9..f5c6db51 100644 --- a/apps/extension/src/lib/__tests__/trace-reducer.test.ts +++ b/apps/extension/src/lib/__tests__/trace-reducer.test.ts @@ -151,7 +151,7 @@ describe("reduceTraceSteps", () => { }); }); - it("drops hover steps unsupported by historical v2 clients", () => { + it("keeps hover steps before menu clicks", () => { const { steps } = reduceTraceSteps( [ { @@ -167,11 +167,11 @@ describe("reduceTraceSteps", () => { ], "https://example.com/app", ); - expect(steps.map((s) => s.op)).toEqual(["click"]); + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); expect(steps[0]).toMatchObject({ - op: "click", + op: "hover", page: "p1", - target: { name: "Profile" }, + target: { name: "Account" }, }); }); diff --git a/apps/extension/src/lib/trace-reducer.ts b/apps/extension/src/lib/trace-reducer.ts index 2d5b0df2..8f83fff3 100644 --- a/apps/extension/src/lib/trace-reducer.ts +++ b/apps/extension/src/lib/trace-reducer.ts @@ -18,7 +18,6 @@ export function shouldRecordPress( } function shouldIncludeDraft(step: DraftTraceStep): boolean { - if (step.op === "hover") return false; if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false; return true; } @@ -140,7 +139,12 @@ function toV2Step( effectForNavigation(step.navigated_to, urlToId), ); case "hover": - return null; + return { + op: "hover", + id, + page, + target: step.target, + }; case "fill": return { op: "fill", diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index e855f67a..7d876677 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -793,6 +793,7 @@ export type DraftTraceStep = export type Step = | ({ op: "navigate" } & StepCommon & { to: string }) | ({ op: "click" } & StepCommon & { target: TargetDescriptor }) + | ({ op: "hover" } & StepCommon & { target: TargetDescriptor }) | ({ op: "fill" } & StepCommon & { target: TargetDescriptor; value: string; @@ -834,7 +835,7 @@ export type StepV3 = }) | ({ op: "select" } & StepCommonV3 & { target: TargetDescriptorV3; - selection: SelectedOption[]; + selection?: SelectedOption[]; }) | ({ op: "press" } & StepCommonV3 & { key: string; @@ -844,7 +845,7 @@ export type StepV3 = | ({ op: "scroll" } & StepCommonV3); export interface TraceV3 { - version: number; + version: 3; recorded_at: string; started_at?: string; purpose?: string; diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index 622e2704..a16fdf89 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -512,6 +512,46 @@ } } }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, { "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", @@ -803,10 +843,10 @@ }, "version": { "type": "integer", - "format": "uint32", - "minimum": 0.0 + "const": 3 } - } + }, + "additionalProperties": false }, "TraceEntry": { "description": "Recording entry point — first URL the flow starts from.", @@ -900,7 +940,8 @@ "$ref": "#/definitions/StepV2" } } - } + }, + "additionalProperties": false } } } diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index 49d84c02..6fe6d48e 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -512,6 +512,46 @@ } } }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, { "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", @@ -803,10 +843,10 @@ }, "version": { "type": "integer", - "format": "uint32", - "minimum": 0.0 + "const": 3 } - } + }, + "additionalProperties": false }, "TraceEntry": { "description": "Recording entry point — first URL the flow starts from.", @@ -900,7 +940,8 @@ "$ref": "#/definitions/StepV2" } } - } + }, + "additionalProperties": false } } } diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index f17a0280..2abfd555 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -51,10 +51,10 @@ }, "version": { "type": "integer", - "format": "uint32", - "minimum": 0.0 + "const": 3 } }, + "additionalProperties": false, "definitions": { "FillCommit": { "type": "string", diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json index 22b0b328..c301ee9f 100644 --- a/crates/bsk-protocol/schema/trace_v2.json +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -42,6 +42,7 @@ } } }, + "additionalProperties": false, "definitions": { "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`).", @@ -189,6 +190,46 @@ } } }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, { "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index 74cd6343..8111965b 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -4,7 +4,7 @@ //! observations (VOM) captured before and after the action. use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use super::interaction::KeyModifier; @@ -17,6 +17,28 @@ pub const TRACE_VERSION_V2: u32 = 2; pub const DEFAULT_TRACE_VERSION: u32 = 2; pub const VOM_FORMAT_VERSION: u32 = 1; +fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let version = u32::deserialize(deserializer)?; + if version != TRACE_VERSION { + return Err(serde::de::Error::custom(format!( + "unsupported trace version {version} (expected {TRACE_VERSION})" + ))); + } + Ok(version) +} + +fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + const_value: Some(serde_json::json!(TRACE_VERSION)), + ..Default::default() + } + .into() +} + // --------------------------------------------------------------------------- // Target // --------------------------------------------------------------------------- @@ -179,7 +201,10 @@ pub enum Step { /// Persisted user-action trace exported by `tool.record_stop` / `await`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Trace { + #[serde(deserialize_with = "deserialize_trace_v3_version")] + #[schemars(schema_with = "trace_v3_version_schema")] pub version: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub purpose: Option, @@ -236,21 +261,22 @@ pub enum RecordedTrace { impl RecordedTrace { pub fn classify_value(v: &serde_json::Value) -> Result { if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) { - if ver == u64::from(TRACE_VERSION) { - return serde_json::from_value(v.clone()) - .map(RecordedTrace::V3) - .map_err(|e| e.to_string()); + if ver != u64::from(TRACE_VERSION) { + return Err(format!("unsupported trace version {ver}")); + } + if v.get("pages").is_some() { + return Err("trace v3 must not include legacy pages[]".into()); } - return Err(format!("unsupported trace version {ver}")); - } - if v.get("pages").is_some() && v.get("states").is_none() { return serde_json::from_value(v.clone()) - .map(RecordedTrace::V2) + .map(RecordedTrace::V3) .map_err(|e| e.to_string()); } if v.get("states").is_some() { + return Err("trace v2 must not include states[]; set version: 3 for Trace v3".into()); + } + if v.get("pages").is_some() { return serde_json::from_value(v.clone()) - .map(RecordedTrace::V3) + .map(RecordedTrace::V2) .map_err(|e| e.to_string()); } Err("ambiguous or unparseable trace".into()) @@ -569,6 +595,42 @@ mod tests { } } + #[test] + fn recorded_trace_rejects_mixed_and_unsupported_versions() { + let v2_with_states = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "states": [], + "steps": [] + }); + assert!(RecordedTrace::classify_value(&v2_with_states).is_err()); + + let v3_with_pages = json!({ + "version": 3, + "recorded_at": "2026-07-21T08:00:00Z", + "stopped_by": "user_finish", + "entry": { "start_url": "https://example.com/" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "states": [], + "steps": [] + }); + assert!(RecordedTrace::classify_value(&v3_with_pages).is_err()); + + let unsupported = json!({ + "version": 2, + "recorded_at": "2026-07-21T08:00:00Z", + "stopped_by": "user_finish", + "entry": { "start_url": "https://example.com/" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [], + "steps": [] + }); + assert!(RecordedTrace::classify_value(&unsupported).is_err()); + assert!(serde_json::from_value::(unsupported).is_err()); + } + #[test] fn recorded_trace_schema_includes_v2_and_v3() { let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); @@ -577,6 +639,8 @@ mod tests { .expect("RecordedTrace schema should use oneOf"); assert_eq!(variants.len(), 2); + let trace_schema = schema["definitions"]["Trace"].clone(); + assert_eq!(trace_schema["properties"]["version"]["const"], 3); } #[test] diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs index 77b3a37d..27f7f3f0 100644 --- a/crates/bsk-protocol/src/tools/record_v2.rs +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -75,6 +75,11 @@ pub enum StepV2 { common: StepCommonV2, target: TargetDescriptorV2, }, + Hover { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + }, Fill { #[serde(flatten)] common: StepCommonV2, @@ -102,6 +107,7 @@ pub enum StepV2 { /// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TraceV2 { /// RFC 3339 timestamp when recording stopped. pub recorded_at: String, @@ -197,7 +203,7 @@ mod tests { } #[test] - fn historical_v2_rejects_hover_steps() { + fn v2_hover_steps_round_trip() { let value = json!({ "recorded_at": "2026-07-21T08:00:00Z", "entry": { "start_url": "https://example.com/" }, @@ -206,10 +212,15 @@ mod tests { "op": "hover", "id": 1, "page": "p1", - "target": { "tag": "button", "name": "结束" } + "target": { "tag": "span", "role": "button", "name": "Account" } }] }); - assert!(serde_json::from_value::(value).is_err()); + let trace: TraceV2 = serde_json::from_value(value).unwrap(); + assert!(matches!(trace.steps.as_slice(), [StepV2::Hover { .. }])); + assert_eq!( + serde_json::to_value(&trace).unwrap()["steps"][0]["op"], + "hover" + ); } } From 3d159c02541da62de054136bb723ef07b045efef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Tue, 18 Aug 2026 15:28:08 +0800 Subject: [PATCH 3/7] test(protocol): align handshake fixtures with 1.0 floor and clarify v2/v3 naming --- .../extension/src/lib/__tests__/connection-controller.test.ts | 2 +- apps/extension/src/transport/types.ts | 4 +++- crates/bsk-cli/tests/browser_wait.rs | 2 +- crates/bsk-cli/tests/cancel_forwarding.rs | 2 +- crates/bsk-cli/tests/per_session_queue.rs | 2 +- crates/bsk-cli/tests/session_user_interrupt.rs | 2 +- crates/bsk-cli/tests/sessions_ipc.rs | 4 ++-- crates/bsk-cli/tests/tools_ipc.rs | 2 +- crates/bsk-cli/tests/tools_m7_ipc.rs | 2 +- crates/bsk-cli/tests/tools_m8_ipc.rs | 2 +- crates/bsk-cli/tests/tools_m9_ipc.rs | 2 +- crates/bsk-cli/tests/ws_handshake.rs | 2 +- 12 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/extension/src/lib/__tests__/connection-controller.test.ts b/apps/extension/src/lib/__tests__/connection-controller.test.ts index 53d34af5..44945235 100644 --- a/apps/extension/src/lib/__tests__/connection-controller.test.ts +++ b/apps/extension/src/lib/__tests__/connection-controller.test.ts @@ -26,7 +26,7 @@ function handshake( } describe("computeConnectedState (protocol-based compat)", () => { - it("returns connected when protocol strings match", () => { + it("returns connected when daemon protocol equals extension protocol", () => { expect(computeConnectedState(handshake("1.1", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({ kind: "connected", }); diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 7d876677..e430a8e9 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -657,7 +657,9 @@ export interface EmulateResult { } // -------------------------------------------------------------------------- -// Semantic record payloads — mirror bsk-protocol record.rs +// Semantic record payloads — legacy Trace v2 shapes (`Trace`/`Step`) mirror +// bsk-protocol record_v2.rs; Trace v3 shapes (`TraceV3`/`StepV3`) mirror +// record.rs (note: in Rust, `Trace` is the v3 shape). // -------------------------------------------------------------------------- export const TRACE_VERSION = 3; diff --git a/crates/bsk-cli/tests/browser_wait.rs b/crates/bsk-cli/tests/browser_wait.rs index 26f7620f..b2a9a140 100644 --- a/crates/bsk-cli/tests/browser_wait.rs +++ b/crates/bsk-cli/tests/browser_wait.rs @@ -119,7 +119,7 @@ async fn handshake_as_ext( version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".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 1abd8f9b..01894c82 100644 --- a/crates/bsk-cli/tests/cancel_forwarding.rs +++ b/crates/bsk-cli/tests/cancel_forwarding.rs @@ -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.1".into()), + min_compatible_protocol: Some("1.0".into()), }; let req = RequestFrame { id: "hs".into(), diff --git a/crates/bsk-cli/tests/per_session_queue.rs b/crates/bsk-cli/tests/per_session_queue.rs index c1baa74c..8f0bdf6e 100644 --- a/crates/bsk-cli/tests/per_session_queue.rs +++ b/crates/bsk-cli/tests/per_session_queue.rs @@ -88,7 +88,7 @@ async fn handshake_as_ext( version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".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 7529273a..4ae1ce46 100644 --- a/crates/bsk-cli/tests/session_user_interrupt.rs +++ b/crates/bsk-cli/tests/session_user_interrupt.rs @@ -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.1".into()), + min_compatible_protocol: Some("1.0".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 9411b00c..4d55b068 100644 --- a/crates/bsk-cli/tests/sessions_ipc.rs +++ b/crates/bsk-cli/tests/sessions_ipc.rs @@ -93,7 +93,7 @@ async fn handshake_as_ext(ws: &mut TestWs) -> HandshakeResult { version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".into()), label: "Test".into(), }; let req = RequestFrame { @@ -765,7 +765,7 @@ async fn connect_second_ext( version: "130".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".into()), label: label.into(), }; let hs = RequestFrame { diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index 97fa635a..b64ff9bd 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -80,7 +80,7 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".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 095584ab..4635828e 100644 --- a/crates/bsk-cli/tests/tools_m7_ipc.rs +++ b/crates/bsk-cli/tests/tools_m7_ipc.rs @@ -83,7 +83,7 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".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 4bdce78e..777d2128 100644 --- a/crates/bsk-cli/tests/tools_m8_ipc.rs +++ b/crates/bsk-cli/tests/tools_m8_ipc.rs @@ -82,7 +82,7 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".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 7937c27d..f5015cac 100644 --- a/crates/bsk-cli/tests/tools_m9_ipc.rs +++ b/crates/bsk-cli/tests/tools_m9_ipc.rs @@ -92,7 +92,7 @@ async fn do_handshake(ws: &mut Ws) -> HandshakeResult { version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".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 300a8f0e..be413d27 100644 --- a/crates/bsk-cli/tests/ws_handshake.rs +++ b/crates/bsk-cli/tests/ws_handshake.rs @@ -60,7 +60,7 @@ pub async fn send_handshake( version: "131.0".into(), }, min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()), - min_compatible_protocol: Some("1.1".into()), + min_compatible_protocol: Some("1.0".into()), label: "Test Chrome".into(), }; let req = RequestFrame { From b750eed5d4ef419db792d803b09caeb7ab75be9d Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Wed, 19 Aug 2026 11:55:15 +0800 Subject: [PATCH 4/7] fix(protocol): integrate with vom --- .../__tests__/connection-controller.test.ts | 24 +- apps/extension/src/lib/trace-reducer.ts | 20 +- apps/extension/src/tools/record.ts | 18 +- .../src/transport/__tests__/handshake.test.ts | 23 +- apps/extension/src/transport/handshake.ts | 2 +- apps/extension/src/transport/types.ts | 85 ++- crates/bsk-cli/src/cli/doctor.rs | 10 +- crates/bsk-cli/src/cli/record.rs | 3 - crates/bsk-cli/src/daemon/start.rs | 4 +- crates/bsk-cli/src/daemon/state.rs | 2 +- crates/bsk-cli/tests/browser_wait.rs | 2 +- crates/bsk-cli/tests/cancel_forwarding.rs | 2 +- crates/bsk-cli/tests/handshake_compat.rs | 45 +- crates/bsk-cli/tests/per_session_queue.rs | 2 +- .../bsk-cli/tests/session_user_interrupt.rs | 2 +- crates/bsk-cli/tests/sessions_ipc.rs | 4 +- crates/bsk-cli/tests/status_cmd.rs | 2 +- crates/bsk-cli/tests/tools_ipc.rs | 2 +- crates/bsk-cli/tests/tools_m7_ipc.rs | 2 +- crates/bsk-cli/tests/tools_m8_ipc.rs | 2 +- crates/bsk-cli/tests/tools_m9_ipc.rs | 2 +- crates/bsk-cli/tests/ws_handshake.rs | 4 +- .../schema/tool_record_await_result.json | 626 +++++++++--------- .../schema/tool_record_start_params.json | 23 - .../schema/tool_record_stop_result.json | 626 +++++++++--------- crates/bsk-protocol/schema/trace.json | 80 +-- crates/bsk-protocol/schema/trace_step.json | 34 +- crates/bsk-protocol/schema/trace_v2.json | 5 +- crates/bsk-protocol/src/bin/dump-schema.rs | 4 +- crates/bsk-protocol/src/tools/mod.rs | 3 +- crates/bsk-protocol/src/tools/record.rs | 184 +++-- .../bsk-protocol/src/tools/record_common.rs | 7 + crates/bsk-protocol/src/tools/record_v2.rs | 11 +- 33 files changed, 867 insertions(+), 998 deletions(-) create mode 100644 crates/bsk-protocol/src/tools/record_common.rs diff --git a/apps/extension/src/lib/__tests__/connection-controller.test.ts b/apps/extension/src/lib/__tests__/connection-controller.test.ts index 44945235..6d5b578b 100644 --- a/apps/extension/src/lib/__tests__/connection-controller.test.ts +++ b/apps/extension/src/lib/__tests__/connection-controller.test.ts @@ -26,20 +26,20 @@ function handshake( } describe("computeConnectedState (protocol-based compat)", () => { - it("returns connected when daemon protocol equals extension protocol", () => { - expect(computeConnectedState(handshake("1.1", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({ + it("returns connected when protocol strings match", () => { + expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({ kind: "connected", }); }); it("returns version_skew when daemon protocol minor is newer", () => { - expect(computeConnectedState(handshake("1.2", "1.0"))).toEqual({ + expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({ kind: "version_skew", }); }); it("returns version_skew when daemon protocol string differs but floor is satisfied", () => { - expect(computeConnectedState(handshake("1.1.0", "1.0"))).toEqual({ + expect(computeConnectedState(handshake("1", "1.0"))).toEqual({ kind: "version_skew", }); }); @@ -53,7 +53,7 @@ describe("computeConnectedState (protocol-based compat)", () => { }); it("rejects when extension is below daemon min_compatible_protocol", () => { - const result = computeConnectedState(handshake("1.1", "1.5")); + const result = computeConnectedState(handshake("1.0", "1.5")); expect(result.kind).toBe("rejected"); if (result.kind === "rejected") { expect(result.reason).toContain("min_compatible_protocol"); @@ -65,7 +65,7 @@ describe("computeConnectedState (protocol-based compat)", () => { const result = computeConnectedState({ server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.1", + protocol_version: "1.0", min_compatible_peer: "0.1.0", }); expect(result).toEqual({ kind: "connected" }); @@ -79,14 +79,8 @@ describe("computeConnectedState (protocol-based compat)", () => { } }); - it("returns version_skew when daemon protocol is 1.0 and floor is satisfied", () => { - expect(computeConnectedState(handshake("1.0", "1.0"))).toEqual({ - kind: "version_skew", - }); - }); - it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => { - const result = computeConnectedState(handshake("1.1", "not-a-protocol")); + const result = computeConnectedState(handshake("1.0", "not-a-protocol")); expect(result.kind).toBe("rejected"); if (result.kind === "rejected") { expect(result.reason).toContain("daemon min_compatible_protocol"); @@ -248,11 +242,11 @@ describe("ConnectionController connectionEnabled", () => { const second = transport.send.mock.calls[1]?.[0] as { id: string }; expect(second.id).not.toBe(first.id); - transport.emitMessage({ id: first.id, result: handshake("1.1", "1.0") }); + transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") }); await Promise.resolve(); expect(controller.snapshot().state).not.toBe("connected"); - transport.emitMessage({ id: second.id, result: handshake("1.1", "1.0") }); + transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") }); await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected")); }); }); diff --git a/apps/extension/src/lib/trace-reducer.ts b/apps/extension/src/lib/trace-reducer.ts index 8f83fff3..b58b7939 100644 --- a/apps/extension/src/lib/trace-reducer.ts +++ b/apps/extension/src/lib/trace-reducer.ts @@ -1,4 +1,4 @@ -import type { DraftTraceStep, PageRef, SelectedOption, Step } from "@/transport/types"; +import type { DraftTraceStep, PageRefV2, SelectedOptionV2, StepV2 } from "@/transport/types"; const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]); const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]); @@ -60,7 +60,7 @@ function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] { function buildPageRegistry( steps: DraftTraceStep[], startUrl?: string, -): { pages: PageRef[]; urlToId: Map } { +): { pages: PageRefV2[]; urlToId: Map } { const urls = collectUrls(steps, startUrl); const urlToId = new Map(); const pages = urls.map((url, index) => { @@ -90,19 +90,19 @@ function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | u function effectForNavigation( navigatedTo: string | undefined, urlToId: Map, -): Step["effect"] { +): StepV2["effect"] { if (!navigatedTo) return undefined; const pageId = urlToId.get(navigatedTo); if (!pageId) return undefined; return { navigated_to: pageId }; } -function withEffect(step: Step, effect: Step["effect"]): Step { +function withEffect(step: StepV2, effect: StepV2["effect"]): StepV2 { if (!effect) return step; return { ...step, effect }; } -function toSelection(values: string[], labels?: string[]): SelectedOption[] { +function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] { return values.map((value, index) => ({ value, ...(labels?.[index] ? { label: labels[index] } : {}), @@ -114,7 +114,7 @@ function toV2Step( id: number, urlToId: Map, fallbackUrl?: string, -): Step | null { +): StepV2 | null { if (!shouldIncludeDraft(step)) return null; const pageUrl = pageUrlForDraft(step, fallbackUrl); @@ -181,8 +181,8 @@ function toV2Step( } export interface ReducedTrace { - pages: PageRef[]; - steps: Step[]; + pages: PageRefV2[]; + steps: StepV2[]; } /** @@ -192,7 +192,7 @@ export interface ReducedTrace { export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): ReducedTrace { const collapsed = collapseNavigations(steps); const { pages, urlToId } = buildPageRegistry(collapsed, startUrl); - const out: Step[] = []; + const out: StepV2[] = []; let id = 1; let lastUrl = startUrl; for (const draft of collapsed) { @@ -210,7 +210,7 @@ export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): Re export function resolveTraceStartUrl( drafts: DraftTraceStep[], startUrl?: string, - pages?: PageRef[], + pages?: PageRefV2[], ): string { if (startUrl) return startUrl; const navigate = drafts.find((step): step is Extract => { diff --git a/apps/extension/src/tools/record.ts b/apps/extension/src/tools/record.ts index c797ef16..be869ef7 100644 --- a/apps/extension/src/tools/record.ts +++ b/apps/extension/src/tools/record.ts @@ -29,7 +29,7 @@ import type { RecordStopParams, RecordStopResult, RpcError, - Trace, + TraceV2, } from "@/transport/types"; import { handleNavigate } from "./navigation"; import { @@ -50,8 +50,8 @@ interface ActiveRecording { steps: DraftTraceStep[]; startedAt: string; startedAtMs: number; - finishPromise: Promise; - resolveFinish: (trace: Trace) => void; + finishPromise: Promise; + resolveFinish: (trace: TraceV2) => void; rejectFinish: (err: Error) => void; settled: boolean; finishing: boolean; @@ -154,7 +154,7 @@ async function sendRecordStartWithAck( throw lastError ?? new Error("failed to start recording in content script"); } -function buildTrace(recording: ActiveRecording): Trace { +function buildTrace(recording: ActiveRecording): TraceV2 { const { pages, steps } = reduceTraceSteps(recording.steps, recording.startUrl); const startUrl = resolveTraceStartUrl(recording.steps, recording.startUrl, pages); return { @@ -532,7 +532,7 @@ async function finishRecordingByRequest( } } -async function finishRecording(sessionId: string, deps: RecordDeps): Promise { +async function finishRecording(sessionId: string, deps: RecordDeps): Promise { const recording = recordings.get(sessionId); if (!recording || recording.settled || recording.finishing) return null; recording.finishing = true; @@ -574,9 +574,9 @@ export async function handleRecordStart( // on the destination page can RECORD_QUERY → rearm → show RecordOverlay // instead of flashing ControlOverlay ("Agent 正在控制"). const requestId = makeRequestId(target.tabId); - let resolveFinish!: (trace: Trace) => void; + let resolveFinish!: (trace: TraceV2) => void; let rejectFinish!: (err: Error) => void; - const finishPromise = new Promise((resolve, reject) => { + const finishPromise = new Promise((resolve, reject) => { resolveFinish = resolve; rejectFinish = reject; }); @@ -785,9 +785,9 @@ export async function handleRecordAwait( return { code: "cancelled", message: "record_await aborted" }; } - const outcome = await new Promise<{ trace: Trace } | { error: RpcError }>((resolve) => { + const outcome = await new Promise<{ trace: TraceV2 } | { error: RpcError }>((resolve) => { let settled = false; - const finish = (result: { trace: Trace } | { error: RpcError }) => { + const finish = (result: { trace: TraceV2 } | { error: RpcError }) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); diff --git a/apps/extension/src/transport/__tests__/handshake.test.ts b/apps/extension/src/transport/__tests__/handshake.test.ts index 6196f344..b511453c 100644 --- a/apps/extension/src/transport/__tests__/handshake.test.ts +++ b/apps/extension/src/transport/__tests__/handshake.test.ts @@ -65,11 +65,6 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol } describe("performHandshake", () => { - it("advertises the protocol compatibility boundary", () => { - expect(PROTOCOL_VERSION).toBe("1.1"); - expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.0"); - }); - it("sends system.handshake with identity and both compat fields", async () => { let sentFrame: ProtocolFrame | null = null; const transport = fakeTransport((req) => { @@ -79,7 +74,7 @@ describe("performHandshake", () => { result: { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.1", + protocol_version: "1.0", min_compatible_peer: "0.0.0", min_compatible_protocol: "1.0", }, @@ -134,8 +129,8 @@ describe("performHandshake", () => { const response = { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.1", - min_compatible_protocol: "1.1", + protocol_version: "1.0", + min_compatible_protocol: "1.0", } satisfies HandshakeResult; const transport = fakeTransport((req) => ({ id: (req as { id: string }).id, @@ -150,7 +145,7 @@ describe("performHandshake", () => { }); expect(outcome.result.min_compatible_peer).toBeUndefined(); - expect(outcome.result.min_compatible_protocol).toBe("1.1"); + expect(outcome.result.min_compatible_protocol).toBe("1.0"); }); it("rejects when the daemon responds with an error", async () => { @@ -183,9 +178,9 @@ describe("performHandshake", () => { result: { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.1", + protocol_version: "1.0", min_compatible_peer: "0.0.0", - min_compatible_protocol: "1.1", + min_compatible_protocol: "1.0", }, }); emit({ @@ -193,14 +188,14 @@ describe("performHandshake", () => { result: { server: "browser-skill-daemon", version: "0.1.0", - protocol_version: "1.1", + protocol_version: "1.0", min_compatible_peer: "0.0.0", - min_compatible_protocol: "1.1", + min_compatible_protocol: "1.0", }, }); await expect(pending).resolves.toMatchObject({ - result: { server: "browser-skill-daemon", protocol_version: "1.1" }, + result: { server: "browser-skill-daemon", protocol_version: "1.0" }, }); }); diff --git a/apps/extension/src/transport/handshake.ts b/apps/extension/src/transport/handshake.ts index a869ca52..3da56fda 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.1"; +export const PROTOCOL_VERSION = "1.0"; /** * Extension semver, injected at build time from `package.json` via * Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`). diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index e430a8e9..5659392b 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -657,12 +657,10 @@ export interface EmulateResult { } // -------------------------------------------------------------------------- -// Semantic record payloads — legacy Trace v2 shapes (`Trace`/`Step`) mirror -// bsk-protocol record_v2.rs; Trace v3 shapes (`TraceV3`/`StepV3`) mirror -// record.rs (note: in Rust, `Trace` is the v3 shape). +// Semantic record payloads mirror the versioned Rust protocol models. // -------------------------------------------------------------------------- -export const TRACE_VERSION = 3; +export const TRACE_VERSION_V3 = 3; export const TRACE_VERSION_V2 = 2; export const DEFAULT_TRACE_VERSION = 2; export const VOM_FORMAT_VERSION = 1; @@ -682,25 +680,22 @@ export interface RecorderInfo { export type StopReason = "user_finish" | "cli_stop"; -export interface TraceState { +export interface TraceStateV3 { id: string; url: string; title?: string; - /** Wire-only: full page observation text. */ - body?: string; - /** Disk-only: filename under the bundle `pages/` directory. */ - page?: string; + body: string; truncated?: boolean; } -export interface StepResult { +export interface StepResultV3 { state: string; } export interface StepCommonV3 { id: number; state: string; - result: StepResult; + result: StepResultV3; } export type NavigationCause = @@ -715,7 +710,7 @@ export type NavigationCause = export type FillCommit = "enter" | "suggestion" | "blur"; /** Legacy v2 target shape retained for existing record producers. */ -export interface TargetDescriptor { +export interface TargetDescriptorV2 { role?: string; name?: string; tag: string; @@ -728,43 +723,43 @@ export interface TraceEntry { start_url: string; } -export interface PageRef { +export interface PageRefV2 { id: string; url: string; title?: string; } -export interface SelectedOption { +export interface SelectedOptionV2 { value: string; label?: string; } -export interface StepEffect { +export interface StepEffectV2 { navigated_to: string; } -export interface StepCommon { +export interface StepCommonV2 { id: number; page: string; - effect?: StepEffect; + effect?: StepEffectV2; } /** Capture/buffer draft before v2 reduction. */ export type DraftTraceStep = | { op: "click"; - target: TargetDescriptor; + target: TargetDescriptorV2; navigated_to?: string; page_url?: string; } | { op: "hover"; - target: TargetDescriptor; + target: TargetDescriptorV2; page_url?: string; } | { op: "fill"; - target: TargetDescriptor; + target: TargetDescriptorV2; value: string; redacted?: boolean; page_url?: string; @@ -772,14 +767,14 @@ export type DraftTraceStep = | { op: "press"; key: string; - target?: TargetDescriptor; + target?: TargetDescriptorV2; modifiers?: KeyModifier[]; navigated_to?: string; page_url?: string; } | { op: "select"; - target: TargetDescriptor; + target: TargetDescriptorV2; values: string[]; labels?: string[]; navigated_to?: string; @@ -792,38 +787,38 @@ export type DraftTraceStep = }; /** Exported record-only step (trace v2). */ -export type Step = - | ({ op: "navigate" } & StepCommon & { to: string }) - | ({ op: "click" } & StepCommon & { target: TargetDescriptor }) - | ({ op: "hover" } & StepCommon & { target: TargetDescriptor }) - | ({ op: "fill" } & StepCommon & { - target: TargetDescriptor; +export type StepV2 = + | ({ op: "navigate" } & StepCommonV2 & { to: string }) + | ({ op: "click" } & StepCommonV2 & { target: TargetDescriptorV2 }) + | ({ op: "hover" } & StepCommonV2 & { target: TargetDescriptorV2 }) + | ({ op: "fill" } & StepCommonV2 & { + target: TargetDescriptorV2; value: string; redacted?: boolean; }) - | ({ op: "select" } & StepCommon & { - target: TargetDescriptor; - selection: SelectedOption[]; + | ({ op: "select" } & StepCommonV2 & { + target: TargetDescriptorV2; + selection: SelectedOptionV2[]; }) - | ({ op: "press" } & StepCommon & { + | ({ op: "press" } & StepCommonV2 & { key: string; modifiers?: KeyModifier[]; - target?: TargetDescriptor; + target?: TargetDescriptorV2; }); -export type TargetDescriptorV2 = TargetDescriptor; -export type StepV2 = Step; - -export interface Trace { +export interface TraceV2 { recorded_at: string; started_at?: string; purpose?: string; entry: TraceEntry; - pages: PageRef[]; - steps: Step[]; + pages: PageRefV2[]; + steps: StepV2[]; } -export type TraceV2 = Trace; +export interface SelectedOptionV3 { + value: string; + label?: string; +} export type StepV3 = | ({ op: "navigate" } & StepCommonV3 & { to: string; cause: NavigationCause }) @@ -837,7 +832,7 @@ export type StepV3 = }) | ({ op: "select" } & StepCommonV3 & { target: TargetDescriptorV3; - selection?: SelectedOption[]; + selection?: SelectedOptionV3[]; }) | ({ op: "press" } & StepCommonV3 & { key: string; @@ -847,14 +842,14 @@ export type StepV3 = | ({ op: "scroll" } & StepCommonV3); export interface TraceV3 { - version: 3; + version: typeof TRACE_VERSION_V3; recorded_at: string; started_at?: string; purpose?: string; stopped_by: StopReason; entry: TraceEntry; recorder: RecorderInfo; - states: TraceState[]; + states: TraceStateV3[]; steps: StepV3[]; } @@ -865,10 +860,6 @@ export interface RecordStartParams { tab_id?: number; url?: string; purpose?: string; - max_page_tokens?: number; - redact_values?: boolean; - /** Omitted means v2; `3` requests a state-linked v3 trace. */ - trace_version?: number; } export interface RecordStartResult { diff --git a/crates/bsk-cli/src/cli/doctor.rs b/crates/bsk-cli/src/cli/doctor.rs index 2a839012..3d0c57a9 100644 --- a/crates/bsk-cli/src/cli/doctor.rs +++ b/crates/bsk-cli/src/cli/doctor.rs @@ -486,7 +486,7 @@ mod m2_tests { fn fake_status(browsers: Vec, skew: Vec) -> StatusResult { StatusResult { daemon_version: env!("CARGO_PKG_VERSION").into(), - protocol_version: "1.1".into(), + protocol_version: "1.0".into(), pid: 1, uptime_secs: 0, ws_port: 0, @@ -571,7 +571,7 @@ mod m2_tests { session_count: 0, connected_at_ms: 1, version_skew: false, - extension_protocol_version: "1.1".into(), + extension_protocol_version: "1.0".into(), }], Vec::new(), ); @@ -592,7 +592,7 @@ mod m2_tests { session_count: 0, connected_at_ms: 1, version_skew: true, - extension_protocol_version: "1.2".into(), + extension_protocol_version: "1.1".into(), }], vec![VersionSkewEntry { instance_id: "alpha".into(), @@ -600,8 +600,8 @@ mod m2_tests { label: "Personal".into(), server_version: env!("CARGO_PKG_VERSION").into(), client_version: "0.0.9".into(), - server_protocol_version: "1.1".into(), - client_protocol_version: "1.2".into(), + server_protocol_version: "1.0".into(), + client_protocol_version: "1.1".into(), }], ); let check = check_browsers_protocol_compatible(Some(&status)); diff --git a/crates/bsk-cli/src/cli/record.rs b/crates/bsk-cli/src/cli/record.rs index 7cee47f9..8cc66dba 100644 --- a/crates/bsk-cli/src/cli/record.rs +++ b/crates/bsk-cli/src/cli/record.rs @@ -98,9 +98,6 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> tab_id: args.tab_id, url: args.url, purpose: args.purpose.clone(), - max_page_tokens: None, - redact_values: None, - trace_version: None, }; let start_result = business_rpc::call::( info.sock_path.clone(), diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index 241eb855..fdd46a69 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -26,7 +26,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, PROTOCOL_VERSION}, + state::DaemonState, ws, }; @@ -297,7 +297,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> { ws_port, sock_path: sock_path.clone(), daemon_version: env!("CARGO_PKG_VERSION"), - protocol_version: PROTOCOL_VERSION, + protocol_version: "1.0", }; 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 ad3b48bd..a1b5d117 100644 --- a/crates/bsk-cli/src/daemon/state.rs +++ b/crates/bsk-cli/src/daemon/state.rs @@ -16,7 +16,7 @@ use super::start::DaemonConfig; use super::ws::WsHandle; pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub const PROTOCOL_VERSION: &str = "1.1"; +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"; /// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer` diff --git a/crates/bsk-cli/tests/browser_wait.rs b/crates/bsk-cli/tests/browser_wait.rs index b2a9a140..16513e0f 100644 --- a/crates/bsk-cli/tests/browser_wait.rs +++ b/crates/bsk-cli/tests/browser_wait.rs @@ -112,7 +112,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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/cancel_forwarding.rs b/crates/bsk-cli/tests/cancel_forwarding.rs index 01894c82..c48113ce 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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/handshake_compat.rs b/crates/bsk-cli/tests/handshake_compat.rs index 7c8fd507..a5e91b01 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.1", env!("CARGO_PKG_VERSION")).await; + let resp = send_handshake(&mut ws, "1.0", 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.1"); + assert_eq!(result.protocol_version, "1.0"); assert_eq!( result .min_compatible_peer @@ -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.1", "9.9.9", Some("0.0.0"), Some("1.1")).await; + send_handshake_with_floors(&mut ws, "1.0", "9.9.9", Some("0.0.0"), Some("1.0")).await; match resp.body { ResponseBody::Ok(_) => {} other => panic!("expected ok when protocol matches, got {other:?}"), @@ -149,32 +149,7 @@ 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.2", - env!("CARGO_PKG_VERSION"), - Some("0.0.0"), - Some("1.1"), - ) - .await; - match resp.body { - ResponseBody::Ok(_) => {} - other => panic!("minor protocol drift should warn-but-allow, got {other:?}"), - } - let state = handle.state(); - let client = state - .browsers - .get(&bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into())) - .expect("browser registered"); - assert!(client.version_skew); - handle.shutdown().await; -} - -#[tokio::test] -async fn handshake_skew_when_protocol_1_0_peer() { - 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", + "1.1", env!("CARGO_PKG_VERSION"), Some("0.0.0"), Some("1.0"), @@ -182,7 +157,7 @@ async fn handshake_skew_when_protocol_1_0_peer() { .await; match resp.body { ResponseBody::Ok(_) => {} - other => panic!("protocol 1.0 peer should connect with skew, got {other:?}"), + other => panic!("minor protocol drift should warn-but-allow, got {other:?}"), } let state = handle.state(); let client = state @@ -235,7 +210,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.1", + "1.0", env!("CARGO_PKG_VERSION"), Some("0.1.0"), None, @@ -260,7 +235,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.2".into(), + extension_protocol_version: "1.1".into(), label: "Older".into(), sink: bsk::daemon::browsers::BrowserSink { tx }, pending: Mutex::new(bsk::daemon::browsers::Pending::default()), @@ -288,8 +263,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.2"); - assert_eq!(skew.server_protocol_version, "1.1"); + assert_eq!(skew.client_protocol_version, "1.1"); + assert_eq!(skew.server_protocol_version, "1.0"); assert_eq!(skew.client_version, "9.9.9"); let entry = status .browsers @@ -306,7 +281,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.1", + "1.0", 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 8f0bdf6e..70df3a7d 100644 --- a/crates/bsk-cli/tests/per_session_queue.rs +++ b/crates/bsk-cli/tests/per_session_queue.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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/session_user_interrupt.rs b/crates/bsk-cli/tests/session_user_interrupt.rs index 4ae1ce46..9c5288cc 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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/sessions_ipc.rs b/crates/bsk-cli/tests/sessions_ipc.rs index 4d55b068..e6d77a9d 100644 --- a/crates/bsk-cli/tests/sessions_ipc.rs +++ b/crates/bsk-cli/tests/sessions_ipc.rs @@ -86,7 +86,7 @@ async fn handshake_as_ext(ws: &mut TestWs) -> HandshakeResult { let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), @@ -758,7 +758,7 @@ async fn connect_second_ext( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.1".into(), + protocol_version: "1.0".into(), instance_id: instance_id.into(), browser: BrowserPeerInfo { name: "edge".into(), diff --git a/crates/bsk-cli/tests/status_cmd.rs b/crates/bsk-cli/tests/status_cmd.rs index 18513e2e..0e329146 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.1"); + assert_eq!(parsed["protocol_version"], "1.0"); 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 b64ff9bd..d40bb9ca 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -73,7 +73,7 @@ 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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/tools_m7_ipc.rs b/crates/bsk-cli/tests/tools_m7_ipc.rs index 4635828e..ee74b5e5 100644 --- a/crates/bsk-cli/tests/tools_m7_ipc.rs +++ b/crates/bsk-cli/tests/tools_m7_ipc.rs @@ -76,7 +76,7 @@ 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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/tools_m8_ipc.rs b/crates/bsk-cli/tests/tools_m8_ipc.rs index 777d2128..14e7c72a 100644 --- a/crates/bsk-cli/tests/tools_m8_ipc.rs +++ b/crates/bsk-cli/tests/tools_m8_ipc.rs @@ -75,7 +75,7 @@ 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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/tools_m9_ipc.rs b/crates/bsk-cli/tests/tools_m9_ipc.rs index f5015cac..2f8b46d9 100644 --- a/crates/bsk-cli/tests/tools_m9_ipc.rs +++ b/crates/bsk-cli/tests/tools_m9_ipc.rs @@ -85,7 +85,7 @@ 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.1".into(), + protocol_version: "1.0".into(), instance_id: TEST_EXT_ID.into(), browser: BrowserPeerInfo { name: "chrome".into(), diff --git a/crates/bsk-cli/tests/ws_handshake.rs b/crates/bsk-cli/tests/ws_handshake.rs index be413d27..c6e2da14 100644 --- a/crates/bsk-cli/tests/ws_handshake.rs +++ b/crates/bsk-cli/tests/ws_handshake.rs @@ -53,7 +53,7 @@ pub async fn send_handshake( let params = HandshakeParams { client: "browser-skill-extension".into(), version: "0.1.0-dev.0".parse().unwrap(), - protocol_version: "1.1".into(), + protocol_version: "1.0".into(), instance_id: instance_id.into(), browser: BrowserPeerInfo { name: "chrome".into(), @@ -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.1"); + assert_eq!(result.protocol_version, "1.0"); 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 a16fdf89..dee452f4 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -41,7 +41,7 @@ "browser" ] }, - "PageRef": { + "PageRefV2": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", "required": [ @@ -69,7 +69,7 @@ "$ref": "#/definitions/TraceV2" }, { - "$ref": "#/definitions/Trace" + "$ref": "#/definitions/TraceV3" } ] }, @@ -90,7 +90,7 @@ } } }, - "SelectedOption": { + "SelectedOptionV2": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -108,7 +108,7 @@ } } }, - "SelectedOptionV2": { + "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -126,23 +126,52 @@ } } }, - "Step": { - "description": "One recorded user action — discriminated union by `op`.", + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", "oneOf": [ { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ - "cause", "id", "op", - "result", - "state", + "page", "to" ], "properties": { - "cause": { - "$ref": "#/definitions/NavigationCause" + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] }, "id": { "type": "integer", @@ -155,11 +184,8 @@ "navigate" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "to": { @@ -168,16 +194,25 @@ } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "result", - "state", + "page", "target" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -189,29 +224,35 @@ "click" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "result", - "state", + "page", "target" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -223,33 +264,35 @@ "hover" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ - "commit", "id", "op", - "result", - "state", + "page", "target", "value" ], "properties": { - "commit": { - "$ref": "#/definitions/FillCommit" + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] }, "id": { "type": "integer", @@ -262,18 +305,18 @@ "fill" ] }, - "redacted": { - "type": "boolean" - }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -281,16 +324,26 @@ } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "result", - "state", + "page", + "selection", "target" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -302,35 +355,41 @@ "select" ] }, - "result": { - "$ref": "#/definitions/StepResult" + "page": { + "description": "Reference into `pages[]`.", + "type": "string" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV2" } }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "key", "op", - "result", - "state" + "page" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -354,17 +413,14 @@ "press" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -372,17 +428,27 @@ ] } } - }, + } + ] + }, + "StepV3": { + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ { "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", "result", - "state" + "state", + "to" ], "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, "id": { "type": "integer", "format": "uint32", @@ -391,67 +457,32 @@ "op": { "type": "string", "enum": [ - "scroll" + "navigate" ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" + }, + "to": { + "type": "string" } } - } - ] - }, - "StepEffectV2": { - "description": "Observed navigation after a step (objective fact only).", - "type": "object", - "required": [ - "navigated_to" - ], - "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", - "type": "string" - } - } - }, - "StepResult": { - "type": "object", - "required": [ - "state" - ], - "properties": { - "state": { - "type": "string" - } - } - }, - "StepV2": { - "description": "One recorded user action — discriminated union by `op` (v2).", - "oneOf": [ + }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "page", - "to" + "result", + "state", + "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -460,78 +491,32 @@ "op": { "type": "string", "enum": [ - "navigate" + "click" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResultV3" }, - "to": { + "state": { + "description": "Observation id immediately before this action.", "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" } } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "page", - "target" - ], - "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "click" - ] - }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV2" - } - } - }, - { - "description": "Fields shared by every v2 step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -543,35 +528,33 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" } } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "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/StepEffectV2" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -584,18 +567,18 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" }, "value": { "type": "string" @@ -603,26 +586,16 @@ } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -634,41 +607,35 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResultV3" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOptionV2" + "$ref": "#/definitions/SelectedOptionV3" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" } } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -692,14 +659,17 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" }, { "type": "null" @@ -707,6 +677,36 @@ ] } } + }, + { + "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/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + } + } } ] }, @@ -717,39 +717,6 @@ "cli_stop" ] }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element within a page observation.", - "type": "object", - "properties": { - "ctx": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "ref": { - "type": [ - "string", - "null" - ] - }, - "role": { - "type": [ - "string", - "null" - ] - }, - "unmatched": { - "type": "boolean" - } - } - }, "TargetDescriptorV2": { "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", @@ -792,64 +759,40 @@ } } }, - "Trace": { - "description": "Persisted user-action trace exported by `tool.record_stop` / `await`.", + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", - "required": [ - "entry", - "recorded_at", - "recorder", - "states", - "steps", - "stopped_by", - "version" - ], "properties": { - "entry": { - "$ref": "#/definitions/TraceEntry" - }, - "purpose": { + "ctx": { "type": [ "string", "null" ] }, - "recorded_at": { - "type": "string" - }, - "recorder": { - "$ref": "#/definitions/RecorderInfo" - }, - "started_at": { + "name": { "type": [ "string", "null" ] }, - "states": { - "type": "array", - "items": { - "$ref": "#/definitions/TraceState" - } - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/definitions/Step" - } + "ref": { + "type": [ + "string", + "null" + ] }, - "stopped_by": { - "$ref": "#/definitions/StopReason" + "role": { + "type": [ + "string", + "null" + ] }, - "version": { - "type": "integer", - "const": 3 + "unmatched": { + "type": "boolean" } - }, - "additionalProperties": false + } }, "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", "type": "object", "required": [ "start_url" @@ -860,31 +803,22 @@ } } }, - "TraceState": { + "TraceStateV3": { "description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.", "type": "object", "required": [ + "body", "id", "url" ], "properties": { "body": { - "description": "Wire-only: full page observation (front matter + VOM body + annotations).", - "type": [ - "string", - "null" - ] + "description": "Full page observation (front matter + VOM body + annotations).", + "type": "string" }, "id": { "type": "string" }, - "page": { - "description": "Disk-only: filename under the bundle `pages/` directory.", - "type": [ - "string", - "null" - ] - }, "title": { "type": [ "string", @@ -915,7 +849,7 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { @@ -942,6 +876,62 @@ } }, "additionalProperties": false + }, + "TraceV3": { + "description": "Wire trace returned by `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "type": "string" + }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV3" + } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "const": 3 + } + }, + "additionalProperties": false } } } diff --git a/crates/bsk-protocol/schema/tool_record_start_params.json b/crates/bsk-protocol/schema/tool_record_start_params.json index e81c2618..859ebf82 100644 --- a/crates/bsk-protocol/schema/tool_record_start_params.json +++ b/crates/bsk-protocol/schema/tool_record_start_params.json @@ -6,26 +6,12 @@ "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" }, @@ -36,15 +22,6 @@ ], "format": "int64" }, - "trace_version": { - "description": "Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle.", - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0.0 - }, "url": { "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 6fe6d48e..5d1e04a9 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -41,7 +41,7 @@ "browser" ] }, - "PageRef": { + "PageRefV2": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", "required": [ @@ -69,7 +69,7 @@ "$ref": "#/definitions/TraceV2" }, { - "$ref": "#/definitions/Trace" + "$ref": "#/definitions/TraceV3" } ] }, @@ -90,7 +90,7 @@ } } }, - "SelectedOption": { + "SelectedOptionV2": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -108,7 +108,7 @@ } } }, - "SelectedOptionV2": { + "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -126,23 +126,52 @@ } } }, - "Step": { - "description": "One recorded user action — discriminated union by `op`.", + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", "oneOf": [ { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ - "cause", "id", "op", - "result", - "state", + "page", "to" ], "properties": { - "cause": { - "$ref": "#/definitions/NavigationCause" + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] }, "id": { "type": "integer", @@ -155,11 +184,8 @@ "navigate" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "to": { @@ -168,16 +194,25 @@ } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "result", - "state", + "page", "target" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -189,29 +224,35 @@ "click" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "result", - "state", + "page", "target" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -223,33 +264,35 @@ "hover" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ - "commit", "id", "op", - "result", - "state", + "page", "target", "value" ], "properties": { - "commit": { - "$ref": "#/definitions/FillCommit" + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] }, "id": { "type": "integer", @@ -262,18 +305,18 @@ "fill" ] }, - "redacted": { - "type": "boolean" - }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -281,16 +324,26 @@ } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "result", - "state", + "page", + "selection", "target" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -302,35 +355,41 @@ "select" ] }, - "result": { - "$ref": "#/definitions/StepResult" + "page": { + "description": "Reference into `pages[]`.", + "type": "string" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV2" } }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" } } }, { - "description": "Fields shared by every step variant (flattened in JSON).", + "description": "Fields shared by every v2 step variant (flattened in JSON).", "type": "object", "required": [ "id", "key", "op", - "result", - "state" + "page" ], "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, "id": { "type": "integer", "format": "uint32", @@ -354,17 +413,14 @@ "press" ] }, - "result": { - "$ref": "#/definitions/StepResult" - }, - "state": { - "description": "Observation id immediately before this action.", + "page": { + "description": "Reference into `pages[]`.", "type": "string" }, "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -372,17 +428,27 @@ ] } } - }, + } + ] + }, + "StepV3": { + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ { "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "cause", "id", "op", "result", - "state" + "state", + "to" ], "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, "id": { "type": "integer", "format": "uint32", @@ -391,67 +457,32 @@ "op": { "type": "string", "enum": [ - "scroll" + "navigate" ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" + }, + "to": { + "type": "string" } } - } - ] - }, - "StepEffectV2": { - "description": "Observed navigation after a step (objective fact only).", - "type": "object", - "required": [ - "navigated_to" - ], - "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", - "type": "string" - } - } - }, - "StepResult": { - "type": "object", - "required": [ - "state" - ], - "properties": { - "state": { - "type": "string" - } - } - }, - "StepV2": { - "description": "One recorded user action — discriminated union by `op` (v2).", - "oneOf": [ + }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "page", - "to" + "result", + "state", + "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -460,78 +491,32 @@ "op": { "type": "string", "enum": [ - "navigate" + "click" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResultV3" }, - "to": { + "state": { + "description": "Observation id immediately before this action.", "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" } } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "page", - "target" - ], - "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "click" - ] - }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV2" - } - } - }, - { - "description": "Fields shared by every v2 step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -543,35 +528,33 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" } } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "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/StepEffectV2" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -584,18 +567,18 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" }, "value": { "type": "string" @@ -603,26 +586,16 @@ } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -634,41 +607,35 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResultV3" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOptionV2" + "$ref": "#/definitions/SelectedOptionV3" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" } } }, { - "description": "Fields shared by every v2 step variant (flattened in JSON).", + "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffectV2" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -692,14 +659,17 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptorV2" + "$ref": "#/definitions/TargetDescriptorV3" }, { "type": "null" @@ -707,6 +677,36 @@ ] } } + }, + { + "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/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + } + } } ] }, @@ -717,39 +717,6 @@ "cli_stop" ] }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element within a page observation.", - "type": "object", - "properties": { - "ctx": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "ref": { - "type": [ - "string", - "null" - ] - }, - "role": { - "type": [ - "string", - "null" - ] - }, - "unmatched": { - "type": "boolean" - } - } - }, "TargetDescriptorV2": { "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", @@ -792,64 +759,40 @@ } } }, - "Trace": { - "description": "Persisted user-action trace exported by `tool.record_stop` / `await`.", + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", - "required": [ - "entry", - "recorded_at", - "recorder", - "states", - "steps", - "stopped_by", - "version" - ], "properties": { - "entry": { - "$ref": "#/definitions/TraceEntry" - }, - "purpose": { + "ctx": { "type": [ "string", "null" ] }, - "recorded_at": { - "type": "string" - }, - "recorder": { - "$ref": "#/definitions/RecorderInfo" - }, - "started_at": { + "name": { "type": [ "string", "null" ] }, - "states": { - "type": "array", - "items": { - "$ref": "#/definitions/TraceState" - } - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/definitions/Step" - } + "ref": { + "type": [ + "string", + "null" + ] }, - "stopped_by": { - "$ref": "#/definitions/StopReason" + "role": { + "type": [ + "string", + "null" + ] }, - "version": { - "type": "integer", - "const": 3 + "unmatched": { + "type": "boolean" } - }, - "additionalProperties": false + } }, "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", "type": "object", "required": [ "start_url" @@ -860,31 +803,22 @@ } } }, - "TraceState": { + "TraceStateV3": { "description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.", "type": "object", "required": [ + "body", "id", "url" ], "properties": { "body": { - "description": "Wire-only: full page observation (front matter + VOM body + annotations).", - "type": [ - "string", - "null" - ] + "description": "Full page observation (front matter + VOM body + annotations).", + "type": "string" }, "id": { "type": "string" }, - "page": { - "description": "Disk-only: filename under the bundle `pages/` directory.", - "type": [ - "string", - "null" - ] - }, "title": { "type": [ "string", @@ -915,7 +849,7 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { @@ -942,6 +876,62 @@ } }, "additionalProperties": false + }, + "TraceV3": { + "description": "Wire trace returned by `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "type": "string" + }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV3" + } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "const": 3 + } + }, + "additionalProperties": false } } } diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 2abfd555..750ce6a7 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Trace", - "description": "Persisted user-action trace exported by `tool.record_stop` / `await`.", + "title": "TraceV3", + "description": "Wire trace returned by `tool.record_stop` / `await`.", "type": "object", "required": [ "entry", @@ -37,13 +37,13 @@ "states": { "type": "array", "items": { - "$ref": "#/definitions/TraceState" + "$ref": "#/definitions/TraceStateV3" } }, "steps": { "type": "array", "items": { - "$ref": "#/definitions/Step" + "$ref": "#/definitions/StepV3" } }, "stopped_by": { @@ -103,7 +103,7 @@ } } }, - "SelectedOption": { + "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -121,7 +121,18 @@ } } }, - "Step": { + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV3": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ { @@ -151,7 +162,7 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", @@ -185,14 +196,14 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -219,14 +230,14 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -261,14 +272,14 @@ "type": "boolean" }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, "value": { "type": "string" @@ -298,12 +309,12 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV3" } }, "state": { @@ -311,7 +322,7 @@ "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -350,7 +361,7 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", @@ -359,7 +370,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, { "type": "null" @@ -390,7 +401,7 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", @@ -400,17 +411,6 @@ } ] }, - "StepResult": { - "type": "object", - "required": [ - "state" - ], - "properties": { - "state": { - "type": "string" - } - } - }, "StopReason": { "type": "string", "enum": [ @@ -418,7 +418,7 @@ "cli_stop" ] }, - "TargetDescriptor": { + "TargetDescriptorV3": { "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", "properties": { @@ -452,7 +452,6 @@ } }, "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", "type": "object", "required": [ "start_url" @@ -463,31 +462,22 @@ } } }, - "TraceState": { + "TraceStateV3": { "description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.", "type": "object", "required": [ + "body", "id", "url" ], "properties": { "body": { - "description": "Wire-only: full page observation (front matter + VOM body + annotations).", - "type": [ - "string", - "null" - ] + "description": "Full page observation (front matter + VOM body + annotations).", + "type": "string" }, "id": { "type": "string" }, - "page": { - "description": "Disk-only: filename under the bundle `pages/` directory.", - "type": [ - "string", - "null" - ] - }, "title": { "type": [ "string", diff --git a/crates/bsk-protocol/schema/trace_step.json b/crates/bsk-protocol/schema/trace_step.json index 79f5e1e7..14a33ee1 100644 --- a/crates/bsk-protocol/schema/trace_step.json +++ b/crates/bsk-protocol/schema/trace_step.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Step", + "title": "StepV3", "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ { @@ -30,7 +30,7 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", @@ -64,14 +64,14 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -98,14 +98,14 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -140,14 +140,14 @@ "type": "boolean" }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, "value": { "type": "string" @@ -177,12 +177,12 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV3" } }, "state": { @@ -190,7 +190,7 @@ "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -229,7 +229,7 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", @@ -238,7 +238,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, { "type": "null" @@ -269,7 +269,7 @@ ] }, "result": { - "$ref": "#/definitions/StepResult" + "$ref": "#/definitions/StepResultV3" }, "state": { "description": "Observation id immediately before this action.", @@ -309,7 +309,7 @@ "browser" ] }, - "SelectedOption": { + "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -327,7 +327,7 @@ } } }, - "StepResult": { + "StepResultV3": { "type": "object", "required": [ "state" @@ -338,7 +338,7 @@ } } }, - "TargetDescriptor": { + "TargetDescriptorV3": { "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", "properties": { diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json index c301ee9f..7db328c2 100644 --- a/crates/bsk-protocol/schema/trace_v2.json +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -16,7 +16,7 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { @@ -54,7 +54,7 @@ "shift" ] }, - "PageRef": { + "PageRefV2": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", "required": [ @@ -431,7 +431,6 @@ } }, "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", "type": "object", "required": [ "start_url" diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 2071a057..6e565fe4 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -114,8 +114,8 @@ fn main() { dump!(RequestHelpResult, "tool_request_help_result"); dump!(TraceV2, "trace_v2"); - dump!(Trace, "trace"); - dump!(Step, "trace_step"); + dump!(TraceV3, "trace"); + dump!(StepV3, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); dump!(RecordStartResult, "tool_record_start_result"); dump!(RecordStopParams, "tool_record_stop_params"); diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 67e8f53f..11ab651d 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -9,7 +9,8 @@ pub mod navigation; pub mod network; pub mod observation; pub mod record; -pub mod record_v2; +mod record_common; +mod record_v2; pub mod script; pub mod session; pub mod tabs; diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index 8111965b..d4e862b4 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -8,11 +8,12 @@ use serde::{Deserialize, Deserializer, Serialize}; use super::interaction::KeyModifier; -pub use crate::record_v2::{ - PageRef, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, +pub use super::record_common::TraceEntry; +pub use super::record_v2::{ + PageRefV2, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, }; -pub const TRACE_VERSION: u32 = 3; +pub const TRACE_VERSION_V3: u32 = 3; pub const TRACE_VERSION_V2: u32 = 2; pub const DEFAULT_TRACE_VERSION: u32 = 2; pub const VOM_FORMAT_VERSION: u32 = 1; @@ -22,9 +23,9 @@ where D: Deserializer<'de>, { let version = u32::deserialize(deserializer)?; - if version != TRACE_VERSION { + if version != TRACE_VERSION_V3 { return Err(serde::de::Error::custom(format!( - "unsupported trace version {version} (expected {TRACE_VERSION})" + "unsupported trace version {version} (expected {TRACE_VERSION_V3})" ))); } Ok(version) @@ -33,7 +34,7 @@ where fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { schemars::schema::SchemaObject { instance_type: Some(schemars::schema::InstanceType::Integer.into()), - const_value: Some(serde_json::json!(TRACE_VERSION)), + const_value: Some(serde_json::json!(TRACE_VERSION_V3)), ..Default::default() } .into() @@ -45,7 +46,7 @@ fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars /// Stable semantic handle for an interacted element within a page observation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TargetDescriptor { +pub struct TargetDescriptorV3 { #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")] pub element_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -59,15 +60,9 @@ pub struct TargetDescriptor { } // --------------------------------------------------------------------------- -// Trace envelope +// Trace v3 envelope // --------------------------------------------------------------------------- -/// Recording entry point — first URL the flow starts from. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TraceEntry { - pub start_url: String, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecorderInfo { pub bsk: String, @@ -83,42 +78,38 @@ pub enum StopReason { /// Page observation dictionary entry — referenced by steps via `state` / `result.state`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TraceState { +pub struct TraceStateV3 { 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, + /// Full page observation (front matter + VOM body + annotations). + pub body: String, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub truncated: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepResult { +pub struct StepResultV3 { pub state: String, } /// Fields shared by every step variant (flattened in JSON). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepCommon { +pub struct StepCommonV3 { pub id: u32, /// Observation id immediately before this action. pub state: String, - pub result: StepResult, + pub result: StepResultV3, } // --------------------------------------------------------------------------- -// Step op-specific payloads +// Trace v3 step payloads // --------------------------------------------------------------------------- /// One selected option (`select` op). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct SelectedOption { +pub struct SelectedOptionV3 { pub value: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, @@ -147,27 +138,27 @@ pub enum FillCommit { /// One recorded user action — discriminated union by `op`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "op", rename_all = "snake_case")] -pub enum Step { +pub enum StepV3 { Navigate { #[serde(flatten)] - common: StepCommon, + common: StepCommonV3, to: String, cause: NavigationCause, }, Click { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, }, Hover { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, }, Fill { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, value: String, commit: FillCommit, #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -175,34 +166,34 @@ pub enum Step { }, Select { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, #[serde(default, skip_serializing_if = "Vec::is_empty")] - selection: Vec, + selection: Vec, }, Press { #[serde(flatten)] - common: StepCommon, + common: StepCommonV3, key: String, #[serde(default, skip_serializing_if = "Option::is_none")] modifiers: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, + target: Option, }, Scroll { #[serde(flatten)] - common: StepCommon, + common: StepCommonV3, }, } // --------------------------------------------------------------------------- -// Trace root +// Trace v3 root // --------------------------------------------------------------------------- -/// Persisted user-action trace exported by `tool.record_stop` / `await`. +/// Wire trace returned by `tool.record_stop` / `await`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct Trace { +pub struct TraceV3 { #[serde(deserialize_with = "deserialize_trace_v3_version")] #[schemars(schema_with = "trace_v3_version_schema")] pub version: u32, @@ -214,8 +205,8 @@ pub struct Trace { pub stopped_by: StopReason, pub entry: TraceEntry, pub recorder: RecorderInfo, - pub states: Vec, - pub steps: Vec, + pub states: Vec, + pub steps: Vec, } // --------------------------------------------------------------------------- @@ -231,13 +222,6 @@ 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, - /// Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace_version: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -255,13 +239,13 @@ pub struct RecordStopParams { #[derive(Debug, Clone, PartialEq)] pub enum RecordedTrace { V2(TraceV2), - V3(Trace), + V3(TraceV3), } impl RecordedTrace { pub fn classify_value(v: &serde_json::Value) -> Result { if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) { - if ver != u64::from(TRACE_VERSION) { + if ver != u64::from(TRACE_VERSION_V3) { return Err(format!("unsupported trace version {ver}")); } if v.get("pages").is_some() { @@ -281,24 +265,6 @@ impl RecordedTrace { } Err("ambiguous or unparseable trace".into()) } - - pub fn is_v3(&self) -> bool { - matches!(self, RecordedTrace::V3(_)) - } - - pub fn as_v3(&self) -> Option<&Trace> { - match self { - RecordedTrace::V3(t) => Some(t), - RecordedTrace::V2(_) => None, - } - } - - pub fn as_v2(&self) -> Option<&TraceV2> { - match self { - RecordedTrace::V2(t) => Some(t), - RecordedTrace::V3(_) => None, - } - } } impl Serialize for RecordedTrace { @@ -333,7 +299,7 @@ impl JsonSchema for RecordedTrace { subschemas: Some(Box::new(schemars::schema::SubschemaValidation { one_of: Some(vec![ generator.subschema_for::(), - generator.subschema_for::(), + generator.subschema_for::(), ]), ..Default::default() })), @@ -369,18 +335,18 @@ mod tests { use super::*; use serde_json::json; - fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommon { - StepCommon { + fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommonV3 { + StepCommonV3 { id, state: state.into(), - result: StepResult { + result: StepResultV3 { state: result_state.into(), }, } } - fn sample_target() -> TargetDescriptor { - TargetDescriptor { + fn sample_target() -> TargetDescriptorV3 { + TargetDescriptorV3 { element_ref: Some("e21".into()), role: Some("button".into()), name: Some("发布".into()), @@ -389,9 +355,9 @@ mod tests { } } - fn sample_trace() -> Trace { - Trace { - version: TRACE_VERSION, + fn sample_trace() -> TraceV3 { + TraceV3 { + version: TRACE_VERSION_V3, purpose: Some("把草稿商品发布上架".into()), started_at: Some("2026-08-10T02:10:41.080Z".into()), recorded_at: "2026-08-10T02:12:55.360Z".into(), @@ -404,32 +370,30 @@ mod tests { vom: VOM_FORMAT_VERSION, }, states: vec![ - TraceState { + TraceStateV3 { id: "s1".into(), url: "https://example.com/".into(), title: Some("Example Domain".into()), - body: None, - page: Some("s1.vom.txt".into()), + body: "@vom 1\nRootWebArea \"Example Domain\"".into(), truncated: false, }, - TraceState { + TraceStateV3 { id: "s2".into(), url: "https://shop.example.com/products?status=draft".into(), title: Some("商品管理".into()), - body: None, - page: Some("s2.vom.txt".into()), + body: "@vom 1\nRootWebArea \"商品管理\"".into(), truncated: false, }, ], steps: vec![ - Step::Navigate { + StepV3::Navigate { common: sample_common(1, "s1", "s2"), to: "https://shop.example.com/products?status=draft".into(), cause: NavigationCause::UserTyped, }, - Step::Fill { + StepV3::Fill { common: sample_common(2, "s2", "s2"), - target: TargetDescriptor { + target: TargetDescriptorV3 { element_ref: Some("e12".into()), role: Some("textbox".into()), name: Some("搜索商品".into()), @@ -440,7 +404,7 @@ mod tests { commit: FillCommit::Enter, redacted: false, }, - Step::Click { + StepV3::Click { common: sample_common(3, "s2", "s2"), target: sample_target(), }, @@ -450,7 +414,7 @@ mod tests { #[test] fn step_click_round_trips() { - let step = Step::Click { + let step = StepV3::Click { common: sample_common(1, "s1", "s2"), target: sample_target(), }; @@ -459,15 +423,15 @@ mod tests { 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(); + let round: StepV3 = serde_json::from_value(v).unwrap(); assert_eq!(round, step); } #[test] fn step_fill_with_commit_round_trips() { - let step = Step::Fill { + let step = StepV3::Fill { common: sample_common(2, "s2", "s3"), - target: TargetDescriptor { + target: TargetDescriptorV3 { element_ref: Some("e12".into()), role: Some("textbox".into()), name: Some("搜索商品".into()), @@ -482,15 +446,15 @@ mod tests { assert_eq!(v["op"], "fill"); assert_eq!(v["commit"], "enter"); assert!(v.get("redacted").is_none()); - let round: Step = serde_json::from_value(v).unwrap(); + let round: StepV3 = serde_json::from_value(v).unwrap(); assert_eq!(round, step); } #[test] fn step_fill_password_is_redacted() { - let step = Step::Fill { + let step = StepV3::Fill { common: sample_common(1, "s1", "s1"), - target: TargetDescriptor { + target: TargetDescriptorV3 { element_ref: Some("e3".into()), role: Some("textbox".into()), name: Some("密码".into()), @@ -508,7 +472,7 @@ mod tests { #[test] fn step_navigate_with_cause_round_trips() { - let step = Step::Navigate { + let step = StepV3::Navigate { common: sample_common(1, "s1", "s2"), to: "https://example.com".into(), cause: NavigationCause::UserTyped, @@ -516,7 +480,7 @@ mod tests { let v = serde_json::to_value(&step).unwrap(); assert_eq!(v["op"], "navigate"); assert_eq!(v["cause"], "user_typed"); - let round: Step = serde_json::from_value(v).unwrap(); + let round: StepV3 = serde_json::from_value(v).unwrap(); assert_eq!(round, step); } @@ -528,15 +492,15 @@ mod tests { 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(); + let round: TraceV3 = serde_json::from_value(v).unwrap(); assert_eq!(round, trace); } #[test] fn default_fields_are_omitted() { - let step = Step::Click { + let step = StepV3::Click { common: sample_common(1, "s1", "s1"), - target: TargetDescriptor { + target: TargetDescriptorV3 { element_ref: Some("e1".into()), role: Some("button".into()), name: Some("OK".into()), @@ -552,9 +516,9 @@ mod tests { #[test] fn unmatched_target_serializes_flag() { - let step = Step::Click { + let step = StepV3::Click { common: sample_common(1, "s1", "s2"), - target: TargetDescriptor { + target: TargetDescriptorV3 { element_ref: None, role: Some("button".into()), name: Some("发布".into()), @@ -628,7 +592,7 @@ mod tests { "steps": [] }); assert!(RecordedTrace::classify_value(&unsupported).is_err()); - assert!(serde_json::from_value::(unsupported).is_err()); + assert!(serde_json::from_value::(unsupported).is_err()); } #[test] @@ -639,7 +603,7 @@ mod tests { .expect("RecordedTrace schema should use oneOf"); assert_eq!(variants.len(), 2); - let trace_schema = schema["definitions"]["Trace"].clone(); + let trace_schema = schema["definitions"]["TraceV3"].clone(); assert_eq!(trace_schema["properties"]["version"]["const"], 3); } @@ -654,8 +618,8 @@ mod tests { "entry": { "start_url": "https://example.com/editor" }, "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" } + { "id": "s1", "url": "https://example.com/editor", "body": "@vom 1" }, + { "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" } ], "steps": [ { @@ -676,7 +640,7 @@ mod tests { } ] }); - let trace: Trace = serde_json::from_value(v).unwrap(); + let trace: TraceV3 = serde_json::from_value(v).unwrap(); assert_eq!(trace.version, 3); assert_eq!(trace.states.len(), 2); assert_eq!(trace.steps.len(), 2); diff --git a/crates/bsk-protocol/src/tools/record_common.rs b/crates/bsk-protocol/src/tools/record_common.rs new file mode 100644 index 00000000..02617366 --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_common.rs @@ -0,0 +1,7 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TraceEntry { + pub start_url: String, +} diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs index 27f7f3f0..43b36e7e 100644 --- a/crates/bsk-protocol/src/tools/record_v2.rs +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -1,13 +1,12 @@ //! Trace v2 — record-only user-action log with `pages[]` and step `page` refs. //! -//! Legacy wire format: no top-level `version` field. New peers omit -//! `trace_version` on `record_start` to request this shape. +//! Legacy wire format with no top-level `version` field. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::interaction::KeyModifier; -use super::record::TraceEntry; +use super::record_common::TraceEntry; /// Stable semantic handle for an interacted element (v2). /// @@ -29,7 +28,7 @@ pub struct TargetDescriptorV2 { /// Page context dictionary entry — referenced by steps via `page` id. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct PageRef { +pub struct PageRefV2 { pub id: String, pub url: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -116,7 +115,7 @@ pub struct TraceV2 { #[serde(default, skip_serializing_if = "Option::is_none")] pub purpose: Option, pub entry: TraceEntry, - pub pages: Vec, + pub pages: Vec, pub steps: Vec, } @@ -153,7 +152,7 @@ mod tests { entry: TraceEntry { start_url: "https://example.com/".into(), }, - pages: vec![PageRef { + pages: vec![PageRefV2 { id: "p1".into(), url: "https://example.com/".into(), title: None, From 368507b07dd11bceff426cbe9b415359f6d997f7 Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Fri, 21 Aug 2026 11:37:20 +0800 Subject: [PATCH 5/7] fix(protocol): compatible with dto --- apps/extension/src/transport/types.ts | 1 - crates/bsk-protocol/src/tools/record.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 5659392b..b10425c7 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -662,7 +662,6 @@ export interface EmulateResult { export const TRACE_VERSION_V3 = 3; export const TRACE_VERSION_V2 = 2; -export const DEFAULT_TRACE_VERSION = 2; export const VOM_FORMAT_VERSION = 1; export interface TargetDescriptorV3 { diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index d4e862b4..fe7c7329 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -15,7 +15,6 @@ pub use super::record_v2::{ pub const TRACE_VERSION_V3: u32 = 3; pub const TRACE_VERSION_V2: u32 = 2; -pub const DEFAULT_TRACE_VERSION: u32 = 2; pub const VOM_FORMAT_VERSION: u32 = 1; fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result From e64f4cac9f0a20b599455f1cb5d6a849851e498a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Fri, 21 Aug 2026 18:19:57 +0800 Subject: [PATCH 6/7] fix(protocol): keep generic trace schema compatible with v2 and v3 Split standalone v2/v3 schemas and drop TraceV2 deny_unknown_fields so legacy traces with extra keys still parse; mix and version checks stay in RecordedTrace. --- .../schema/tool_record_await_result.json | 5 +- .../schema/tool_record_stop_result.json | 5 +- crates/bsk-protocol/schema/trace.json | 535 ++++++++++++++++-- crates/bsk-protocol/schema/trace_v2.json | 3 +- crates/bsk-protocol/schema/trace_v3.json | 496 ++++++++++++++++ crates/bsk-protocol/src/bin/dump-schema.rs | 3 +- crates/bsk-protocol/src/tools/mod.rs | 1 + crates/bsk-protocol/src/tools/record.rs | 462 +-------------- crates/bsk-protocol/src/tools/record_v2.rs | 28 +- crates/bsk-protocol/src/tools/record_v3.rs | 428 ++++++++++++++ 10 files changed, 1471 insertions(+), 495 deletions(-) create mode 100644 crates/bsk-protocol/schema/trace_v3.json create mode 100644 crates/bsk-protocol/src/tools/record_v3.rs diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index dee452f4..28b140e9 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -834,7 +834,7 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -874,8 +874,7 @@ "$ref": "#/definitions/StepV2" } } - }, - "additionalProperties": false + } }, "TraceV3": { "description": "Wire trace returned by `tool.record_stop` / `await`.", diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index 5d1e04a9..c1477d4f 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -834,7 +834,7 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -874,8 +874,7 @@ "$ref": "#/definitions/StepV2" } } - }, - "additionalProperties": false + } }, "TraceV3": { "description": "Wire trace returned by `tool.record_stop` / `await`.", diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 750ce6a7..4737d0ab 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,60 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "TraceV3", - "description": "Wire trace returned by `tool.record_stop` / `await`.", - "type": "object", - "required": [ - "entry", - "recorded_at", - "recorder", - "states", - "steps", - "stopped_by", - "version" - ], - "properties": { - "entry": { - "$ref": "#/definitions/TraceEntry" - }, - "purpose": { - "type": [ - "string", - "null" - ] - }, - "recorded_at": { - "type": "string" - }, - "recorder": { - "$ref": "#/definitions/RecorderInfo" - }, - "started_at": { - "type": [ - "string", - "null" - ] - }, - "states": { - "type": "array", - "items": { - "$ref": "#/definitions/TraceStateV3" - } - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/definitions/StepV3" - } - }, - "stopped_by": { - "$ref": "#/definitions/StopReason" + "title": "RecordedTrace", + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" }, - "version": { - "type": "integer", - "const": 3 + { + "$ref": "#/definitions/TraceV3" } - }, - "additionalProperties": false, + ], "definitions": { "FillCommit": { "type": "string", @@ -86,6 +40,28 @@ "browser" ] }, + "PageRefV2": { + "description": "Page context dictionary entry — referenced by steps via `page` id.", + "type": "object", + "required": [ + "id", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + } + }, "RecorderInfo": { "type": "object", "required": [ @@ -103,6 +79,24 @@ } } }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", @@ -121,6 +115,19 @@ } } }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, "StepResultV3": { "type": "object", "required": [ @@ -132,6 +139,287 @@ } } }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, "StepV3": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ @@ -418,6 +706,48 @@ "cli_stop" ] }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + }, "TargetDescriptorV3": { "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", @@ -491,6 +821,105 @@ "type": "string" } } + }, + "TraceV2": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRefV2" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + } + }, + "TraceV3": { + "description": "Wire trace returned by `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "type": "string" + }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV3" + } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "const": 3 + } + }, + "additionalProperties": false } } } diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json index 7db328c2..a9142503 100644 --- a/crates/bsk-protocol/schema/trace_v2.json +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "TraceV2", - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -42,7 +42,6 @@ } } }, - "additionalProperties": false, "definitions": { "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`).", diff --git a/crates/bsk-protocol/schema/trace_v3.json b/crates/bsk-protocol/schema/trace_v3.json new file mode 100644 index 00000000..750ce6a7 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_v3.json @@ -0,0 +1,496 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TraceV3", + "description": "Wire trace returned by `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "type": "string" + }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV3" + } + }, + "stopped_by": { + "$ref": "#/definitions/StopReason" + }, + "version": { + "type": "integer", + "const": 3 + } + }, + "additionalProperties": false, + "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", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "SelectedOptionV3": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "StepV3": { + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "cause", + "id", + "op", + "result", + "state", + "to" + ], + "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV3" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV3" + }, + { + "type": "null" + } + ] + } + } + }, + { + "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/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + } + } + } + ] + }, + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", + "type": "object", + "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "unmatched": { + "type": "boolean" + } + } + }, + "TraceEntry": { + "type": "object", + "required": [ + "start_url" + ], + "properties": { + "start_url": { + "type": "string" + } + } + }, + "TraceStateV3": { + "description": "Page observation dictionary entry — referenced by steps via `state` / `result.state`.", + "type": "object", + "required": [ + "body", + "id", + "url" + ], + "properties": { + "body": { + "description": "Full page observation (front matter + VOM body + annotations).", + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "truncated": { + "type": "boolean" + }, + "url": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 6e565fe4..9ec21e86 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -114,7 +114,8 @@ fn main() { dump!(RequestHelpResult, "tool_request_help_result"); dump!(TraceV2, "trace_v2"); - dump!(TraceV3, "trace"); + dump!(TraceV3, "trace_v3"); + dump!(RecordedTrace, "trace"); dump!(StepV3, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); dump!(RecordStartResult, "tool_record_start_result"); diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 11ab651d..88347b3e 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -11,6 +11,7 @@ pub mod observation; pub mod record; mod record_common; mod record_v2; +mod record_v3; pub mod script; pub mod session; pub mod tabs; diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index fe7c7329..cfb1b1ad 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -1,212 +1,22 @@ //! Semantic user-action recording (`tool.record_start` / `stop` / `await`). //! -//! Trace v3 is a **state-action-state** chain: each step binds to page -//! observations (VOM) captured before and after the action. +//! Wire traces are either Trace v2 (`pages[]`) or Trace v3 (`version: 3`, +//! `states[]`). Version-specific models live in `record_v2` / `record_v3`. use schemars::JsonSchema; -use serde::{Deserialize, Deserializer, Serialize}; - -use super::interaction::KeyModifier; +use serde::{Deserialize, Serialize}; pub use super::record_common::TraceEntry; pub use super::record_v2::{ PageRefV2, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, }; +pub use super::record_v3::{ + FillCommit, NavigationCause, RecorderInfo, SelectedOptionV3, StepCommonV3, StepResultV3, + StepV3, StopReason, TRACE_VERSION_V3, TargetDescriptorV3, TraceStateV3, TraceV3, + VOM_FORMAT_VERSION, +}; -pub const TRACE_VERSION_V3: u32 = 3; pub const TRACE_VERSION_V2: u32 = 2; -pub const VOM_FORMAT_VERSION: u32 = 1; - -fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let version = u32::deserialize(deserializer)?; - if version != TRACE_VERSION_V3 { - return Err(serde::de::Error::custom(format!( - "unsupported trace version {version} (expected {TRACE_VERSION_V3})" - ))); - } - Ok(version) -} - -fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { - schemars::schema::SchemaObject { - instance_type: Some(schemars::schema::InstanceType::Integer.into()), - const_value: Some(serde_json::json!(TRACE_VERSION_V3)), - ..Default::default() - } - .into() -} - -// --------------------------------------------------------------------------- -// Target -// --------------------------------------------------------------------------- - -/// Stable semantic handle for an interacted element within a page observation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TargetDescriptorV3 { - #[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ctx: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub unmatched: bool, -} - -// --------------------------------------------------------------------------- -// Trace v3 envelope -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -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 TraceStateV3 { - pub id: String, - pub url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - /// Full page observation (front matter + VOM body + annotations). - pub body: String, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepResultV3 { - pub state: String, -} - -/// Fields shared by every step variant (flattened in JSON). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepCommonV3 { - pub id: u32, - /// Observation id immediately before this action. - pub state: String, - pub result: StepResultV3, -} - -// --------------------------------------------------------------------------- -// Trace v3 step payloads -// --------------------------------------------------------------------------- - -/// One selected option (`select` op). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct SelectedOptionV3 { - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum NavigationCause { - UserTyped, - Link, - FormSubmit, - Reload, - History, - Script, - Browser, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum FillCommit { - Enter, - Suggestion, - Blur, -} - -/// One recorded user action — discriminated union by `op`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "op", rename_all = "snake_case")] -pub enum StepV3 { - Navigate { - #[serde(flatten)] - common: StepCommonV3, - to: String, - cause: NavigationCause, - }, - Click { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - }, - Hover { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - }, - Fill { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - value: String, - commit: FillCommit, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - redacted: bool, - }, - Select { - #[serde(flatten)] - common: StepCommonV3, - target: TargetDescriptorV3, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - selection: Vec, - }, - Press { - #[serde(flatten)] - common: StepCommonV3, - key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - modifiers: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - }, - Scroll { - #[serde(flatten)] - common: StepCommonV3, - }, -} - -// --------------------------------------------------------------------------- -// Trace v3 root -// --------------------------------------------------------------------------- - -/// Wire trace returned by `tool.record_stop` / `await`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TraceV3 { - #[serde(deserialize_with = "deserialize_trace_v3_version")] - #[schemars(schema_with = "trace_v3_version_schema")] - 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 recorder: RecorderInfo, - pub states: Vec, - pub steps: Vec, -} // --------------------------------------------------------------------------- // RPC params / results @@ -325,211 +135,11 @@ pub struct RecordAwaitResult { pub trace: RecordedTrace, } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; use serde_json::json; - fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommonV3 { - StepCommonV3 { - id, - state: state.into(), - result: StepResultV3 { - state: result_state.into(), - }, - } - } - - fn sample_target() -> TargetDescriptorV3 { - TargetDescriptorV3 { - element_ref: Some("e21".into()), - role: Some("button".into()), - name: Some("发布".into()), - ctx: Some("金桔柠檬 6 号".into()), - unmatched: false, - } - } - - fn sample_trace() -> TraceV3 { - TraceV3 { - version: TRACE_VERSION_V3, - 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://example.com/".into(), - }, - recorder: RecorderInfo { - bsk: "0.1.10".into(), - vom: VOM_FORMAT_VERSION, - }, - states: vec![ - TraceStateV3 { - id: "s1".into(), - url: "https://example.com/".into(), - title: Some("Example Domain".into()), - body: "@vom 1\nRootWebArea \"Example Domain\"".into(), - truncated: false, - }, - TraceStateV3 { - id: "s2".into(), - url: "https://shop.example.com/products?status=draft".into(), - title: Some("商品管理".into()), - body: "@vom 1\nRootWebArea \"商品管理\"".into(), - truncated: false, - }, - ], - steps: vec![ - StepV3::Navigate { - common: sample_common(1, "s1", "s2"), - to: "https://shop.example.com/products?status=draft".into(), - cause: NavigationCause::UserTyped, - }, - StepV3::Fill { - common: sample_common(2, "s2", "s2"), - target: TargetDescriptorV3 { - element_ref: Some("e12".into()), - role: Some("textbox".into()), - name: Some("搜索商品".into()), - ctx: None, - unmatched: false, - }, - value: "金桔柠檬".into(), - commit: FillCommit::Enter, - redacted: false, - }, - StepV3::Click { - common: sample_common(3, "s2", "s2"), - target: sample_target(), - }, - ], - } - } - - #[test] - fn step_click_round_trips() { - let step = StepV3::Click { - 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("state").and_then(|v| v.as_str()), Some("s1")); - assert_eq!(v["result"]["state"], "s2"); - assert_eq!(v["target"]["ref"], "e21"); - let round: StepV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); - } - - #[test] - fn step_fill_with_commit_round_trips() { - let step = StepV3::Fill { - common: sample_common(2, "s2", "s3"), - target: TargetDescriptorV3 { - element_ref: Some("e12".into()), - role: Some("textbox".into()), - name: Some("搜索商品".into()), - ctx: None, - unmatched: false, - }, - 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["commit"], "enter"); - assert!(v.get("redacted").is_none()); - let round: StepV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); - } - - #[test] - fn step_fill_password_is_redacted() { - let step = StepV3::Fill { - common: sample_common(1, "s1", "s1"), - target: TargetDescriptorV3 { - element_ref: Some("e3".into()), - role: Some("textbox".into()), - name: Some("密码".into()), - ctx: None, - unmatched: false, - }, - value: "***".into(), - commit: FillCommit::Blur, - redacted: true, - }; - let v = serde_json::to_value(&step).unwrap(); - assert_eq!(v["value"], "***"); - assert_eq!(v["redacted"], true); - } - - #[test] - fn step_navigate_with_cause_round_trips() { - let step = StepV3::Navigate { - 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["cause"], "user_typed"); - let round: StepV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); - } - - #[test] - fn trace_v3_round_trips() { - let trace = sample_trace(); - let v = serde_json::to_value(&trace).unwrap(); - 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: TraceV3 = serde_json::from_value(v).unwrap(); - assert_eq!(round, trace); - } - - #[test] - fn default_fields_are_omitted() { - let step = StepV3::Click { - common: sample_common(1, "s1", "s1"), - target: TargetDescriptorV3 { - 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("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 = StepV3::Click { - common: sample_common(1, "s1", "s2"), - target: TargetDescriptorV3 { - 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 recorded_trace_classifies_v2_and_v3() { let v2 = json!({ @@ -607,41 +217,29 @@ mod tests { } #[test] - fn extension_trace_deserializes() { - let v = json!({ - "version": 3, + fn standalone_trace_schema_is_recorded_trace_union() { + let schema = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); + assert_eq!(schema["title"], "RecordedTrace"); + let variants = schema["oneOf"] + .as_array() + .expect("standalone trace schema should be a v2|v3 oneOf"); + assert_eq!(variants.len(), 2); + assert!(schema["definitions"].get("TraceV2").is_some()); + assert!(schema["definitions"].get("TraceV3").is_some()); + } + + #[test] + fn recorded_trace_accepts_v2_with_unknown_extension_fields() { + let v2 = json!({ "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" }, - "recorder": { "bsk": "0.1.10", "vom": 1 }, - "states": [ - { "id": "s1", "url": "https://example.com/editor", "body": "@vom 1" }, - { "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" } - ], - "steps": [ - { - "op": "fill", - "id": 1, - "state": "s1", - "result": { "state": "s1" }, - "target": { "ref": "e1", "role": "textbox", "name": "标题" }, - "value": "hello", - "commit": "blur" - }, - { - "op": "click", - "id": 2, - "state": "s1", - "result": { "state": "s2" }, - "target": { "ref": "e2", "role": "button", "name": "发布" } - } - ] + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [], + "meta": { "tool": "legacy-exporter" } }); - let trace: TraceV3 = serde_json::from_value(v).unwrap(); - assert_eq!(trace.version, 3); - assert_eq!(trace.states.len(), 2); - assert_eq!(trace.steps.len(), 2); + match RecordedTrace::classify_value(&v2).unwrap() { + RecordedTrace::V2(trace) => assert_eq!(trace.pages.len(), 1), + other => panic!("expected v2, got {other:?}"), + } } } diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs index 43b36e7e..b75b55d6 100644 --- a/crates/bsk-protocol/src/tools/record_v2.rs +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -105,8 +105,10 @@ pub enum StepV2 { } /// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. +/// +/// Unknown extension fields are ignored so older traces remain readable. +/// Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct TraceV2 { /// RFC 3339 timestamp when recording stopped. pub recorded_at: String, @@ -222,4 +224,28 @@ mod tests { "hover" ); } + + #[test] + fn trace_v2_ignores_unknown_extension_fields() { + let value = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [], + "meta": { "tool": "legacy-exporter" } + }); + let trace: TraceV2 = serde_json::from_value(value).unwrap(); + assert_eq!(trace.pages.len(), 1); + assert!(trace.steps.is_empty()); + } + + #[test] + fn trace_v2_schema_allows_additional_properties() { + let schema = serde_json::to_value(schemars::schema_for!(TraceV2)).unwrap(); + assert_ne!( + schema.get("additionalProperties"), + Some(&serde_json::Value::Bool(false)), + "Trace v2 must keep accepting traces with unknown extension fields" + ); + } } diff --git a/crates/bsk-protocol/src/tools/record_v3.rs b/crates/bsk-protocol/src/tools/record_v3.rs new file mode 100644 index 00000000..bbdd97fe --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_v3.rs @@ -0,0 +1,428 @@ +//! Trace v3 — state-action-state log with `states[]` and step `state` refs. +//! +//! Wire format with top-level `version: 3`. + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::interaction::KeyModifier; +use super::record_common::TraceEntry; + +pub const TRACE_VERSION_V3: u32 = 3; +pub const VOM_FORMAT_VERSION: u32 = 1; + +fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let version = u32::deserialize(deserializer)?; + if version != TRACE_VERSION_V3 { + return Err(serde::de::Error::custom(format!( + "unsupported trace version {version} (expected {TRACE_VERSION_V3})" + ))); + } + Ok(version) +} + +fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + const_value: Some(serde_json::json!(TRACE_VERSION_V3)), + ..Default::default() + } + .into() +} + +/// Stable semantic handle for an interacted element within a page observation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetDescriptorV3 { + #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ctx: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unmatched: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +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 TraceStateV3 { + pub id: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Full page observation (front matter + VOM body + annotations). + pub body: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepResultV3 { + pub state: String, +} + +/// Fields shared by every step variant (flattened in JSON). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepCommonV3 { + pub id: u32, + /// Observation id immediately before this action. + pub state: String, + pub result: StepResultV3, +} + +/// One selected option (`select` op). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SelectedOptionV3 { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NavigationCause { + UserTyped, + Link, + FormSubmit, + Reload, + History, + Script, + Browser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FillCommit { + Enter, + Suggestion, + Blur, +} + +/// One recorded user action — discriminated union by `op`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum StepV3 { + Navigate { + #[serde(flatten)] + common: StepCommonV3, + to: String, + cause: NavigationCause, + }, + Click { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + }, + Hover { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + }, + Fill { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + value: String, + commit: FillCommit, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + redacted: bool, + }, + Select { + #[serde(flatten)] + common: StepCommonV3, + target: TargetDescriptorV3, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + selection: Vec, + }, + Press { + #[serde(flatten)] + common: StepCommonV3, + key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + modifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, + Scroll { + #[serde(flatten)] + common: StepCommonV3, + }, +} + +/// Wire trace returned by `tool.record_stop` / `await`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TraceV3 { + #[serde(deserialize_with = "deserialize_trace_v3_version")] + #[schemars(schema_with = "trace_v3_version_schema")] + 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 recorder: RecorderInfo, + pub states: Vec, + pub steps: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommonV3 { + StepCommonV3 { + id, + state: state.into(), + result: StepResultV3 { + state: result_state.into(), + }, + } + } + + fn sample_target() -> TargetDescriptorV3 { + TargetDescriptorV3 { + element_ref: Some("e21".into()), + role: Some("button".into()), + name: Some("发布".into()), + ctx: Some("金桔柠檬 6 号".into()), + unmatched: false, + } + } + + fn sample_trace() -> TraceV3 { + TraceV3 { + version: TRACE_VERSION_V3, + 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://example.com/".into(), + }, + recorder: RecorderInfo { + bsk: "0.1.10".into(), + vom: VOM_FORMAT_VERSION, + }, + states: vec![ + TraceStateV3 { + id: "s1".into(), + url: "https://example.com/".into(), + title: Some("Example Domain".into()), + body: "@vom 1\nRootWebArea \"Example Domain\"".into(), + truncated: false, + }, + TraceStateV3 { + id: "s2".into(), + url: "https://shop.example.com/products?status=draft".into(), + title: Some("商品管理".into()), + body: "@vom 1\nRootWebArea \"商品管理\"".into(), + truncated: false, + }, + ], + steps: vec![ + StepV3::Navigate { + common: sample_common(1, "s1", "s2"), + to: "https://shop.example.com/products?status=draft".into(), + cause: NavigationCause::UserTyped, + }, + StepV3::Fill { + common: sample_common(2, "s2", "s2"), + target: TargetDescriptorV3 { + element_ref: Some("e12".into()), + role: Some("textbox".into()), + name: Some("搜索商品".into()), + ctx: None, + unmatched: false, + }, + value: "金桔柠檬".into(), + commit: FillCommit::Enter, + redacted: false, + }, + StepV3::Click { + common: sample_common(3, "s2", "s2"), + target: sample_target(), + }, + ], + } + } + + #[test] + fn step_click_round_trips() { + let step = StepV3::Click { + 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("state").and_then(|v| v.as_str()), Some("s1")); + assert_eq!(v["result"]["state"], "s2"); + assert_eq!(v["target"]["ref"], "e21"); + let round: StepV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, step); + } + + #[test] + fn step_fill_with_commit_round_trips() { + let step = StepV3::Fill { + common: sample_common(2, "s2", "s3"), + target: TargetDescriptorV3 { + element_ref: Some("e12".into()), + role: Some("textbox".into()), + name: Some("搜索商品".into()), + ctx: None, + unmatched: false, + }, + 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["commit"], "enter"); + assert!(v.get("redacted").is_none()); + let round: StepV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, step); + } + + #[test] + fn step_fill_password_is_redacted() { + let step = StepV3::Fill { + common: sample_common(1, "s1", "s1"), + target: TargetDescriptorV3 { + element_ref: Some("e3".into()), + role: Some("textbox".into()), + name: Some("密码".into()), + ctx: None, + unmatched: false, + }, + value: "***".into(), + commit: FillCommit::Blur, + redacted: true, + }; + let v = serde_json::to_value(&step).unwrap(); + assert_eq!(v["value"], "***"); + assert_eq!(v["redacted"], true); + } + + #[test] + fn step_navigate_with_cause_round_trips() { + let step = StepV3::Navigate { + 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["cause"], "user_typed"); + let round: StepV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, step); + } + + #[test] + fn trace_v3_round_trips() { + let trace = sample_trace(); + let v = serde_json::to_value(&trace).unwrap(); + 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: TraceV3 = serde_json::from_value(v).unwrap(); + assert_eq!(round, trace); + } + + #[test] + fn default_fields_are_omitted() { + let step = StepV3::Click { + common: sample_common(1, "s1", "s1"), + target: TargetDescriptorV3 { + 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("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 = StepV3::Click { + common: sample_common(1, "s1", "s2"), + target: TargetDescriptorV3 { + 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" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [ + { "id": "s1", "url": "https://example.com/editor", "body": "@vom 1" }, + { "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" } + ], + "steps": [ + { + "op": "fill", + "id": 1, + "state": "s1", + "result": { "state": "s1" }, + "target": { "ref": "e1", "role": "textbox", "name": "标题" }, + "value": "hello", + "commit": "blur" + }, + { + "op": "click", + "id": 2, + "state": "s1", + "result": { "state": "s2" }, + "target": { "ref": "e2", "role": "button", "name": "发布" } + } + ] + }); + let trace: TraceV3 = serde_json::from_value(v).unwrap(); + assert_eq!(trace.version, 3); + assert_eq!(trace.states.len(), 2); + assert_eq!(trace.steps.len(), 2); + } +} From c2a225377b7903a30757a74359f9d81af9a58847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Sat, 22 Aug 2026 16:59:52 +0800 Subject: [PATCH 7/7] fix(protocol): align trace schemas with v2/v3 classify rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordedTrace validation now follows classify_value — numeric version selects v3, otherwise v2 — so mixed pages/states and v2 envelopes with a version field fail Schema checks before Recall import. Keep standalone trace_step as a v2|v3 union for tools that validate a single step. --- apps/extension/src/transport/types.ts | 2 + .../schema/tool_record_await_result.json | 32 +- .../schema/tool_record_stop_result.json | 32 +- crates/bsk-protocol/schema/trace.json | 32 +- crates/bsk-protocol/schema/trace_step.json | 901 +++++++++++++----- crates/bsk-protocol/schema/trace_step_v2.json | 368 +++++++ crates/bsk-protocol/schema/trace_step_v3.json | 375 ++++++++ crates/bsk-protocol/schema/trace_v2.json | 9 +- crates/bsk-protocol/src/bin/dump-schema.rs | 4 +- crates/bsk-protocol/src/tools/record.rs | 238 ++++- crates/bsk-protocol/src/tools/record_v2.rs | 83 +- 11 files changed, 1770 insertions(+), 306 deletions(-) create mode 100644 crates/bsk-protocol/schema/trace_step_v2.json create mode 100644 crates/bsk-protocol/schema/trace_step_v3.json diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index b10425c7..c72a44c1 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -661,6 +661,7 @@ export interface EmulateResult { // -------------------------------------------------------------------------- export const TRACE_VERSION_V3 = 3; +/** Logical v2 identifier. Not a wire field — v2 envelopes omit `version`. */ export const TRACE_VERSION_V2 = 2; export const VOM_FORMAT_VERSION = 1; @@ -853,6 +854,7 @@ export interface TraceV3 { } export type RecordedTrace = TraceV2 | TraceV3; +export type RecordedStep = StepV2 | StepV3; export interface RecordStartParams { session_id: string; diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index 28b140e9..df2a122a 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -64,14 +64,22 @@ } }, "RecordedTrace": { - "oneOf": [ - { - "$ref": "#/definitions/TraceV2" - }, - { - "$ref": "#/definitions/TraceV3" + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } } - ] + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + } }, "RecorderInfo": { "type": "object", @@ -834,7 +842,8 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -868,11 +877,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } } }, diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index c1477d4f..c1dfef02 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -64,14 +64,22 @@ } }, "RecordedTrace": { - "oneOf": [ - { - "$ref": "#/definitions/TraceV2" - }, - { - "$ref": "#/definitions/TraceV3" + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } } - ] + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + } }, "RecorderInfo": { "type": "object", @@ -834,7 +842,8 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -868,11 +877,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } } }, diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 4737d0ab..664e035e 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,14 +1,22 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "RecordedTrace", - "oneOf": [ - { - "$ref": "#/definitions/TraceV2" - }, - { - "$ref": "#/definitions/TraceV3" + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } } - ], + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + }, "definitions": { "FillCommit": { "type": "string", @@ -823,7 +831,8 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -857,11 +866,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } } }, diff --git a/crates/bsk-protocol/schema/trace_step.json b/crates/bsk-protocol/schema/trace_step.json index 14a33ee1..ae749428 100644 --- a/crates/bsk-protocol/schema/trace_step.json +++ b/crates/bsk-protocol/schema/trace_step.json @@ -1,339 +1,712 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "StepV3", - "description": "One recorded user action — discriminated union by `op`.", - "oneOf": [ - { - "description": "Fields shared by every step variant (flattened in JSON).", + "title": "RecordedStep", + "not": { + "required": [ + "page", + "state" + ] + }, + "if": { + "required": [ + "state" + ] + }, + "then": { + "$ref": "#/definitions/StepV3" + }, + "else": { + "$ref": "#/definitions/StepV2" + }, + "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", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", "type": "object", "required": [ - "cause", - "id", - "op", - "result", - "state", - "to" + "value" ], "properties": { - "cause": { - "$ref": "#/definitions/NavigationCause" - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "navigate" + "label": { + "type": [ + "string", + "null" ] }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "to": { + "value": { "type": "string" } } }, - { - "description": "Fields shared by every step variant (flattened in JSON).", + "SelectedOptionV3": { + "description": "One selected option (`select` op).", "type": "object", "required": [ - "id", - "op", - "result", - "state", - "target" + "value" ], "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "click" + "label": { + "type": [ + "string", + "null" ] }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", + "value": { "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" } } }, - { - "description": "Fields shared by every step variant (flattened in JSON).", + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", "type": "object", "required": [ - "id", - "op", - "result", - "state", - "target" + "navigated_to" ], "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "hover" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" } } }, - { - "description": "Fields shared by every step variant (flattened in JSON).", + "StepResultV3": { "type": "object", "required": [ - "commit", - "id", - "op", - "result", - "state", - "target", - "value" + "state" ], "properties": { - "commit": { - "$ref": "#/definitions/FillCommit" - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "fill" - ] - }, - "redacted": { - "type": "boolean" - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" - }, - "value": { "type": "string" } } }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "result", - "state", - "target" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } }, - "op": { - "type": "string", - "enum": [ - "select" - ] + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } }, - "result": { - "$ref": "#/definitions/StepResultV3" + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } }, - "selection": { - "type": "array", - "items": { - "$ref": "#/definitions/SelectedOptionV3" + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } } }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } } - } + ] }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "key", - "op", - "result", - "state" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 + "StepV3": { + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "cause", + "id", + "op", + "result", + "state", + "to" + ], + "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } }, - "key": { - "type": "string" + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } }, - "modifiers": { - "type": [ - "array", - "null" + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" ], - "items": { - "$ref": "#/definitions/KeyModifier" + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } } }, - "op": { - "type": "string", - "enum": [ - "press" - ] + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + }, + "value": { + "type": "string" + } + } }, - "result": { - "$ref": "#/definitions/StepResultV3" + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV3" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV3" + }, + { + "type": "null" + } + ] + } + } }, - "target": { - "anyOf": [ - { - "$ref": "#/definitions/TargetDescriptorV3" + { + "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 }, - { - "type": "null" + "op": { + "type": "string", + "enum": [ + "scroll" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" } - ] + } } - } + ] }, - { - "description": "Fields shared by every step variant (flattened in JSON).", + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", "required": [ - "id", - "op", - "result", - "state" + "tag" ], "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 + "name": { + "type": [ + "string", + "null" + ] }, - "op": { - "type": "string", - "enum": [ - "scroll" + "name_attr": { + "type": [ + "string", + "null" ] }, - "result": { - "$ref": "#/definitions/StepResultV3" + "nearby_label": { + "type": [ + "string", + "null" + ] }, - "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", - "enum": [ - "alt", - "ctrl", - "meta", - "shift" - ] - }, - "NavigationCause": { - "type": "string", - "enum": [ - "user_typed", - "link", - "form_submit", - "reload", - "history", - "script", - "browser" - ] - }, - "SelectedOptionV3": { - "description": "One selected option (`select` op).", - "type": "object", - "required": [ - "value" - ], - "properties": { - "label": { + "placeholder": { "type": [ "string", "null" ] }, - "value": { - "type": "string" - } - } - }, - "StepResultV3": { - "type": "object", - "required": [ - "state" - ], - "properties": { - "state": { + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { "type": "string" } } diff --git a/crates/bsk-protocol/schema/trace_step_v2.json b/crates/bsk-protocol/schema/trace_step_v2.json new file mode 100644 index 00000000..2d11f980 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_step_v2.json @@ -0,0 +1,368 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StepV2", + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ], + "definitions": { + "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", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/trace_step_v3.json b/crates/bsk-protocol/schema/trace_step_v3.json new file mode 100644 index 00000000..14a33ee1 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_step_v3.json @@ -0,0 +1,375 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StepV3", + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "cause", + "id", + "op", + "result", + "state", + "to" + ], + "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV3" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV3" + }, + { + "type": "null" + } + ] + } + } + }, + { + "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/StepResultV3" + }, + "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", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "SelectedOptionV3": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", + "type": "object", + "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "unmatched": { + "type": "boolean" + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json index a9142503..bd1081ca 100644 --- a/crates/bsk-protocol/schema/trace_v2.json +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "TraceV2", - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -35,11 +35,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } }, "definitions": { diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 9ec21e86..e1f4e6d6 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -116,7 +116,9 @@ fn main() { dump!(TraceV2, "trace_v2"); dump!(TraceV3, "trace_v3"); dump!(RecordedTrace, "trace"); - dump!(StepV3, "trace_step"); + dump!(StepV2, "trace_step_v2"); + dump!(StepV3, "trace_step_v3"); + dump!(RecordedStep, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); dump!(RecordStartResult, "tool_record_start_result"); dump!(RecordStopParams, "tool_record_stop_params"); diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index cfb1b1ad..d18eca1e 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -16,6 +16,7 @@ pub use super::record_v3::{ VOM_FORMAT_VERSION, }; +/// Logical v2 identifier. Not a wire field — v2 envelopes omit `version`. pub const TRACE_VERSION_V2: u32 = 2; // --------------------------------------------------------------------------- @@ -45,6 +46,10 @@ pub struct RecordStopParams { } /// Wire trace payload — v2 (legacy `pages[]`) or v3 (`version: 3`, `states[]`). +/// +/// Classification matches [`RecordedTrace::classify_value`]: a numeric `version` +/// selects v3 (and forbids `pages[]`); otherwise the envelope is v2 and must +/// not include `states[]`. #[derive(Debug, Clone, PartialEq)] pub enum RecordedTrace { V2(TraceV2), @@ -104,12 +109,134 @@ impl JsonSchema for RecordedTrace { } fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + // Mirror classify_value: a numeric `version` selects v3; otherwise v2. + // TraceV2 stays open for unknown extension fields but forbids `states[]` + // and a numeric `version`. TraceV3 already denies `pages[]`. + let mut version_props = schemars::Map::new(); + version_props.insert( + "version".into(), + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + ..Default::default() + } + .into(), + ); + let mut required = schemars::Set::new(); + required.insert("version".into()); + schemars::schema::SchemaObject { subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - one_of: Some(vec![ - generator.subschema_for::(), - generator.subschema_for::(), - ]), + if_schema: Some(Box::new( + schemars::schema::SchemaObject { + object: Some(Box::new(schemars::schema::ObjectValidation { + properties: version_props, + required, + ..Default::default() + })), + ..Default::default() + } + .into(), + )), + then_schema: Some(Box::new(generator.subschema_for::())), + else_schema: Some(Box::new(generator.subschema_for::())), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +/// One recorded step — v2 (`page`) or v3 (`state` / `result.state`). +/// +/// Classification matches [`RecordedStep::classify_value`]: `state` selects v3 +/// (and forbids `page`); otherwise the step is v2 and must include `page`. +#[derive(Debug, Clone, PartialEq)] +pub enum RecordedStep { + V2(StepV2), + V3(StepV3), +} + +impl RecordedStep { + pub fn classify_value(v: &serde_json::Value) -> Result { + if v.get("state").is_some() { + if v.get("page").is_some() { + return Err("step v3 must not include legacy page".into()); + } + return serde_json::from_value(v.clone()) + .map(RecordedStep::V3) + .map_err(|e| e.to_string()); + } + if v.get("page").is_some() { + return serde_json::from_value(v.clone()) + .map(RecordedStep::V2) + .map_err(|e| e.to_string()); + } + Err("ambiguous or unparseable step".into()) + } +} + +impl Serialize for RecordedStep { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + RecordedStep::V2(s) => s.serialize(serializer), + RecordedStep::V3(s) => s.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RecordedStep { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + Self::classify_value(&value).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for RecordedStep { + fn schema_name() -> String { + "RecordedStep".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + // Mirror classify_value: `state` selects v3; otherwise v2. + // Mixed page + state is rejected at this layer because StepV2/StepV3 + // still allow unknown extension fields. + let mut required = schemars::Set::new(); + required.insert("state".into()); + let mut mixed_keys = schemars::Set::new(); + mixed_keys.insert("page".into()); + mixed_keys.insert("state".into()); + + schemars::schema::SchemaObject { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + if_schema: Some(Box::new( + schemars::schema::SchemaObject { + object: Some(Box::new(schemars::schema::ObjectValidation { + required, + ..Default::default() + })), + ..Default::default() + } + .into(), + )), + then_schema: Some(Box::new(generator.subschema_for::())), + else_schema: Some(Box::new(generator.subschema_for::())), + not: Some(Box::new( + schemars::schema::SchemaObject { + object: Some(Box::new(schemars::schema::ObjectValidation { + required: mixed_keys, + ..Default::default() + })), + ..Default::default() + } + .into(), + )), ..Default::default() })), ..Default::default() @@ -204,26 +331,43 @@ mod tests { assert!(serde_json::from_value::(unsupported).is_err()); } + fn assert_classifies_by_integer_version(schema: &serde_json::Value) { + assert_eq!( + schema["if"]["required"], + json!(["version"]), + "numeric version is the RecordedTrace classification key" + ); + assert_eq!(schema["if"]["properties"]["version"]["type"], "integer"); + assert_eq!(schema["then"]["$ref"], "#/definitions/TraceV3"); + assert_eq!(schema["else"]["$ref"], "#/definitions/TraceV2"); + } + #[test] fn recorded_trace_schema_includes_v2_and_v3() { let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); - let variants = schema["definitions"]["RecordedTrace"]["oneOf"] - .as_array() - .expect("RecordedTrace schema should use oneOf"); + assert_classifies_by_integer_version(&schema["definitions"]["RecordedTrace"]); + assert_eq!( + schema["definitions"]["TraceV3"]["properties"]["version"]["const"], + 3 + ); + } + + #[test] + fn recorded_trace_schema_follows_classify_value() { + let standalone = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); + assert_classifies_by_integer_version(&standalone); + assert!(standalone["definitions"].get("TraceV2").is_some()); + assert!(standalone["definitions"].get("TraceV3").is_some()); - assert_eq!(variants.len(), 2); - let trace_schema = schema["definitions"]["TraceV3"].clone(); - assert_eq!(trace_schema["properties"]["version"]["const"], 3); + let stop_result = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + assert_classifies_by_integer_version(&stop_result["definitions"]["RecordedTrace"]); } #[test] fn standalone_trace_schema_is_recorded_trace_union() { let schema = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); assert_eq!(schema["title"], "RecordedTrace"); - let variants = schema["oneOf"] - .as_array() - .expect("standalone trace schema should be a v2|v3 oneOf"); - assert_eq!(variants.len(), 2); + assert_classifies_by_integer_version(&schema); assert!(schema["definitions"].get("TraceV2").is_some()); assert!(schema["definitions"].get("TraceV3").is_some()); } @@ -242,4 +386,70 @@ mod tests { other => panic!("expected v2, got {other:?}"), } } + + fn v2_click() -> serde_json::Value { + json!({ + "op": "click", + "id": 1, + "page": "p1", + "target": { "tag": "button", "role": "button", "name": "发布" } + }) + } + + fn v3_click() -> serde_json::Value { + json!({ + "op": "click", + "id": 1, + "state": "s1", + "result": { "state": "s2" }, + "target": { "ref": "e1", "role": "button", "name": "发布" } + }) + } + + fn assert_step_classifies_by_state(schema: &serde_json::Value) { + assert_eq!( + schema["if"]["required"], + json!(["state"]), + "state is the RecordedStep classification key" + ); + assert_eq!(schema["then"]["$ref"], "#/definitions/StepV3"); + assert_eq!(schema["else"]["$ref"], "#/definitions/StepV2"); + let mut forbidden = schema["not"]["required"] + .as_array() + .expect("RecordedStep schema must reject mixed page + state steps") + .iter() + .filter_map(|v| v.as_str()) + .collect::>(); + forbidden.sort_unstable(); + assert_eq!(forbidden, ["page", "state"]); + } + + #[test] + fn recorded_step_classifies_v2_and_v3() { + match RecordedStep::classify_value(&v2_click()).unwrap() { + RecordedStep::V2(StepV2::Click { .. }) => {} + other => panic!("expected v2 click, got {other:?}"), + } + match RecordedStep::classify_value(&v3_click()).unwrap() { + RecordedStep::V3(StepV3::Click { .. }) => {} + other => panic!("expected v3 click, got {other:?}"), + } + } + + #[test] + fn recorded_step_rejects_mixed_page_and_state() { + let mut mixed = v2_click(); + mixed["state"] = json!("s1"); + mixed["result"] = json!({ "state": "s2" }); + assert!(RecordedStep::classify_value(&mixed).is_err()); + } + + #[test] + fn recorded_step_schema_follows_classify_value() { + let schema = serde_json::to_value(schemars::schema_for!(RecordedStep)).unwrap(); + assert_eq!(schema["title"], "RecordedStep"); + assert_step_classifies_by_state(&schema); + assert!(schema["definitions"].get("StepV2").is_some()); + assert!(schema["definitions"].get("StepV3").is_some()); + } } diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs index b75b55d6..23923815 100644 --- a/crates/bsk-protocol/src/tools/record_v2.rs +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -107,8 +107,9 @@ pub enum StepV2 { /// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. /// /// Unknown extension fields are ignored so older traces remain readable. -/// Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +/// `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` +/// classification — serde still ignores them, but the JSON Schema rejects them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TraceV2 { /// RFC 3339 timestamp when recording stopped. pub recorded_at: String, @@ -121,6 +122,70 @@ pub struct TraceV2 { pub steps: Vec, } +fn constrain_trace_v2_schema(mut schema: schemars::schema::Schema) -> schemars::schema::Schema { + let schemars::schema::Schema::Object(obj) = &mut schema else { + return schema; + }; + obj.metadata().title = Some("TraceV2".into()); + obj.metadata().description = Some( + "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\n\ + Unknown extension fields are ignored so older traces remain readable. \ + `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification." + .into(), + ); + + let object = obj.object.get_or_insert_with(Default::default); + object + .properties + .insert("states".into(), schemars::schema::Schema::Bool(false)); + object.properties.insert( + "version".into(), + schemars::schema::SchemaObject { + metadata: Some(Box::new(schemars::schema::Metadata { + description: Some( + "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.".into(), + ), + ..Default::default() + })), + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + not: Some(Box::new( + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + ..Default::default() + } + .into(), + )), + ..Default::default() + })), + ..Default::default() + } + .into(), + ); + schema +} + +impl JsonSchema for TraceV2 { + fn schema_name() -> String { + "TraceV2".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + #[derive(JsonSchema)] + #[allow(dead_code)] + struct TraceV2Shape { + /// RFC 3339 timestamp when recording stopped. + recorded_at: String, + started_at: Option, + purpose: Option, + entry: TraceEntry, + pages: Vec, + steps: Vec, + } + + constrain_trace_v2_schema(TraceV2Shape::json_schema(generator)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -248,4 +313,18 @@ mod tests { "Trace v2 must keep accepting traces with unknown extension fields" ); } + + #[test] + fn trace_v2_schema_forbids_classification_keys() { + let schema = serde_json::to_value(schemars::schema_for!(TraceV2)).unwrap(); + assert_eq!( + schema["properties"]["states"], + json!(false), + "Trace v2 schema must reject states[] (reserved for v3)" + ); + assert_eq!( + schema["properties"]["version"]["not"]["type"], "integer", + "Trace v2 schema must reject a numeric version (that selects the v3 path)" + ); + } }