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/types.ts b/apps/extension/src/transport/types.ts index d53825a1..c72a44c1 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -657,10 +657,60 @@ export interface EmulateResult { } // -------------------------------------------------------------------------- -// Semantic record payloads — mirror bsk-protocol record.rs (Trace v2) +// Semantic record payloads mirror the versioned Rust protocol models. // -------------------------------------------------------------------------- -export interface TargetDescriptor { +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; + +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 TraceStateV3 { + id: string; + url: string; + title?: string; + body: string; + truncated?: boolean; +} + +export interface StepResultV3 { + state: string; +} + +export interface StepCommonV3 { + id: number; + state: string; + result: StepResultV3; +} + +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 TargetDescriptorV2 { role?: string; name?: string; tag: string; @@ -673,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; @@ -717,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; @@ -737,34 +787,75 @@ 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 interface Trace { +export interface TraceV2 { + recorded_at: string; + started_at?: string; + purpose?: string; + entry: TraceEntry; + pages: PageRefV2[]; + steps: StepV2[]; +} + +export interface SelectedOptionV3 { + value: string; + label?: string; +} + +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?: SelectedOptionV3[]; + }) + | ({ op: "press" } & StepCommonV3 & { + key: string; + modifiers?: KeyModifier[]; + target?: TargetDescriptorV3; + }) + | ({ op: "scroll" } & StepCommonV3); + +export interface TraceV3 { + version: typeof TRACE_VERSION_V3; recorded_at: string; started_at?: string; purpose?: string; + stopped_by: StopReason; entry: TraceEntry; - pages: PageRef[]; - steps: Step[]; + recorder: RecorderInfo; + states: TraceStateV3[]; + steps: StepV3[]; } +export type RecordedTrace = TraceV2 | TraceV3; +export type RecordedStep = StepV2 | StepV3; + export interface RecordStartParams { session_id: string; tab_id?: number; @@ -782,7 +873,7 @@ export interface RecordStopParams { } export interface RecordStopResult { - trace: Trace; + trace: RecordedTrace; } export interface RecordAwaitParams { @@ -791,5 +882,5 @@ export interface RecordAwaitParams { } export interface RecordAwaitResult { - trace: Trace; + trace: RecordedTrace; } diff --git a/crates/bsk-cli/src/cli/record.rs b/crates/bsk-cli/src/cli/record.rs index ec72d9a2..8cc66dba 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}; @@ -190,7 +190,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 +207,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 +221,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-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index 348edc8d..df2a122a 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,7 +29,19 @@ "shift" ] }, - "PageRef": { + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "PageRefV2": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", "required": [ @@ -43,7 +63,42 @@ } } }, - "SelectedOption": { + "RecordedTrace": { + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } + } + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + } + }, + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "SelectedOptionV2": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -61,11 +116,53 @@ } } }, - "Step": { - "description": "One recorded user action — discriminated union by `op`.", + "SelectedOptionV3": { + "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" + } + } + }, + "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": [ "id", @@ -77,7 +174,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -105,7 +202,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", @@ -117,7 +214,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -140,12 +237,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", @@ -157,7 +254,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -180,12 +277,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 +295,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -227,7 +324,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -235,7 +332,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 +345,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -273,16 +370,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 +391,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -331,7 +428,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -342,21 +439,294 @@ } ] }, - "StepEffect": { - "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" + "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" + ] }, - "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" @@ -397,8 +767,83 @@ } } }, - "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", + "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" + } + } + }, + "TraceV2": { + "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", @@ -413,11 +858,10 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" @@ -433,25 +877,76 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { - "$ref": "#/definitions/Step" + "$ref": "#/definitions/StepV2" + } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" } } } }, - "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", + "TraceV3": { + "description": "Wire trace returned by `tool.record_stop` / `await`.", "type": "object", "required": [ - "start_url" + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" ], "properties": { - "start_url": { + "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_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index c46ba691..c1dfef02 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,7 +29,19 @@ "shift" ] }, - "PageRef": { + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "PageRefV2": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", "required": [ @@ -43,7 +63,42 @@ } } }, - "SelectedOption": { + "RecordedTrace": { + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } + } + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + } + }, + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "SelectedOptionV2": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -61,11 +116,53 @@ } } }, - "Step": { - "description": "One recorded user action — discriminated union by `op`.", + "SelectedOptionV3": { + "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" + } + } + }, + "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": [ "id", @@ -77,7 +174,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -105,7 +202,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", @@ -117,7 +214,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -140,12 +237,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", @@ -157,7 +254,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -180,12 +277,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 +295,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -227,7 +324,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -235,7 +332,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 +345,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -273,16 +370,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 +391,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -331,7 +428,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -342,21 +439,294 @@ } ] }, - "StepEffect": { - "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" + "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" + ] }, - "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" @@ -397,8 +767,83 @@ } } }, - "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", + "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" + } + } + }, + "TraceV2": { + "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", @@ -413,11 +858,10 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" @@ -433,25 +877,76 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { - "$ref": "#/definitions/Step" + "$ref": "#/definitions/StepV2" + } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" } } } }, - "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", + "TraceV3": { + "description": "Wire trace returned by `tool.record_stop` / `await`.", "type": "object", "required": [ - "start_url" + "entry", + "recorded_at", + "recorder", + "states", + "steps", + "stopped_by", + "version" ], "properties": { - "start_url": { + "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 0a31d67f..664e035e 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,49 +1,31 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Trace", - "description": "Persisted user-action trace exported by `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": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", - "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/Step" + "title": "RecordedTrace", + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" } } }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + }, "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,7 +36,19 @@ "shift" ] }, - "PageRef": { + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "PageRefV2": { "description": "Page context dictionary entry — referenced by steps via `page` id.", "type": "object", "required": [ @@ -76,7 +70,24 @@ } } }, - "SelectedOption": { + "RecorderInfo": { + "type": "object", + "required": [ + "bsk", + "vom" + ], + "properties": { + "bsk": { + "type": "string" + }, + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "SelectedOptionV2": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -94,11 +105,53 @@ } } }, - "Step": { - "description": "One recorded user action — discriminated union by `op`.", + "SelectedOptionV3": { + "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" + } + } + }, + "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": [ "id", @@ -110,7 +163,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -138,7 +191,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", @@ -150,7 +203,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -173,12 +226,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", @@ -190,7 +243,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -213,12 +266,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", @@ -231,7 +284,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -260,7 +313,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -268,7 +321,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", @@ -281,7 +334,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -306,16 +359,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", @@ -327,7 +380,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -364,7 +417,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -375,21 +428,294 @@ } ] }, - "StepEffect": { - "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" + "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" + ] }, - "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" @@ -430,8 +756,40 @@ } } }, + "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": { - "description": "Recording entry point — first URL the flow starts from.", "type": "object", "required": [ "start_url" @@ -441,6 +799,143 @@ "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" + } + } + }, + "TraceV2": { + "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", + "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" + ] + }, + "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" + } + } + } + }, + "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_step.json b/crates/bsk-protocol/schema/trace_step.json index 69dbec90..ae749428 100644 --- a/crates/bsk-protocol/schema/trace_step.json +++ b/crates/bsk-protocol/schema/trace_step.json @@ -1,329 +1,676 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Step", - "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": [ - "id", - "op", - "page", - "to" + "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "navigate" + "label": { + "type": [ + "string", + "null" ] }, - "page": { - "description": "Reference into `pages[]`.", - "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", - "page", - "target" + "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "click" + "label": { + "type": [ + "string", + "null" ] }, - "page": { - "description": "Reference into `pages[]`.", + "value": { "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptor" } } }, - { - "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", - "page", - "target" + "navigated_to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "hover" - ] - }, - "page": { - "description": "Reference into `pages[]`.", + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptor" } } }, - { - "description": "Fields shared by every step variant (flattened in JSON).", + "StepResultV3": { "type": "object", "required": [ - "id", - "op", - "page", - "target", - "value" + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "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/TargetDescriptor" - }, - "value": { + "state": { "type": "string" } } }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "page", - "selection", - "target" - ], - "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" + "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 }, - { - "type": "null" + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" } - ] + } }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 + { + "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" + } + } }, - "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": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + { + "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" + } + } }, - "selection": { - "type": "array", - "items": { - "$ref": "#/definitions/SelectedOption" + { + "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/TargetDescriptor" + { + "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", - "page" - ], - "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" + "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" }, - { - "type": "null" + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" } - ] + } }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 + { + "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" + } + } }, - "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": [ + "hover" + ] + }, + "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": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" ], - "items": { - "$ref": "#/definitions/KeyModifier" + "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" + } } }, - "op": { - "type": "string", - "enum": [ - "press" - ] + { + "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" + } + } }, - "page": { - "description": "Reference into `pages[]`.", - "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/TargetDescriptor" + { + "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" } - ] + } } - } - } - ], - "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" ] }, - "SelectedOption": { - "description": "One selected option (`select` op).", - "type": "object", - "required": [ - "value" - ], - "properties": { - "label": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - } - }, - "StepEffect": { - "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" - } - } - }, - "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" @@ -363,6 +710,39 @@ "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_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 new file mode 100644 index 00000000..bd1081ca --- /dev/null +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -0,0 +1,451 @@ +{ + "$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. `states[]` and a numeric `version` are reserved for Trace v3 / `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" + ] + }, + "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": { + "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" + ] + }, + "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" + } + } + }, + "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" + ], + "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" + } + ] + } + } + } + ] + }, + "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": { + "type": "object", + "required": [ + "start_url" + ], + "properties": { + "start_url": { + "type": "string" + } + } + } + } +} 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 de9b7f25..e1f4e6d6 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -113,8 +113,12 @@ fn main() { dump!(RequestHelpParams, "tool_request_help_params"); dump!(RequestHelpResult, "tool_request_help_result"); - dump!(Trace, "trace"); - dump!(Step, "trace_step"); + dump!(TraceV2, "trace_v2"); + dump!(TraceV3, "trace_v3"); + dump!(RecordedTrace, "trace"); + 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/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index a9c04dbb..88347b3e 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -9,6 +9,9 @@ pub mod navigation; pub mod network; 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 6dc2e097..d18eca1e 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -1,177 +1,253 @@ //! 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. +//! 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, Serialize}; -use super::interaction::KeyModifier; +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, +}; + +/// Logical v2 identifier. Not a wire field — v2 envelopes omit `version`. +pub const TRACE_VERSION_V2: u32 = 2; // --------------------------------------------------------------------------- -// Target +// RPC params / results // --------------------------------------------------------------------------- -/// Stable semantic handle for an interacted element. -/// -/// `name` and `nearby_label` are **untrusted page text**. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TargetDescriptor { - #[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, +pub struct RecordStartParams { + pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub name_attr: Option, + pub tab_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub placeholder: Option, + pub url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub nearby_label: Option, + pub purpose: Option, } -// --------------------------------------------------------------------------- -// Trace envelope -// --------------------------------------------------------------------------- - -/// Recording entry point — first URL the flow starts from. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TraceEntry { - pub start_url: String, +pub struct RecordStartResult { + pub tab_id: i64, + pub recording: bool, } -/// 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, +pub struct RecordStopParams { + pub session_id: String, } -/// 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, +/// 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), + V3(TraceV3), } -/// 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, +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_V3) { + return Err(format!("unsupported trace version {ver}")); + } + if v.get("pages").is_some() { + return Err("trace v3 must not include legacy pages[]".into()); + } + return serde_json::from_value(v.clone()) + .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::V2) + .map_err(|e| e.to_string()); + } + Err("ambiguous or unparseable trace".into()) + } } -/// 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, +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), + } + } } -// --------------------------------------------------------------------------- -// Step op-specific payloads -// --------------------------------------------------------------------------- - -/// One recorded user action — discriminated union by `op`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "op", rename_all = "snake_case")] -pub enum Step { - Navigate { - #[serde(flatten)] - common: StepCommon, - to: String, - }, - Click { - #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, - }, - Hover { - #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, - }, - Fill { - #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, - value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - redacted: Option, - }, - Select { - #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, - selection: Vec, - }, - Press { - #[serde(flatten)] - common: StepCommon, - key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - modifiers: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - }, +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) + } } -// --------------------------------------------------------------------------- -// Trace root -// --------------------------------------------------------------------------- +impl JsonSchema for RecordedTrace { + fn schema_name() -> String { + "RecordedTrace".into() + } -/// 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). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purpose: Option, - pub entry: TraceEntry, - pub pages: Vec, - pub steps: Vec, + 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 { + 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() + } } -// --------------------------------------------------------------------------- -// RPC params / results -// --------------------------------------------------------------------------- +/// 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), +} -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct RecordStartParams { - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tab_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purpose: Option, +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()) + } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct RecordStartResult { - pub tab_id: i64, - pub recording: bool, +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), + } + } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct RecordStopParams { - pub session_id: String, +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() + } + .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,281 +259,197 @@ pub struct RecordAwaitParams { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordAwaitResult { - pub trace: Trace, + pub trace: RecordedTrace, } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; use serde_json::json; - fn sample_common(id: u32) -> StepCommon { - StepCommon { - id, - page: "p1".into(), - effect: None, + #[test] + 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:?}"), } - } - fn sample_target() -> TargetDescriptor { - TargetDescriptor { - role: Some("button".into()), - name: Some("发布".into()), - tag: "button".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + 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:?}"), } } - 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()), - entry: TraceEntry { - start_url: "https://x.com/editor".into(), - }, - pages: vec![ - PageRef { - id: "p1".into(), - url: "https://x.com/editor".into(), - title: Some("写文章".into()), - }, - PageRef { - id: "p2".into(), - url: "https://x.com/p/99".into(), - title: None, - }, - ], - steps: vec![ - Step::Fill { - common: sample_common(1), - target: TargetDescriptor { - role: Some("textbox".into()), - name: Some("标题".into()), - tag: "input".into(), - name_attr: None, - placeholder: None, - nearby_label: None, - }, - value: "我的第一篇文章".into(), - redacted: None, - }, - Step::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()), - }], - }, - Step::Click { - common: StepCommon { - effect: Some(StepEffect { - navigated_to: "p2".into(), - }), - ..sample_common(4) - }, - target: sample_target(), - }, - ], - } + #[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 step_click_round_trips() { - let step = Step::Click { - common: sample_common(1), - 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()); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + 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 step_fill_with_raw_value_round_trips() { - let step = Step::Fill { - common: sample_common(2), - target: TargetDescriptor { - role: Some("textbox".into()), - name: Some("服务名称".into()), - tag: "input".into(), - name_attr: Some("serviceName".into()), - placeholder: None, - nearby_label: None, - }, - value: "my-svc".into(), - redacted: None, - }; - let v = serde_json::to_value(&step).unwrap(); - assert_eq!(v["op"], "fill"); - assert_eq!(v["value"], "my-svc"); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + fn recorded_trace_schema_includes_v2_and_v3() { + let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + assert_classifies_by_integer_version(&schema["definitions"]["RecordedTrace"]); + assert_eq!( + schema["definitions"]["TraceV3"]["properties"]["version"]["const"], + 3 + ); } #[test] - fn step_fill_password_is_redacted() { - let step = Step::Fill { - common: sample_common(1), - target: TargetDescriptor { - role: Some("textbox".into()), - name: Some("密码".into()), - tag: "input".into(), - name_attr: None, - placeholder: None, - nearby_label: None, - }, - value: "***".into(), - redacted: Some(true), - }; - let v = serde_json::to_value(&step).unwrap(); - assert_eq!(v["value"], "***"); - assert_eq!(v["redacted"], true); + 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()); + + let stop_result = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + assert_classifies_by_integer_version(&stop_result["definitions"]["RecordedTrace"]); } #[test] - fn step_select_uses_object_array() { - let step = Step::Select { - common: sample_common(3), - target: TargetDescriptor { - role: Some("combobox".into()), - name: Some("分类".into()), - tag: "select".into(), - name_attr: None, - placeholder: None, - nearby_label: None, - }, - selection: vec![SelectedOption { - value: "tech".into(), - label: Some("技术分享".into()), - }], - }; - let v = serde_json::to_value(&step).unwrap(); - assert_eq!(v["selection"][0]["value"], "tech"); - assert_eq!(v["selection"][0]["label"], "技术分享"); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + fn standalone_trace_schema_is_recorded_trace_union() { + let schema = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); + assert_eq!(schema["title"], "RecordedTrace"); + assert_classifies_by_integer_version(&schema); + assert!(schema["definitions"].get("TraceV2").is_some()); + assert!(schema["definitions"].get("TraceV3").is_some()); } #[test] - fn step_navigate_round_trips() { - let step = Step::Navigate { - common: sample_common(1), - to: "https://example.com".into(), - }; - 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); + fn recorded_trace_accepts_v2_with_unknown_extension_fields() { + let v2 = 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" } + }); + match RecordedTrace::classify_value(&v2).unwrap() { + RecordedTrace::V2(trace) => assert_eq!(trace.pages.len(), 1), + other => panic!("expected v2, got {other:?}"), + } } - #[test] - fn step_press_with_modifiers_round_trips() { - let step = Step::Press { - common: StepCommon { - effect: Some(StepEffect { - navigated_to: "p2".into(), - }), - ..sample_common(2) - }, - key: "Enter".into(), - modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]), - target: Some(sample_target()), - }; - let v = serde_json::to_value(&step).unwrap(); - assert_eq!(v["key"], "Enter"); - assert_eq!(v["modifiers"], json!(["ctrl", "shift"])); - assert_eq!(v["effect"]["navigated_to"], "p2"); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + fn v2_click() -> serde_json::Value { + json!({ + "op": "click", + "id": 1, + "page": "p1", + "target": { "tag": "button", "role": "button", "name": "发布" } + }) } - #[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()); + 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!( - v.get("entry") - .and_then(|e| e.get("start_url")) - .and_then(|u| u.as_str()), - Some("https://x.com/editor") + schema["if"]["required"], + json!(["state"]), + "state is the RecordedStep classification key" ); - let round: Trace = serde_json::from_value(v).unwrap(); - assert_eq!(round, trace); + 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 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_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 extension_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: 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.steps.len(), 2); + 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_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 new file mode 100644 index 00000000..23923815 --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -0,0 +1,330 @@ +//! Trace v2 — record-only user-action log with `pages[]` and step `page` refs. +//! +//! Legacy wire format with no top-level `version` field. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::interaction::KeyModifier; +use super::record_common::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 PageRefV2 { + 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, + }, + Hover { + #[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`. +/// +/// Unknown extension fields are ignored so older traces remain readable. +/// `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, + #[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, +} + +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::*; + 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![PageRefV2 { + 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 v2_hover_steps_round_trip() { + 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": "span", "role": "button", "name": "Account" } + }] + }); + + 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" + ); + } + + #[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" + ); + } + + #[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)" + ); + } +} 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); + } +}