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..b10425c7 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -657,10 +657,59 @@ 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; +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 +722,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 +766,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 +786,74 @@ 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 interface RecordStartParams { session_id: string; tab_id?: number; @@ -782,7 +871,7 @@ export interface RecordStopParams { } export interface RecordStopResult { - trace: Trace; + trace: RecordedTrace; } export interface RecordAwaitParams { @@ -791,5 +880,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..dee452f4 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,34 @@ } } }, - "SelectedOption": { + "RecordedTrace": { + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" + }, + { + "$ref": "#/definitions/TraceV3" + } + ] + }, + "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 +108,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 +166,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -105,7 +194,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 +206,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -140,12 +229,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 +246,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -180,12 +269,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 +287,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -227,7 +316,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -235,7 +324,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 +337,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -273,16 +362,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 +383,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -331,7 +420,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -342,21 +431,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" + } + } } - } + ] }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", "required": [ "tag" @@ -397,8 +759,82 @@ } } }, - "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": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", "type": "object", "required": [ "entry", @@ -413,11 +849,10 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" @@ -436,22 +871,67 @@ "steps": { "type": "array", "items": { - "$ref": "#/definitions/Step" + "$ref": "#/definitions/StepV2" } } - } + }, + "additionalProperties": false }, - "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..5d1e04a9 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,34 @@ } } }, - "SelectedOption": { + "RecordedTrace": { + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" + }, + { + "$ref": "#/definitions/TraceV3" + } + ] + }, + "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 +108,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 +166,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -105,7 +194,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 +206,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -140,12 +229,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 +246,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -180,12 +269,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 +287,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -227,7 +316,7 @@ ] }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, "value": { "type": "string" @@ -235,7 +324,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 +337,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -273,16 +362,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 +383,7 @@ "effect": { "anyOf": [ { - "$ref": "#/definitions/StepEffect" + "$ref": "#/definitions/StepEffectV2" }, { "type": "null" @@ -331,7 +420,7 @@ "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV2" }, { "type": "null" @@ -342,21 +431,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" + } + } } - } + ] }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", + "StopReason": { + "type": "string", + "enum": [ + "user_finish", + "cli_stop" + ] + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", "type": "object", "required": [ "tag" @@ -397,8 +759,82 @@ } } }, - "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": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", "type": "object", "required": [ "entry", @@ -413,11 +849,10 @@ "pages": { "type": "array", "items": { - "$ref": "#/definitions/PageRef" + "$ref": "#/definitions/PageRefV2" } }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" @@ -436,22 +871,67 @@ "steps": { "type": "array", "items": { - "$ref": "#/definitions/Step" + "$ref": "#/definitions/StepV2" } } - } + }, + "additionalProperties": false }, - "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..750ce6a7 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,49 +1,69 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Trace", - "description": "Persisted user-action trace exported by `tool.record_stop` / `await`.", + "title": "TraceV3", + "description": "Wire trace returned by `tool.record_stop` / `await`.", "type": "object", "required": [ "entry", - "pages", "recorded_at", - "steps" + "recorder", + "states", + "steps", + "stopped_by", + "version" ], "properties": { "entry": { "$ref": "#/definitions/TraceEntry" }, - "pages": { - "type": "array", - "items": { - "$ref": "#/definitions/PageRef" - } - }, "purpose": { - "description": "Optional user-provided goal from `--purpose` (metadata only).", "type": [ "string", "null" ] }, "recorded_at": { - "description": "RFC 3339 timestamp when recording stopped.", "type": "string" }, + "recorder": { + "$ref": "#/definitions/RecorderInfo" + }, "started_at": { "type": [ "string", "null" ] }, + "states": { + "type": "array", + "items": { + "$ref": "#/definitions/TraceStateV3" + } + }, "steps": { "type": "array", "items": { - "$ref": "#/definitions/Step" + "$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", @@ -54,29 +74,36 @@ "shift" ] }, - "PageRef": { - "description": "Page context dictionary entry — referenced by steps via `page` id.", + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "RecorderInfo": { "type": "object", "required": [ - "id", - "url" + "bsk", + "vom" ], "properties": { - "id": { + "bsk": { "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "url": { - "type": "string" + "vom": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 } } }, - "SelectedOption": { + "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -94,28 +121,34 @@ } } }, - "Step": { + "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", - "page", + "result", + "state", "to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "cause": { + "$ref": "#/definitions/NavigationCause" }, "id": { "type": "integer", @@ -128,8 +161,11 @@ "navigate" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "to": { @@ -143,20 +179,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -168,12 +195,15 @@ "click" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -183,20 +213,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -208,12 +229,15 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -221,22 +245,17 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "commit", "id", "op", - "page", + "result", + "state", "target", "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -249,18 +268,18 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, "value": { "type": "string" @@ -273,21 +292,11 @@ "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -299,18 +308,21 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResultV3" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV3" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -321,19 +333,10 @@ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -357,14 +360,17 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, { "type": "null" @@ -372,48 +378,63 @@ ] } } + }, + { + "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" + } + } } ] }, - "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" - } - } + "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**.", + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", - "required": [ - "tag" - ], "properties": { - "name": { + "ctx": { "type": [ "string", "null" ] }, - "name_attr": { - "type": [ - "string", - "null" - ] - }, - "nearby_label": { + "name": { "type": [ "string", "null" ] }, - "placeholder": { + "ref": { "type": [ "string", "null" @@ -425,13 +446,12 @@ "null" ] }, - "tag": { - "type": "string" + "unmatched": { + "type": "boolean" } } }, "TraceEntry": { - "description": "Recording entry point — first URL the flow starts from.", "type": "object", "required": [ "start_url" @@ -441,6 +461,36 @@ "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/schema/trace_step.json b/crates/bsk-protocol/schema/trace_step.json index 69dbec90..14a33ee1 100644 --- a/crates/bsk-protocol/schema/trace_step.json +++ b/crates/bsk-protocol/schema/trace_step.json @@ -1,27 +1,22 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Step", + "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", - "page", + "result", + "state", "to" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "cause": { + "$ref": "#/definitions/NavigationCause" }, "id": { "type": "integer", @@ -34,8 +29,11 @@ "navigate" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "to": { @@ -49,20 +47,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -74,12 +63,15 @@ "click" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -89,20 +81,11 @@ "required": [ "id", "op", - "page", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -114,12 +97,15 @@ "hover" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -127,22 +113,17 @@ "description": "Fields shared by every step variant (flattened in JSON).", "type": "object", "required": [ + "commit", "id", "op", - "page", + "result", + "state", "target", "value" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] + "commit": { + "$ref": "#/definitions/FillCommit" }, "id": { "type": "integer", @@ -155,18 +136,18 @@ "fill" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" - }, "redacted": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, "value": { "type": "string" @@ -179,21 +160,11 @@ "required": [ "id", "op", - "page", - "selection", + "result", + "state", "target" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -205,18 +176,21 @@ "select" ] }, - "page": { - "description": "Reference into `pages[]`.", - "type": "string" + "result": { + "$ref": "#/definitions/StepResultV3" }, "selection": { "type": "array", "items": { - "$ref": "#/definitions/SelectedOption" + "$ref": "#/definitions/SelectedOptionV3" } }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, "target": { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" } } }, @@ -227,19 +201,10 @@ "id", "key", "op", - "page" + "result", + "state" ], "properties": { - "effect": { - "anyOf": [ - { - "$ref": "#/definitions/StepEffect" - }, - { - "type": "null" - } - ] - }, "id": { "type": "integer", "format": "uint32", @@ -263,14 +228,17 @@ "press" ] }, - "page": { - "description": "Reference into `pages[]`.", + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", "type": "string" }, "target": { "anyOf": [ { - "$ref": "#/definitions/TargetDescriptor" + "$ref": "#/definitions/TargetDescriptorV3" }, { "type": "null" @@ -278,9 +246,47 @@ ] } } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "scroll" + ] + }, + "result": { + "$ref": "#/definitions/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", @@ -291,7 +297,19 @@ "shift" ] }, - "SelectedOption": { + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", "required": [ @@ -309,45 +327,34 @@ } } }, - "StepEffect": { - "description": "Observed navigation after a step (objective fact only).", + "StepResultV3": { "type": "object", "required": [ - "navigated_to" + "state" ], "properties": { - "navigated_to": { - "description": "Reference into `pages[]` for the destination page.", + "state": { "type": "string" } } }, - "TargetDescriptor": { - "description": "Stable semantic handle for an interacted element.\n\n`name` and `nearby_label` are **untrusted page text**.", + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", - "required": [ - "tag" - ], "properties": { - "name": { - "type": [ - "string", - "null" - ] - }, - "name_attr": { + "ctx": { "type": [ "string", "null" ] }, - "nearby_label": { + "name": { "type": [ "string", "null" ] }, - "placeholder": { + "ref": { "type": [ "string", "null" @@ -359,8 +366,8 @@ "null" ] }, - "tag": { - "type": "string" + "unmatched": { + "type": "boolean" } } } diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json new file mode 100644 index 00000000..7db328c2 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -0,0 +1,445 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRefV2" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + }, + "additionalProperties": false, + "definitions": { + "KeyModifier": { + "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", + "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/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index de9b7f25..6e565fe4 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -113,8 +113,9 @@ 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"); + dump!(StepV3, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); dump!(RecordStartResult, "tool_record_start_result"); dump!(RecordStopParams, "tool_record_stop_params"); diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index a9c04dbb..11ab651d 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -9,6 +9,8 @@ pub mod navigation; pub mod network; pub mod observation; pub mod record; +mod record_common; +mod record_v2; pub mod script; pub mod session; pub mod tabs; diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index 6dc2e097..fe7c7329 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -1,146 +1,211 @@ //! Semantic user-action recording (`tool.record_start` / `stop` / `await`). //! -//! Trace v2 is a **record-only** log of user actions: what was clicked, filled, -//! selected, and where navigation occurred. No LLM runs during recording, so -//! variable-vs-constant classification is **not** stored — executing agents -//! infer that at run time from raw values and control names. +//! Trace v3 is a **state-action-state** chain: each step binds to page +//! observations (VOM) captured before and after the action. use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use super::interaction::KeyModifier; +pub use super::record_common::TraceEntry; +pub use super::record_v2::{ + PageRefV2, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, +}; + +pub const TRACE_VERSION_V3: u32 = 3; +pub const TRACE_VERSION_V2: u32 = 2; +pub const VOM_FORMAT_VERSION: u32 = 1; + +fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let version = u32::deserialize(deserializer)?; + if version != TRACE_VERSION_V3 { + return Err(serde::de::Error::custom(format!( + "unsupported trace version {version} (expected {TRACE_VERSION_V3})" + ))); + } + Ok(version) +} + +fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + const_value: Some(serde_json::json!(TRACE_VERSION_V3)), + ..Default::default() + } + .into() +} + // --------------------------------------------------------------------------- // Target // --------------------------------------------------------------------------- -/// Stable semantic handle for an interacted element. -/// -/// `name` and `nearby_label` are **untrusted page text**. +/// Stable semantic handle for an interacted element within a page observation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TargetDescriptor { +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, - pub tag: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name_attr: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub placeholder: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nearby_label: Option, + pub ctx: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unmatched: bool, } // --------------------------------------------------------------------------- -// Trace envelope +// Trace v3 envelope // --------------------------------------------------------------------------- -/// Recording entry point — first URL the flow starts from. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct TraceEntry { - pub start_url: String, +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 context dictionary entry — referenced by steps via `page` id. +/// Page observation dictionary entry — referenced by steps via `state` / `result.state`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct PageRef { +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, } -/// 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, -} - -/// Observed navigation after a step (objective fact only). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepEffect { - /// Reference into `pages[]` for the destination page. - pub navigated_to: String, +pub struct StepResultV3 { + pub state: String, } /// Fields shared by every step variant (flattened in JSON). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct StepCommon { +pub struct StepCommonV3 { pub id: u32, - /// Reference into `pages[]`. - pub page: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effect: Option, + /// Observation id immediately before this action. + pub state: String, + pub result: StepResultV3, } // --------------------------------------------------------------------------- -// Step op-specific payloads +// Trace v3 step payloads // --------------------------------------------------------------------------- +/// One selected option (`select` op). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SelectedOptionV3 { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NavigationCause { + UserTyped, + Link, + FormSubmit, + Reload, + History, + Script, + Browser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FillCommit { + Enter, + Suggestion, + Blur, +} + /// One recorded user action — discriminated union by `op`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "op", rename_all = "snake_case")] -pub enum Step { +pub enum StepV3 { Navigate { #[serde(flatten)] - common: StepCommon, + common: StepCommonV3, to: String, + cause: NavigationCause, }, Click { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, }, Hover { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, }, Fill { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, + common: StepCommonV3, + target: TargetDescriptorV3, value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - redacted: Option, + commit: FillCommit, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + redacted: bool, }, Select { #[serde(flatten)] - common: StepCommon, - target: TargetDescriptor, - selection: Vec, + common: StepCommonV3, + target: TargetDescriptorV3, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + selection: Vec, }, Press { #[serde(flatten)] - common: StepCommon, + common: StepCommonV3, key: String, #[serde(default, skip_serializing_if = "Option::is_none")] modifiers: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, + target: Option, + }, + Scroll { + #[serde(flatten)] + common: StepCommonV3, }, } // --------------------------------------------------------------------------- -// Trace root +// Trace v3 root // --------------------------------------------------------------------------- -/// Persisted user-action trace exported by `tool.record_stop` / `await`. +/// Wire trace returned by `tool.record_stop` / `await`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -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(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 pages: Vec, - pub steps: Vec, + pub recorder: RecorderInfo, + pub states: Vec, + pub steps: Vec, } // --------------------------------------------------------------------------- @@ -169,9 +234,83 @@ pub struct RecordStopParams { pub session_id: String, } +/// Wire trace payload — v2 (legacy `pages[]`) or v3 (`version: 3`, `states[]`). +#[derive(Debug, Clone, PartialEq)] +pub enum RecordedTrace { + V2(TraceV2), + V3(TraceV3), +} + +impl RecordedTrace { + pub fn classify_value(v: &serde_json::Value) -> Result { + if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) { + if ver != u64::from(TRACE_VERSION_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()) + } +} + +impl Serialize for RecordedTrace { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + RecordedTrace::V2(t) => t.serialize(serializer), + RecordedTrace::V3(t) => t.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RecordedTrace { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + Self::classify_value(&value).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for RecordedTrace { + fn schema_name() -> String { + "RecordedTrace".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + one_of: Some(vec![ + generator.subschema_for::(), + generator.subschema_for::(), + ]), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordStopResult { - pub trace: Trace, + pub trace: RecordedTrace, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -183,7 +322,7 @@ pub struct RecordAwaitParams { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordAwaitResult { - pub trace: Trace, + pub trace: RecordedTrace, } // --------------------------------------------------------------------------- @@ -195,81 +334,77 @@ mod tests { use super::*; use serde_json::json; - fn sample_common(id: u32) -> StepCommon { - StepCommon { + fn sample_common(id: u32, state: &str, result_state: &str) -> StepCommonV3 { + StepCommonV3 { id, - page: "p1".into(), - effect: None, + state: state.into(), + result: StepResultV3 { + state: result_state.into(), + }, } } - fn sample_target() -> TargetDescriptor { - TargetDescriptor { + fn sample_target() -> TargetDescriptorV3 { + TargetDescriptorV3 { + element_ref: Some("e21".into()), role: Some("button".into()), name: Some("发布".into()), - tag: "button".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + ctx: Some("金桔柠檬 6 号".into()), + unmatched: false, } } - fn sample_trace() -> Trace { - Trace { - recorded_at: "2026-07-17T09:01:10Z".into(), - started_at: Some("2026-07-17T09:00:00Z".into()), - purpose: Some("发布一篇文章".into()), + 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://x.com/editor".into(), + start_url: "https://example.com/".into(), }, - pages: vec![ - PageRef { - id: "p1".into(), - url: "https://x.com/editor".into(), - title: Some("写文章".into()), + recorder: RecorderInfo { + bsk: "0.1.10".into(), + vom: VOM_FORMAT_VERSION, + }, + states: vec![ + TraceStateV3 { + id: "s1".into(), + url: "https://example.com/".into(), + title: Some("Example Domain".into()), + body: "@vom 1\nRootWebArea \"Example Domain\"".into(), + truncated: false, }, - PageRef { - id: "p2".into(), - url: "https://x.com/p/99".into(), - title: None, + 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![ - 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, + StepV3::Navigate { + common: sample_common(1, "s1", "s2"), + to: "https://shop.example.com/products?status=draft".into(), + cause: NavigationCause::UserTyped, }, - Step::Select { - common: sample_common(3), - target: TargetDescriptor { - role: Some("combobox".into()), - name: Some("分类".into()), - tag: "select".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + 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, }, - selection: vec![SelectedOption { - value: "tech".into(), - label: Some("技术分享".into()), - }], + value: "金桔柠檬".into(), + commit: FillCommit::Enter, + redacted: false, }, - Step::Click { - common: StepCommon { - effect: Some(StepEffect { - navigated_to: "p2".into(), - }), - ..sample_common(4) - }, + StepV3::Click { + common: sample_common(3, "s2", "s2"), target: sample_target(), }, ], @@ -278,54 +413,56 @@ mod tests { #[test] fn step_click_round_trips() { - let step = Step::Click { - common: sample_common(1), + 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("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!(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_raw_value_round_trips() { - let step = Step::Fill { - common: sample_common(2), - target: TargetDescriptor { + 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()), - tag: "input".into(), - name_attr: Some("serviceName".into()), - placeholder: None, - nearby_label: None, + name: Some("搜索商品".into()), + ctx: None, + unmatched: false, }, - value: "my-svc".into(), - redacted: None, + value: "browser skill".into(), + commit: FillCommit::Enter, + redacted: false, }; let v = serde_json::to_value(&step).unwrap(); assert_eq!(v["op"], "fill"); - assert_eq!(v["value"], "my-svc"); - let round: Step = serde_json::from_value(v).unwrap(); + 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 = Step::Fill { - common: sample_common(1), - target: TargetDescriptor { + let step = StepV3::Fill { + common: sample_common(1, "s1", "s1"), + target: TargetDescriptorV3 { + element_ref: Some("e3".into()), role: Some("textbox".into()), name: Some("密码".into()), - tag: "input".into(), - name_attr: None, - placeholder: None, - nearby_label: None, + ctx: None, + unmatched: false, }, value: "***".into(), - redacted: Some(true), + commit: FillCommit::Blur, + redacted: true, }; let v = serde_json::to_value(&step).unwrap(); assert_eq!(v["value"], "***"); @@ -333,131 +470,178 @@ mod tests { } #[test] - fn step_select_uses_object_array() { - let step = Step::Select { - common: sample_common(3), - target: TargetDescriptor { - role: Some("combobox".into()), - name: Some("分类".into()), - tag: "select".into(), - name_attr: None, - placeholder: None, - nearby_label: None, - }, - selection: vec![SelectedOption { - value: "tech".into(), - label: Some("技术分享".into()), - }], + fn step_navigate_with_cause_round_trips() { + let step = 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["selection"][0]["value"], "tech"); - assert_eq!(v["selection"][0]["label"], "技术分享"); - let round: Step = serde_json::from_value(v).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 step_navigate_round_trips() { - let step = Step::Navigate { - common: sample_common(1), - to: "https://example.com".into(), + fn trace_v3_round_trips() { + let trace = sample_trace(); + let v = serde_json::to_value(&trace).unwrap(); + assert_eq!(v.get("version").and_then(|v| v.as_u64()), Some(3)); + assert!(v.get("pages").is_none()); + assert_eq!(v["recorder"]["vom"], 1); + assert_eq!(v["states"].as_array().unwrap().len(), 2); + let round: 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_eq!(v["op"], "navigate"); - assert_eq!(v["to"], "https://example.com"); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + assert!(v.get("unmatched").is_none()); + assert!(v["target"].get("ctx").is_none()); + assert!(v["target"].get("unmatched").is_none()); } #[test] - fn step_press_with_modifiers_round_trips() { - let step = Step::Press { - common: StepCommon { - effect: Some(StepEffect { - navigated_to: "p2".into(), - }), - ..sample_common(2) + fn unmatched_target_serializes_flag() { + let step = StepV3::Click { + common: sample_common(1, "s1", "s2"), + target: TargetDescriptorV3 { + element_ref: None, + role: Some("button".into()), + name: Some("发布".into()), + ctx: None, + unmatched: true, }, - key: "Enter".into(), - modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]), - target: Some(sample_target()), }; let v = serde_json::to_value(&step).unwrap(); - assert_eq!(v["key"], "Enter"); - assert_eq!(v["modifiers"], json!(["ctrl", "shift"])); - assert_eq!(v["effect"]["navigated_to"], "p2"); - let round: Step = serde_json::from_value(v).unwrap(); - assert_eq!(round, step); + assert_eq!(v["target"]["unmatched"], true); + assert!(v["target"].get("ref").is_none()); } #[test] - fn trace_record_only_round_trips() { - let trace = sample_trace(); - let v = serde_json::to_value(&trace).unwrap(); - assert!(v.get("version").is_none()); - assert_eq!( - v.get("purpose").and_then(|v| v.as_str()), - Some("发布一篇文章") - ); - assert!(v.get("parameters").is_none()); - assert!(v.get("site").is_none()); - assert!(v.get("goal").is_none()); - assert_eq!( - v.get("entry") - .and_then(|e| e.get("start_url")) - .and_then(|u| u.as_str()), - Some("https://x.com/editor") - ); - let round: Trace = serde_json::from_value(v).unwrap(); - assert_eq!(round, trace); + fn recorded_trace_classifies_v2_and_v3() { + let v2 = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [] + }); + match RecordedTrace::classify_value(&v2).unwrap() { + RecordedTrace::V2(_) => {} + other => panic!("expected v2, got {other:?}"), + } + + let v3 = json!({ + "version": 3, + "recorded_at": "2026-07-21T08:00:00Z", + "stopped_by": "user_finish", + "entry": { "start_url": "https://example.com/" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [], + "steps": [] + }); + match RecordedTrace::classify_value(&v3).unwrap() { + RecordedTrace::V3(t) => assert_eq!(t.version, 3), + other => panic!("expected v3, got {other:?}"), + } } #[test] - fn discriminated_union_click_ignores_extra_key_field() { - let bad = json!({ - "op": "click", - "id": 1, - "page": "p1", - "target": { "tag": "button", "name": "OK" }, - "key": "Enter" + 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": [] }); - 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()); + assert!(RecordedTrace::classify_value(&v2_with_states).is_err()); + + let v3_with_pages = json!({ + "version": 3, + "recorded_at": "2026-07-21T08:00:00Z", + "stopped_by": "user_finish", + "entry": { "start_url": "https://example.com/" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "states": [], + "steps": [] + }); + assert!(RecordedTrace::classify_value(&v3_with_pages).is_err()); + + let unsupported = json!({ + "version": 2, + "recorded_at": "2026-07-21T08:00:00Z", + "stopped_by": "user_finish", + "entry": { "start_url": "https://example.com/" }, + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [], + "steps": [] + }); + assert!(RecordedTrace::classify_value(&unsupported).is_err()); + assert!(serde_json::from_value::(unsupported).is_err()); + } + + #[test] + fn recorded_trace_schema_includes_v2_and_v3() { + let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + let variants = schema["definitions"]["RecordedTrace"]["oneOf"] + .as_array() + .expect("RecordedTrace schema should use oneOf"); + + assert_eq!(variants.len(), 2); + let trace_schema = schema["definitions"]["TraceV3"].clone(); + assert_eq!(trace_schema["properties"]["version"]["const"], 3); } #[test] fn extension_trace_deserializes() { let v = json!({ + "version": 3, "recorded_at": "2026-07-21T08:00:00Z", "started_at": "2026-07-21T07:59:00Z", "purpose": "demo", + "stopped_by": "user_finish", "entry": { "start_url": "https://example.com/editor" }, - "pages": [ - { "id": "p1", "url": "https://example.com/editor" }, - { "id": "p2", "url": "https://example.com/p/99" } + "recorder": { "bsk": "0.1.10", "vom": 1 }, + "states": [ + { "id": "s1", "url": "https://example.com/editor", "body": "@vom 1" }, + { "id": "s2", "url": "https://example.com/p/99", "body": "@vom 1" } ], "steps": [ { "op": "fill", "id": 1, - "page": "p1", - "target": { "tag": "input", "role": "textbox", "name": "标题" }, - "value": "hello" + "state": "s1", + "result": { "state": "s1" }, + "target": { "ref": "e1", "role": "textbox", "name": "标题" }, + "value": "hello", + "commit": "blur" }, { "op": "click", "id": 2, - "page": "p1", - "target": { "tag": "button", "role": "button", "name": "发布" }, - "effect": { "navigated_to": "p2" } + "state": "s1", + "result": { "state": "s2" }, + "target": { "ref": "e2", "role": "button", "name": "发布" } } ] }); - let trace: Trace = serde_json::from_value(v).unwrap(); - assert_eq!(trace.entry.start_url, "https://example.com/editor"); - assert_eq!(trace.pages.len(), 2); + let trace: TraceV3 = serde_json::from_value(v).unwrap(); + assert_eq!(trace.version, 3); + assert_eq!(trace.states.len(), 2); assert_eq!(trace.steps.len(), 2); } } diff --git a/crates/bsk-protocol/src/tools/record_common.rs b/crates/bsk-protocol/src/tools/record_common.rs new file mode 100644 index 00000000..02617366 --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_common.rs @@ -0,0 +1,7 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TraceEntry { + pub start_url: String, +} diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs new file mode 100644 index 00000000..43b36e7e --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -0,0 +1,225 @@ +//! 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`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TraceV2 { + /// RFC 3339 timestamp when recording stopped. + pub recorded_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, + pub entry: TraceEntry, + pub pages: Vec, + pub steps: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_common(id: u32) -> StepCommonV2 { + StepCommonV2 { + id, + page: "p1".into(), + effect: None, + } + } + + fn sample_target() -> TargetDescriptorV2 { + TargetDescriptorV2 { + role: Some("button".into()), + name: Some("发布".into()), + tag: "button".into(), + name_attr: None, + placeholder: None, + nearby_label: None, + } + } + + #[test] + fn trace_v2_has_no_version_field() { + let trace = TraceV2 { + recorded_at: "2026-07-17T09:01:10Z".into(), + started_at: None, + purpose: None, + entry: TraceEntry { + start_url: "https://example.com/".into(), + }, + pages: vec![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" + ); + } +}