diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx index 3260e80..6f31e75 100644 --- a/apps/extension/src/entrypoints/popup/App.test.tsx +++ b/apps/extension/src/entrypoints/popup/App.test.tsx @@ -105,7 +105,7 @@ describe("App", () => { snapshot: { ...baseSnapshot, lastError: - "[handshake] daemon rejected handshake: version_too_old — peer protocol 1.0 is below local min_compatible_protocol 1.1", + "[handshake] daemon rejected handshake: version_too_old — peer protocol 1.0 is below local min_compatible_protocol 1.0", }, statusState: "disconnected", setLabel, diff --git a/apps/extension/src/lib/__tests__/connection-controller.test.ts b/apps/extension/src/lib/__tests__/connection-controller.test.ts index 6790916..29014dc 100644 --- a/apps/extension/src/lib/__tests__/connection-controller.test.ts +++ b/apps/extension/src/lib/__tests__/connection-controller.test.ts @@ -84,9 +84,10 @@ describe("computeConnectedState (protocol-based compat)", () => { } }); - it("rejects a pre-Trace-v3 daemon using the configured extension floor", () => { - const result = computeConnectedState(handshake("1.0", "1.0")); - expect(result.kind).toBe("rejected"); + it("returns version_skew when daemon protocol is 1.0 and floor is satisfied", () => { + expect(computeConnectedState(handshake("1.0", "1.0"))).toEqual({ + kind: "version_skew", + }); }); it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => { diff --git a/apps/extension/src/lib/__tests__/trace-reducer-v2.test.ts b/apps/extension/src/lib/__tests__/trace-reducer-v2.test.ts new file mode 100644 index 0000000..3ae52e2 --- /dev/null +++ b/apps/extension/src/lib/__tests__/trace-reducer-v2.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { buildTraceV2, shouldRecordPress } from "@/lib/trace-reducer-v2"; +import type { DraftTraceStep } from "@/transport/types"; + +describe("trace-reducer-v2", () => { + it("drops scroll and bare character press steps", () => { + const steps: DraftTraceStep[] = [ + { + op: "scroll", + page_url: "https://example.com/", + }, + { + op: "press", + key: "a", + page_url: "https://example.com/", + }, + ]; + const trace = buildTraceV2({ + steps, + startedAt: "2026-01-01T00:00:00.000Z", + startUrl: "https://example.com/", + }); + expect(trace.steps).toHaveLength(0); + expect(trace.pages).toHaveLength(1); + }); + + it("drops hover steps unsupported by historical v2 clients", () => { + const trace = buildTraceV2({ + steps: [ + { + op: "hover", + target: { unmatched: true }, + captureTarget: { tag: "button", role: "button", name: "结束" }, + page_url: "https://example.com/", + }, + ], + startedAt: "2026-01-01T00:00:00.000Z", + startUrl: "https://example.com/", + }); + + expect(trace.steps).toHaveLength(0); + }); + + it("maps captureTarget to v2 target with required tag", () => { + const steps: DraftTraceStep[] = [ + { + op: "click", + target: { unmatched: true }, + captureTarget: { tag: "button", role: "button", name: "Submit" }, + page_url: "https://example.com/", + }, + ]; + const trace = buildTraceV2({ + steps, + startedAt: "2026-01-01T00:00:00.000Z", + startUrl: "https://example.com/", + }); + expect(trace.steps).toHaveLength(1); + expect(trace.steps[0]?.op).toBe("click"); + if (trace.steps[0]?.op === "click") { + expect(trace.steps[0].target.tag).toBe("button"); + expect(trace.steps[0].target.name).toBe("Submit"); + } + expect("version" in trace).toBe(false); + }); + + it("records Enter presses", () => { + expect(shouldRecordPress("Enter")).toBe(true); + }); +}); diff --git a/apps/extension/src/lib/trace-reducer-v2.ts b/apps/extension/src/lib/trace-reducer-v2.ts new file mode 100644 index 0000000..8f4edf5 --- /dev/null +++ b/apps/extension/src/lib/trace-reducer-v2.ts @@ -0,0 +1,297 @@ +import type { CaptureTargetDescriptor } from "@/lib/describe-target"; +import type { DraftTraceStep, KeyModifier } 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"]); + +export interface TargetDescriptorV2 { + role?: string; + name?: string; + tag: string; + name_attr?: string; + placeholder?: string; + nearby_label?: string; +} + +export interface PageRef { + id: string; + url: string; + title?: string; +} + +export interface SelectedOptionV2 { + value: string; + label?: string; +} + +export interface StepEffectV2 { + navigated_to: string; +} + +export type StepV2 = + | { op: "navigate"; id: number; page: string; to: string; effect?: StepEffectV2 } + | { op: "click"; id: number; page: string; target: TargetDescriptorV2; effect?: StepEffectV2 } + | { + op: "fill"; + id: number; + page: string; + target: TargetDescriptorV2; + value: string; + redacted?: boolean; + effect?: StepEffectV2; + } + | { + op: "select"; + id: number; + page: string; + target: TargetDescriptorV2; + selection: SelectedOptionV2[]; + effect?: StepEffectV2; + } + | { + op: "press"; + id: number; + page: string; + key: string; + modifiers?: KeyModifier[]; + target?: TargetDescriptorV2; + effect?: StepEffectV2; + }; + +export interface TraceV2 { + recorded_at: string; + started_at?: string; + purpose?: string; + entry: { start_url: string }; + pages: PageRef[]; + steps: StepV2[]; +} + +export function shouldRecordPress( + key: string, + modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">, +): boolean { + if (MODIFIER_ONLY_KEYS.has(key)) return false; + const mods = modifiers ?? []; + const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta"); + if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false; + if (key === "Enter" || key === "Escape") return true; + if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false; + return false; +} + +function shouldIncludeDraft(step: DraftTraceStep): boolean { + if (step.op === "scroll" || step.op === "hover") return false; + if (step.op === "fill" && !(step.value ?? "").trim() && !step.redacted) return false; + if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false; + return true; +} + +function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] { + const out: DraftTraceStep[] = []; + for (const step of steps) { + const prev = out[out.length - 1]; + if (step.op === "navigate" && prev?.op === "navigate") { + out[out.length - 1] = step; + continue; + } + out.push(step); + } + return out; +} + +function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] { + const urls: string[] = []; + if (startUrl) urls.push(startUrl); + for (const step of steps) { + if (step.op === "navigate") { + urls.push(step.url); + continue; + } + if ("page_url" in step && step.page_url) urls.push(step.page_url); + if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to); + } + const seen = new Set(); + const unique: string[] = []; + for (const url of urls) { + if (!url || seen.has(url)) continue; + seen.add(url); + unique.push(url); + } + return unique; +} + +function buildPageRegistry( + steps: DraftTraceStep[], + startUrl?: string, +): { pages: PageRef[]; urlToId: Map } { + const urls = collectUrls(steps, startUrl); + const urlToId = new Map(); + const pages = urls.map((url, index) => { + const id = `p${index + 1}`; + urlToId.set(url, id); + return { id, url }; + }); + return { pages, urlToId }; +} + +function pageIdFor( + url: string | undefined, + urlToId: Map, + fallbackUrl?: string, +): string { + if (url && urlToId.has(url)) return urlToId.get(url)!; + if (fallbackUrl && urlToId.has(fallbackUrl)) return urlToId.get(fallbackUrl)!; + return urlToId.values().next().value ?? "p1"; +} + +function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | undefined { + if (step.op === "navigate") return step.page_url ?? step.url; + if ("page_url" in step && step.page_url) return step.page_url; + return fallbackUrl; +} + +function effectForNavigation( + navigatedTo: string | undefined, + urlToId: Map, +): StepEffectV2 | undefined { + if (!navigatedTo) return undefined; + const pageId = urlToId.get(navigatedTo); + if (!pageId) return undefined; + return { navigated_to: pageId }; +} + +function captureTargetToV2Target(capture?: CaptureTargetDescriptor): TargetDescriptorV2 { + return { + tag: capture?.tag ?? "unknown", + ...(capture?.role ? { role: capture.role } : {}), + ...(capture?.name ? { name: capture.name } : {}), + ...(capture?.name_attr ? { name_attr: capture.name_attr } : {}), + ...(capture?.placeholder ? { placeholder: capture.placeholder } : {}), + ...(capture?.nearby_label ? { nearby_label: capture.nearby_label } : {}), + }; +} + +function targetForDraft(step: DraftTraceStep): TargetDescriptorV2 | undefined { + if (!("target" in step) && !("captureTarget" in step)) return undefined; + const capture = "captureTarget" in step ? step.captureTarget : undefined; + if (capture) return captureTargetToV2Target(capture); + if ("target" in step && step.target && "tag" in (step.target as object)) { + const legacy = step.target as TargetDescriptorV2 & { tag?: string }; + if (legacy.tag) return legacy; + } + return undefined; +} + +function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] { + return values.map((value, index) => ({ + value, + ...(labels?.[index] ? { label: labels[index] } : {}), + })); +} + +function toV2Step( + step: DraftTraceStep, + id: number, + urlToId: Map, + fallbackUrl?: string, +): StepV2 | null { + if (!shouldIncludeDraft(step)) return null; + + const pageUrl = pageUrlForDraft(step, fallbackUrl); + const page = pageIdFor(pageUrl, urlToId, fallbackUrl); + const effect = + "navigated_to" in step ? effectForNavigation(step.navigated_to, urlToId) : undefined; + + switch (step.op) { + case "navigate": + return { + op: "navigate", + id, + page: pageIdFor(step.url, urlToId, fallbackUrl), + to: step.url, + }; + case "click": { + const target = targetForDraft(step); + if (!target) return null; + return { op: "click", id, page, target, ...(effect ? { effect } : {}) }; + } + case "hover": + return null; + case "fill": { + const target = targetForDraft(step); + if (!target) return null; + return { + op: "fill", + id, + page, + target, + value: step.value, + ...(step.redacted ? { redacted: true } : {}), + }; + } + case "press": + return { + op: "press", + id, + page, + key: step.key, + ...(step.target ? { target: targetForDraft(step) } : {}), + ...(step.modifiers?.length ? { modifiers: step.modifiers } : {}), + ...(effect ? { effect } : {}), + }; + case "select": { + const target = targetForDraft(step); + if (!target) return null; + return { + op: "select", + id, + page, + target, + selection: toSelection(step.values, step.labels), + ...(effect ? { effect } : {}), + }; + } + case "scroll": + return null; + } +} + +export interface BuildTraceV2Input { + steps: DraftTraceStep[]; + startedAt: string; + startUrl?: string; + purpose?: string; +} + +export function buildTraceV2(input: BuildTraceV2Input): TraceV2 { + const collapsed = collapseNavigations(input.steps); + const startUrl = + input.startUrl ?? + collapsed.find( + (step): step is Extract => step.op === "navigate", + )?.url ?? + collapsed.find((step) => "page_url" in step && step.page_url)?.page_url ?? + "about:blank"; + const { pages, urlToId } = buildPageRegistry(collapsed, startUrl); + const out: StepV2[] = []; + let id = 1; + let lastUrl = startUrl; + for (const draft of collapsed) { + if (draft.op === "navigate") lastUrl = draft.url; + else if ("navigated_to" in draft && draft.navigated_to) lastUrl = draft.navigated_to; + else if ("page_url" in draft && draft.page_url) lastUrl = draft.page_url; + const step = toV2Step(draft, id, urlToId, lastUrl); + if (!step) continue; + out.push(step); + id += 1; + } + return { + recorded_at: new Date().toISOString(), + started_at: input.startedAt, + ...(input.purpose ? { purpose: input.purpose } : {}), + entry: { start_url: startUrl }, + pages, + steps: out, + }; +} diff --git a/apps/extension/src/tools/__tests__/record-steps.test.ts b/apps/extension/src/tools/__tests__/record-steps.test.ts index b235304..abc04e3 100644 --- a/apps/extension/src/tools/__tests__/record-steps.test.ts +++ b/apps/extension/src/tools/__tests__/record-steps.test.ts @@ -10,7 +10,7 @@ import { resetStateIdCounterForTests } from "@/lib/trace-reducer"; import type { SessionManager } from "@/session-manager/manager"; import type { CdpRunner } from "@/tools/shared"; import { EXTENSION_VERSION } from "@/transport/handshake"; -import type { RecordStopResult, Trace } from "@/transport/types"; +import type { RecordedTrace, RecordStopResult, TraceV3 } from "@/transport/types"; import { attachRecordFinishListener, attachRecordQueryListener, @@ -23,6 +23,14 @@ import { const AGENT_WINDOW_ID = 100; const TAB_ID = 4; const START_URL = "https://example.com/"; +const RECORD_START_V3 = { session_id: "abcd", url: START_URL, trace_version: 3 as const }; + +function asTraceV3(trace: RecordedTrace): TraceV3 { + if (!("version" in trace)) { + throw new Error("expected v3 trace"); + } + return trace; +} type RuntimeListener = ( message: unknown, @@ -264,11 +272,7 @@ describe("recorded user steps reach the exported trace", () => { }); const startDeps = { tabsApi, sendToTab, cdp }; - const started = await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - startDeps, - ); + const started = await handleRecordStart(manager, RECORD_START_V3, startDeps); expect(started).toEqual({ tab_id: TAB_ID, recording: true }); expect(requestId).not.toBe(""); @@ -298,7 +302,7 @@ describe("recorded user steps reach the exported trace", () => { ); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.recorder.bsk).toBe(EXTENSION_VERSION); expect(trace.steps).toHaveLength(1); @@ -321,11 +325,7 @@ describe("recorded user steps reach the exported trace", () => { return { ok: true }; }); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp: makeFakeCdp() }, - ); + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() }); attachRecordStepListener({ tabsApi, sendToTab }); const emit = (step: unknown) => { @@ -350,7 +350,7 @@ describe("recorded user steps reach the exported trace", () => { }); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["fill", "click"]); expect(trace.steps.map((step) => step.id)).toEqual([1, 2]); @@ -366,11 +366,7 @@ describe("recorded user steps reach the exported trace", () => { const tabsApi = makeTabsApi(); const sendToTab = vi.fn(async () => ({ ok: true })); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp: makeFakeCdp() }, - ); + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() }); const destination = "https://example.com/next"; chromeApi.webNavigationOnCommitted.emit({ @@ -384,7 +380,7 @@ describe("recorded user steps reach the exported trace", () => { await new Promise((resolve) => setTimeout(resolve, 0)); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps).toHaveLength(1); const [step] = trace.steps; @@ -404,19 +400,15 @@ describe("recorded user steps reach the exported trace", () => { }); let requestId = ""; - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp: makeFakeCdp([ - axTree("Example Domain", ["Learn more"]), - axTree("腾讯 iWiki"), - axTree("工作台 - 腾讯iWiki", ["文档C+D"]), - ]), - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp: makeFakeCdp([ + axTree("Example Domain", ["Learn more"]), + axTree("腾讯 iWiki"), + axTree("工作台 - 腾讯iWiki", ["文档C+D"]), + ]), + }); attachRecordStepListener({ tabsApi, sendToTab }); const commit = (url: string, transitionType: string) => { @@ -446,7 +438,7 @@ describe("recorded user steps reach the exported trace", () => { }); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.states.map((state) => state.url)).toEqual([ START_URL, @@ -481,20 +473,16 @@ describe("recorded user steps reach the exported trace", () => { const callbackUrl = "https://passport.example/callback"; const dashboardUrl = "https://app.example/dashboard"; - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp: makeFakeCdp([ - axTree("Example Domain"), - axTree("OA登录", ["发起验证"]), - axTree("工作台", ["新建"]), - axTree("工作台", ["新建", "文档"]), - ]), - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp: makeFakeCdp([ + axTree("Example Domain"), + axTree("OA登录", ["发起验证"]), + axTree("工作台", ["新建"]), + axTree("工作台", ["新建", "文档"]), + ]), + }); attachRecordStepListener({ tabsApi, sendToTab }); const commit = (url: string, transitionType: string, transitionQualifiers: string[] = []) => { @@ -527,7 +515,7 @@ describe("recorded user steps reach the exported trace", () => { await settleWait(); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; // Login → dashboard may collapse into one navigate in the reducer; what // matters is the click is bound to the dashboard observation, not login. @@ -554,15 +542,11 @@ describe("recorded user steps reach the exported trace", () => { }); let requestId = ""; - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp: makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布")]), - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp: makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布")]), + }); attachRecordStepListener({ tabsApi, sendToTab }); // Click 发布, the page navigates to the published doc, and the user hits @@ -583,7 +567,7 @@ describe("recorded user steps reach the exported trace", () => { } as unknown as chrome.webNavigation.WebNavigationTransitionCallbackDetails); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["click"]); expect(trace.steps[0]).toMatchObject({ target: { name: "发布" } }); @@ -602,15 +586,11 @@ describe("recorded user steps reach the exported trace", () => { // Only the initial observation succeeds; every later capture throws. const cdp = makeFakeCdp([axTree("编辑", ["发布"])]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp, - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp, + }); attachRecordStepListener({ tabsApi, sendToTab }); runtimeOnMessageEmit(chromeApi, requestId, { @@ -623,7 +603,7 @@ describe("recorded user steps reach the exported trace", () => { await settleWait(); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; // No post-action observation exists anywhere, but the action still happened. expect(trace.steps.map((step) => step.op)).toEqual(["click"]); @@ -643,15 +623,11 @@ describe("recorded user steps reach the exported trace", () => { let requestId = ""; const cdp = makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布", ["编辑"])]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp, - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp, + }); attachRecordStepListener({ tabsApi, sendToTab }); runtimeOnMessageEmit(chromeApi, requestId, { @@ -668,7 +644,7 @@ describe("recorded user steps reach the exported trace", () => { await settleWait(); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["click"]); expect(trace.steps[0]?.result.state).toBe("s2"); @@ -687,15 +663,11 @@ describe("recorded user steps reach the exported trace", () => { let requestId = ""; const cdp = makeFakeCdp([axTree("编辑", ["发布"]), axTree("已发布", ["编辑"])]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp, - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp, + }); attachRecordStepListener({ tabsApi, sendToTab }); runtimeOnMessageEmit(chromeApi, requestId, { @@ -712,7 +684,7 @@ describe("recorded user steps reach the exported trace", () => { cdp.setCaptureFailure(false); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["click"]); expect(trace.steps[0]?.state).toBe("s1"); @@ -737,15 +709,11 @@ describe("recorded user steps reach the exported trace", () => { axTree("分组 已展开", ["添加配置", "确认"]), axTree("停止时的页面", ["无关"]), ]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp, - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp, + }); attachRecordStepListener({ tabsApi, sendToTab }); runtimeOnMessageEmit(chromeApi, requestId, { @@ -777,7 +745,7 @@ describe("recorded user steps reach the exported trace", () => { // landing state of an earlier step. tabsApi.goTo("https://example.com/elsewhere", "停止时的页面"); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["click", "click"]); const [first, second] = trace.steps; @@ -803,15 +771,11 @@ describe("recorded user steps reach the exported trace", () => { axTree("分组 已展开", ["确认"]), axTree("停止时的页面", ["无关"]), ]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp, - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp, + }); attachRecordStepListener({ tabsApi, sendToTab }); // First click: its own settle never produces an observation. @@ -836,7 +800,7 @@ describe("recorded user steps reach the exported trace", () => { tabsApi.goTo("https://example.com/elsewhere", "停止时的页面"); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["click", "click"]); const [first, second] = trace.steps; @@ -858,11 +822,7 @@ describe("recorded user steps reach the exported trace", () => { let requestId = ""; const cdp = makeFakeCdp([axTree("列表", ["打开"]), axTree("详情", ["返回"])]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp }, - ); + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp }); attachRecordStepListener({ tabsApi, sendToTab }); // The click starts a render that runs well past any fixed settle delay. @@ -876,7 +836,7 @@ describe("recorded user steps reach the exported trace", () => { await new Promise((resolve) => setTimeout(resolve, 2_000)); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; const [step] = trace.steps; const landing = trace.states.find((state) => state.id === step?.result.state); @@ -898,11 +858,7 @@ describe("recorded user steps reach the exported trace", () => { axTree("编辑器 弹窗", ["输入标题", "确定"]), axTree("编辑器", ["发布"]), ]); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp }, - ); + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp }); attachRecordStepListener({ tabsApi, sendToTab }); // Typing keeps the page busy, so the fill has not settled when the user @@ -924,7 +880,7 @@ describe("recorded user steps reach the exported trace", () => { await new Promise((resolve) => setTimeout(resolve, 1_500)); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps.map((step) => step.op)).toEqual(["fill", "click"]); const [fill, click] = trace.steps; @@ -944,19 +900,15 @@ describe("recorded user steps reach the exported trace", () => { }); let requestId = ""; - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp: makeFakeCdp([ - axTree("Example Domain", ["Submit"]), - axTree("Loading"), - axTree("Done", ["Next"]), - ]), - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp: makeFakeCdp([ + axTree("Example Domain", ["Submit"]), + axTree("Loading"), + axTree("Done", ["Next"]), + ]), + }); attachRecordStepListener({ tabsApi, sendToTab }); runtimeOnMessageEmit(chromeApi, requestId, { @@ -974,7 +926,7 @@ describe("recorded user steps reach the exported trace", () => { runtimeOnMessageEmit(chromeApi, requestId, { op: "click", page_url: START_URL }); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps).toHaveLength(1); expect(trace.steps[0]).toMatchObject({ @@ -1006,15 +958,11 @@ describe("recorded user steps reach the exported trace", () => { return { ok: true }; }); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp: makeFakeCdp() }, - ); + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() }); attachRecordStepListener({ tabsApi, sendToTab }); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps).toEqual([expect.objectContaining({ op: "fill", value: "saved at stop" })]); }); @@ -1027,11 +975,11 @@ describe("recorded user steps reach the exported trace", () => { const intermediateUrl = "https://idp.example/callback"; const finalUrl = "https://app.example/home"; - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp: makeFakeCdp([axTree("Start"), axTree("Home")]) }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp: makeFakeCdp([axTree("Start"), axTree("Home")]), + }); tabsApi.goTo(finalUrl, "Home"); chromeApi.webNavigationOnCommitted.emit({ @@ -1044,7 +992,7 @@ describe("recorded user steps reach the exported trace", () => { await Promise.resolve(); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; expect(trace.steps).toEqual([expect.objectContaining({ op: "navigate", to: finalUrl })]); }, 15_000); @@ -1062,15 +1010,11 @@ describe("recorded user steps reach the exported trace", () => { const intermediateUrl = "https://example.com/loading"; const finalUrl = "https://example.com/result"; - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { - tabsApi, - sendToTab, - cdp: makeFakeCdp([axTree("Search", ["Go"]), axTree("Result", ["Open"])]), - }, - ); + await handleRecordStart(manager, RECORD_START_V3, { + tabsApi, + sendToTab, + cdp: makeFakeCdp([axTree("Search", ["Go"]), axTree("Result", ["Open"])]), + }); attachRecordStepListener({ tabsApi, sendToTab }); runtimeOnMessageEmit(chromeApi, requestId, { @@ -1090,7 +1034,7 @@ describe("recorded user steps reach the exported trace", () => { tabsApi.goTo(finalUrl, "Result"); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, { tabsApi, sendToTab }); - const trace = (stopped as RecordStopResult).trace as Trace; + const trace = (stopped as RecordStopResult).trace as TraceV3; const resultState = trace.states.find((state) => state.id === trace.steps[0]?.result.state); expect(resultState?.url).toBe(finalUrl); @@ -1119,11 +1063,7 @@ describe("recorded user steps reach the exported trace", () => { return { ok: true }; }); - await handleRecordStart( - manager, - { session_id: "abcd", url: START_URL }, - { tabsApi, sendToTab, cdp: makeFakeCdp() }, - ); + await handleRecordStart(manager, RECORD_START_V3, { tabsApi, sendToTab, cdp: makeFakeCdp() }); attachRecordFinishListener({ tabsApi, sendToTab }); chromeApi.runtimeOnMessage.emit( { type: RECORD_FINISH, requestId }, @@ -1136,7 +1076,7 @@ describe("recorded user steps reach the exported trace", () => { releaseStop(); const stopped = await cliStop; - expect((stopped as RecordStopResult).trace.stopped_by).toBe("user_finish"); + expect(asTraceV3((stopped as RecordStopResult).trace).stopped_by).toBe("user_finish"); }); it("retries STOP only on armed tabs whose final delivery did not ack", async () => { @@ -1173,7 +1113,7 @@ describe("recorded user steps reach the exported trace", () => { }); const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() }; - await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps); + await handleRecordStart(manager, RECORD_START_V3, deps); attachRecordQueryListener(deps); await new Promise((resolve) => { chromeApi.runtimeOnMessage.emit( @@ -1188,10 +1128,55 @@ describe("recorded user steps reach the exported trace", () => { expect(stopTabs).toEqual([TAB_ID, 5]); const retry = await handleRecordStop(manager, { session_id: "abcd" }, deps); - expect((retry as RecordStopResult).trace.version).toBe(3); + const retryTrace = (retry as RecordStopResult).trace; + expect("version" in retryTrace && retryTrace.version).toBe(3); expect(stopTabs).toEqual([TAB_ID, 5, 5]); }); + it("defaults to trace v2 when trace_version is omitted", async () => { + const { runtimeOnMessage } = installChrome(); + const manager = fakeManager(); + const tabsApi = makeTabsApi(); + let requestId = ""; + const sendToTab = vi.fn(async (_tabId: number, msg: unknown) => { + const typed = msg as { type?: string; requestId?: string }; + if (typed.type === RECORD_START && typed.requestId) requestId = typed.requestId; + return { ok: true }; + }); + const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() }; + + await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps); + attachRecordStepListener(deps); + runtimeOnMessage.emit( + { + type: RECORD_STEP, + requestId, + step: { + op: "click", + page_url: START_URL, + target: { role: "button", name: "Submit", tag: "button" }, + }, + }, + { tab: { id: TAB_ID } } as chrome.runtime.MessageSender, + () => {}, + ); + + const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps); + const trace = (stopped as RecordStopResult).trace; + expect("version" in trace).toBe(false); + expect("pages" in trace && trace.pages.length).toBeGreaterThan(0); + }); + + it("rejects unsupported trace_version values", async () => { + const manager = fakeManager(); + const result = await handleRecordStart( + manager, + { session_id: "abcd", url: START_URL, trace_version: 99 }, + { tabsApi: makeTabsApi(), sendToTab: vi.fn(), cdp: makeFakeCdp() }, + ); + expect(result).toMatchObject({ code: "invalid_params" }); + }); + it("does not let a closed armed tab block finish", async () => { const chromeApi = installChrome(); const manager = fakeManager(); @@ -1217,7 +1202,7 @@ describe("recorded user steps reach the exported trace", () => { }); const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() }; - await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps); + await handleRecordStart(manager, RECORD_START_V3, deps); attachRecordQueryListener(deps); await new Promise((resolve) => { chromeApi.runtimeOnMessage.emit( @@ -1230,7 +1215,8 @@ describe("recorded user steps reach the exported trace", () => { const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps); - expect((stopped as RecordStopResult).trace.version).toBe(3); + const stoppedTrace = (stopped as RecordStopResult).trace; + expect("version" in stoppedTrace && stoppedTrace.version).toBe(3); expect(stopTabs).toEqual([TAB_ID]); }); @@ -1272,7 +1258,7 @@ describe("recorded user steps reach the exported trace", () => { }); const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() }; - await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps); + await handleRecordStart(manager, RECORD_START_V3, deps); attachRecordQueryListener(deps); chromeApi.runtimeOnMessage.emit( { type: RECORD_QUERY }, @@ -1286,7 +1272,8 @@ describe("recorded user steps reach the exported trace", () => { releaseSecondStart(); const stopped = await stopping; - expect((stopped as RecordStopResult).trace.version).toBe(3); + const stoppedTrace = (stopped as RecordStopResult).trace; + expect("version" in stoppedTrace && stoppedTrace.version).toBe(3); expect(stopTabs).toEqual([TAB_ID, 5]); }); @@ -1331,7 +1318,7 @@ describe("recorded user steps reach the exported trace", () => { }); const deps = { tabsApi, sendToTab, cdp }; - await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps); + await handleRecordStart(manager, RECORD_START_V3, deps); attachRecordStepListener(deps); runtimeOnMessageEmit(chromeApi, requestId, { op: "click", @@ -1353,7 +1340,7 @@ describe("recorded user steps reach the exported trace", () => { ]); releaseLookup(); const stopped = earlyStop?.result ?? (await stopping); - const trace = (stopped as RecordStopResult).trace; + const trace = asTraceV3((stopped as RecordStopResult).trace); expect(trace.steps.some((step) => step.op === "navigate" && step.to === secondUrl)).toBe(true); const navigation = trace.steps.find((step) => step.op === "navigate" && step.to === secondUrl); @@ -1390,7 +1377,7 @@ describe("recorded user steps reach the exported trace", () => { const sendToTab = vi.fn(async () => ({ ok: true })); const deps = { tabsApi, sendToTab, cdp }; - await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps); + await handleRecordStart(manager, RECORD_START_V3, deps); chromeApi.webNavigationOnCommitted.emit({ tabId: 5, frameId: 0, @@ -1401,7 +1388,7 @@ describe("recorded user steps reach the exported trace", () => { await settleWait(); const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps); - const trace = (stopped as RecordStopResult).trace; + const trace = asTraceV3((stopped as RecordStopResult).trace); const navigation = trace.steps.find((step) => step.op === "navigate" && step.to === secondUrl); const landed = trace.states.find((state) => state.id === navigation?.result.state); diff --git a/apps/extension/src/tools/record.ts b/apps/extension/src/tools/record.ts index 5438cf7..ce7d2a8 100644 --- a/apps/extension/src/tools/record.ts +++ b/apps/extension/src/tools/record.ts @@ -35,20 +35,22 @@ import { settleUnsettledDrafts, } from "@/lib/record-observation"; import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer"; +import { buildTraceV2 } from "@/lib/trace-reducer-v2"; import type { SessionManager } from "@/session-manager/manager"; import { EXTENSION_VERSION } from "@/transport/handshake"; import type { DraftTraceStep, RecordAwaitParams, RecordAwaitResult, + RecordedTrace, RecordStartParams, RecordStartResult, RecordStopParams, RecordStopResult, RpcError, StopReason, - Trace, } from "@/transport/types"; +import { TRACE_VERSION } from "@/transport/types"; import { handleNavigate } from "./navigation"; import { type CdpRunner, @@ -68,8 +70,9 @@ interface ActiveRecording { steps: DraftTraceStep[]; startedAt: string; startedAtMs: number; - finishPromise: Promise; - resolveFinish: (trace: Trace) => void; + traceVersion: 2 | 3; + finishPromise: Promise; + resolveFinish: (trace: RecordedTrace) => void; rejectFinish: (err: Error) => void; settled: boolean; finishing: boolean; @@ -202,15 +205,32 @@ async function sendRecordStartWithAck( throw lastError ?? new Error("failed to start recording in content script"); } -function buildTrace(recording: ActiveRecording): Trace { - return buildTraceV3({ - obs: recording.observation, +function negotiatedTraceVersion(params: RecordStartParams): 2 | 3 | RpcError { + if (params.trace_version === undefined) return 2; + if (params.trace_version === TRACE_VERSION) return 3; + return { + code: "invalid_params", + message: `unsupported trace_version ${params.trace_version}; supported values are omitted (v2) or ${TRACE_VERSION} (v3)`, + }; +} + +function buildTrace(recording: ActiveRecording): RecordedTrace { + if (recording.traceVersion === 3) { + return buildTraceV3({ + obs: recording.observation, + steps: recording.steps, + startedAt: recording.startedAt, + ...(recording.purpose ? { purpose: recording.purpose } : {}), + startUrl: recording.startUrl, + stoppedBy: recording.stoppedBy, + bskVersion: BSK_TRACE_VERSION, + }); + } + return buildTraceV2({ steps: recording.steps, startedAt: recording.startedAt, + ...(recording.startUrl ? { startUrl: recording.startUrl } : {}), ...(recording.purpose ? { purpose: recording.purpose } : {}), - startUrl: recording.startUrl, - stoppedBy: recording.stoppedBy, - bskVersion: BSK_TRACE_VERSION, }); } @@ -750,7 +770,7 @@ async function finishRecording( sessionId: string, deps: RecordDeps, stoppedBy: StopReason, -): Promise { +): Promise { const recording = recordings.get(sessionId); if (!recording || recording.settled) return null; if (recording.finishing) return recording.finishPromise; @@ -798,6 +818,9 @@ export async function handleRecordStart( params: RecordStartParams, deps: RecordDeps = getDefaultDeps(), ): Promise { + const traceVersionOrErr = negotiatedTraceVersion(params); + if (typeof traceVersionOrErr !== "number") return traceVersionOrErr; + const ctxOrErr = lookupSession(manager, params, "record_start"); if (isRpcError(ctxOrErr)) return ctxOrErr; const ctx = ctxOrErr; @@ -814,9 +837,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: RecordedTrace) => void; let rejectFinish!: (err: Error) => void; - const finishPromise = new Promise((resolve, reject) => { + const finishPromise = new Promise((resolve, reject) => { resolveFinish = resolve; rejectFinish = reject; }); @@ -833,6 +856,7 @@ export async function handleRecordStart( steps: [], startedAt: new Date(startedAtMs).toISOString(), startedAtMs, + traceVersion: traceVersionOrErr, finishPromise, resolveFinish, rejectFinish, @@ -1055,9 +1079,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: RecordedTrace } | { error: RpcError }>((resolve) => { let settled = false; - const finish = (result: { trace: Trace } | { error: RpcError }) => { + const finish = (result: { trace: RecordedTrace } | { error: RpcError }) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); diff --git a/apps/extension/src/transport/__tests__/handshake.test.ts b/apps/extension/src/transport/__tests__/handshake.test.ts index add3476..6196f34 100644 --- a/apps/extension/src/transport/__tests__/handshake.test.ts +++ b/apps/extension/src/transport/__tests__/handshake.test.ts @@ -65,9 +65,9 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol } describe("performHandshake", () => { - it("advertises the Trace v3 compatibility boundary", () => { + it("advertises the protocol compatibility boundary", () => { expect(PROTOCOL_VERSION).toBe("1.1"); - expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.1"); + expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.0"); }); it("sends system.handshake with identity and both compat fields", async () => { @@ -81,7 +81,7 @@ describe("performHandshake", () => { version: "0.1.0", protocol_version: "1.1", min_compatible_peer: "0.0.0", - min_compatible_protocol: "1.1", + min_compatible_protocol: "1.0", }, }; }); @@ -107,7 +107,7 @@ describe("performHandshake", () => { }, }); expect(outcome.result.min_compatible_peer).toBe("0.0.0"); - expect(outcome.result.min_compatible_protocol).toBe("1.1"); + expect(outcome.result.min_compatible_protocol).toBe("1.0"); }); it("accepts legacy daemon reply with only min_compatible_peer", async () => { diff --git a/apps/extension/src/transport/handshake.ts b/apps/extension/src/transport/handshake.ts index b727f27..a869ca5 100644 --- a/apps/extension/src/transport/handshake.ts +++ b/apps/extension/src/transport/handshake.ts @@ -18,7 +18,7 @@ export const EXTENSION_VERSION: string = * Lowest **protocol** version this extension accepts (e.g. `"1.0"`). * Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`. */ -export const MIN_COMPATIBLE_PROTOCOL = "1.1"; +export const MIN_COMPATIBLE_PROTOCOL = "1.0"; /** * **Deprecated** — legacy app-semver floor for wire compat with old * daemons. New code sends `"0.0.0"`; compat decisions ignore this. diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 3e7c23c..e035248 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -663,6 +663,8 @@ export interface EmulateResult { // -------------------------------------------------------------------------- export const TRACE_VERSION = 3; +export const TRACE_VERSION_V2 = 2; +export const DEFAULT_TRACE_VERSION = 2; export const VOM_FORMAT_VERSION = 1; export interface TargetDescriptor { @@ -857,7 +859,7 @@ export type Step = }) | ({ op: "scroll" } & StepCommon); -export interface Trace { +export interface TraceV3 { version: number; recorded_at: string; started_at?: string; @@ -869,6 +871,71 @@ export interface Trace { steps: Step[]; } +/** @deprecated Use TraceV3 — kept as alias for existing call sites. */ +export type Trace = TraceV3; + +export interface TargetDescriptorV2 { + role?: string; + name?: string; + tag: string; + name_attr?: string; + placeholder?: string; + nearby_label?: string; +} + +export interface PageRef { + id: string; + url: string; + title?: string; +} + +export type StepV2 = + | { op: "navigate"; id: number; page: string; to: string; effect?: { navigated_to: string } } + | { + op: "click"; + id: number; + page: string; + target: TargetDescriptorV2; + effect?: { navigated_to: string }; + } + | { + op: "fill"; + id: number; + page: string; + target: TargetDescriptorV2; + value: string; + redacted?: boolean; + effect?: { navigated_to: string }; + } + | { + op: "select"; + id: number; + page: string; + target: TargetDescriptorV2; + selection: SelectedOption[]; + effect?: { navigated_to: string }; + } + | { + op: "press"; + id: number; + page: string; + key: string; + modifiers?: KeyModifier[]; + target?: TargetDescriptorV2; + effect?: { navigated_to: string }; + }; + +export interface TraceV2 { + recorded_at: string; + started_at?: string; + purpose?: string; + entry: TraceEntry; + pages: PageRef[]; + steps: StepV2[]; +} + +export type RecordedTrace = TraceV2 | TraceV3; + export interface RecordStartParams { session_id: string; tab_id?: number; @@ -876,6 +943,8 @@ export interface RecordStartParams { purpose?: string; max_page_tokens?: number; redact_values?: boolean; + /** Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle. */ + trace_version?: number; } export interface RecordStartResult { @@ -888,7 +957,7 @@ export interface RecordStopParams { } export interface RecordStopResult { - trace: Trace; + trace: RecordedTrace; } export interface RecordAwaitParams { @@ -897,5 +966,5 @@ export interface RecordAwaitParams { } export interface RecordAwaitResult { - trace: Trace; + trace: RecordedTrace; } diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 8546a15..56f5119 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -265,7 +265,7 @@ retry — complete the task autonomously or stop gracefully. ### Recording — `bsk record` -Capture the user's own actions in the Agent Window to a **trace v3 bundle**, for later LLM-driven automation: +Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `pages/`. ```bash bsk record start --browser \ @@ -274,13 +274,14 @@ bsk record start --browser \ [--output trace] # `--url` is optional; default https://example.com/ when omitted (must be http(s)). # Blocks until the user clicks Finish in the recording panel, then writes: -# trace/trace.json — action chain + state index (version 3) -# trace/pages/ — one `.vom.txt` observe snapshot per settled page state +# trace/trace.json — action chain (+ state index when v3) +# trace/pages/ — v3 only: one `.vom.txt` observe snapshot per settled page state bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable ``` -- **Bundle layout:** `--output` is a directory (default `./trace`) containing `trace.json` and `pages/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `pages/sN.vom.txt` using the same VOM format as `bsk observe`. +- **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `pages/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `pages/sN.vom.txt` using the same VOM format as `bsk observe`. +- **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `pages/` observation files. Update the extension for full v3 bundles. - Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer). - `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead. - `--purpose` is optional context metadata; it does **not** change what gets captured. diff --git a/crates/bsk-cli/src/cli/record.rs b/crates/bsk-cli/src/cli/record.rs index cb4dc56..e340c8b 100644 --- a/crates/bsk-cli/src/cli/record.rs +++ b/crates/bsk-cli/src/cli/record.rs @@ -9,7 +9,7 @@ use std::time::Duration; use anyhow::Context; use bsk_protocol::tools::{ RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams, - RecordStopResult, TRACE_VERSION, Trace, TraceState, + RecordStopResult, RecordedTrace, TRACE_VERSION, TRACE_VERSION_V2, Trace, TraceState, TraceV2, }; use bsk_protocol::{ErrorCode, Method}; use clap::{Args, Subcommand}; @@ -136,6 +136,7 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> purpose: args.purpose.clone(), max_page_tokens: args.max_page_tokens, redact_values: Some(args.redact_values), + trace_version: Some(TRACE_VERSION), }; let start_result = business_rpc::call::( info.sock_path.clone(), @@ -180,8 +181,8 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> // Keep write/render inside a Result so `?` cannot skip session teardown. let run_result: Result<(), CliError> = match await_result { Ok(result) => (|| { - let pages_dir = write_trace_bundle(&args.output, &result.trace)?; - render_finish(&result.trace, &args.output, &pages_dir, format) + let exported = export_recorded_trace(&args.output, &result.trace)?; + render_finish(&result.trace, &args.output, &exported, format) })(), Err(err) => Err(err), }; @@ -211,8 +212,8 @@ fn dispatch_stop(args: RecordStopArgs, format: Format) -> Result<(), CliError> { )?; let run_result: Result<(), CliError> = (|| { - let pages_dir = write_trace_bundle(&args.output, &result.trace)?; - render_stop(&result, &args.output, &pages_dir, format) + let exported = export_recorded_trace(&args.output, &result.trace)?; + render_stop(&result, &args.output, &exported, format) })(); let session_stop_result = stop_recording_session(info.sock_path, &session_id); @@ -229,6 +230,106 @@ fn record_await_ipc_timeout(timeout_ms: u32) -> Duration { .unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15)) } +struct ExportMeta { + pages_dir: Option, + trace_version: u32, + /// True when the CLI requested v3 but the extension returned legacy v2. + v2_fallback: bool, +} + +fn export_recorded_trace(output_dir: &Path, trace: &RecordedTrace) -> Result { + match trace { + RecordedTrace::V3(trace) => { + let pages_dir = write_trace_bundle(output_dir, trace)?; + Ok(ExportMeta { + pages_dir: Some(pages_dir), + trace_version: TRACE_VERSION, + v2_fallback: false, + }) + } + RecordedTrace::V2(trace) => { + write_trace_v2(output_dir, trace)?; + Ok(ExportMeta { + pages_dir: None, + trace_version: TRACE_VERSION_V2, + v2_fallback: true, + }) + } + } +} + +fn acquire_export_lock(output_dir: &Path) -> Result { + validate_bundle_directory(output_dir, "output bundle directory")?; + fs::create_dir_all(output_dir) + .with_context(|| format!("create output dir {}", output_dir.display())) + .map_err(CliError::Local)?; + let lock_path = output_dir.join(".bsk-record-export.lock"); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("open export lock {}", lock_path.display())) + .map_err(CliError::Local)?; + fs2::FileExt::lock_exclusive(&lock_file) + .with_context(|| format!("lock export bundle {}", output_dir.display())) + .map_err(CliError::Local)?; + Ok(lock_file) +} + +fn write_trace_v2(output_dir: &Path, trace: &TraceV2) -> Result<(), CliError> { + let trace_path = trace_json_path(output_dir); + let pages_dir = pages_dir_for_output(output_dir); + let json = serde_json::to_string_pretty(trace) + .context("serialize trace JSON") + .map_err(CliError::Local)?; + + let _lock = acquire_export_lock(output_dir)?; + validate_replaceable_file(&trace_path, "trace JSON")?; + + let transaction_id = uuid::Uuid::new_v4(); + let staging_dir = output_dir.join(format!(".bsk-record-stage-{transaction_id}")); + fs::create_dir_all(&staging_dir) + .with_context(|| format!("create staging dir {}", staging_dir.display())) + .map_err(CliError::Local)?; + + let staged_trace = staging_dir.join("trace.json"); + let stage_result: Result<(), CliError> = (|| { + fs::write(&staged_trace, format!("{json}\n")) + .with_context(|| format!("write staged trace to {}", staged_trace.display())) + .map_err(CliError::Local)?; + Ok(()) + })(); + if let Err(err) = stage_result { + let _ = fs::remove_dir_all(&staging_dir); + return Err(err); + } + + let backup = output_dir.join(format!(".trace.json.{transaction_id}.bak")); + match commit_staged_file(&staged_trace, &trace_path, &backup) { + Ok(old_file) => { + if let Some(old_file) = old_file { + let _ = fs::remove_file(old_file); + } + } + Err(err) => { + let _ = fs::remove_dir_all(&staging_dir); + return Err(CliError::Local(anyhow::anyhow!( + "commit v2 trace {}: {err}", + trace_path.display() + ))); + } + } + let _ = fs::remove_dir_all(&staging_dir); + + if pages_dir.is_dir() { + prune_stale_pages(&pages_dir, &HashSet::new())?; + } + + Ok(()) +} + fn pages_dir_for_output(output_dir: &Path) -> PathBuf { output_dir.join("pages") } @@ -426,21 +527,7 @@ fn write_trace_bundle(output_dir: &Path, trace: &Trace) -> Result Result Result<(), CliError> { let trace_path = trace_json_path(output_dir); match format { Format::Json => { - println!( - "{}", - serde_json::to_string(&serde_json::json!({ + let payload = match trace { + RecordedTrace::V3(t) => serde_json::json!({ "output": output_dir, "trace_json": trace_path, - "pages_dir": pages_dir, - "trace": trace, + "trace_version": exported.trace_version, + "pages_dir": exported.pages_dir, + "trace": t, "window_closed": true, - })) - .map_err(|e| CliError::Local(anyhow::anyhow!(e)))? - ); - } - Format::Human => { + }), + RecordedTrace::V2(t) => serde_json::json!({ + "output": output_dir, + "trace_json": trace_path, + "trace_version": exported.trace_version, + "pages_dir": null, + "trace": t, + "window_closed": true, + }), + }; println!( - "saved {} steps and {} states to {} (+ {})", - trace.steps.len(), - trace.states.len(), - trace_path.display(), - pages_dir.display() + "{}", + serde_json::to_string(&payload).map_err(|e| CliError::Local(anyhow::anyhow!(e)))? ); } + Format::Human => match trace { + RecordedTrace::V3(t) => { + let pages_dir = exported + .pages_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| pages_dir_for_output(output_dir).display().to_string()); + println!( + "saved {} steps and {} states to {} (+ {})", + t.steps.len(), + t.states.len(), + trace_path.display(), + pages_dir + ); + } + RecordedTrace::V2(t) => { + if exported.v2_fallback { + eprintln!( + "note: extension returned trace v2 (no page observations); update the BrowserSkill extension for v3 bundles" + ); + } + println!( + "saved {} steps to {} (trace v2)", + t.steps.len(), + trace_path.display() + ); + } + }, } Ok(()) } @@ -558,10 +675,10 @@ fn render_finish( fn render_stop( result: &RecordStopResult, output: &PathBuf, - pages_dir: &PathBuf, + exported: &ExportMeta, format: Format, ) -> Result<(), CliError> { - render_finish(&result.trace, output, pages_dir, format) + render_finish(&result.trace, output, exported, format) } #[cfg(test)] @@ -805,6 +922,52 @@ mod tests { assert!(!external_output.join("pages").exists()); } + #[test] + fn write_trace_v2_writes_single_json_and_prunes_stale_pages() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("flow"); + fs::create_dir_all(output.join("pages")).unwrap(); + fs::write(output.join("pages/s1.vom.txt"), "stale").unwrap(); + + let trace = TraceV2 { + recorded_at: "2026-08-10T02:12:55.360Z".into(), + started_at: None, + purpose: None, + entry: bsk_protocol::tools::TraceEntry { + start_url: "https://example.com/".into(), + }, + pages: vec![bsk_protocol::tools::PageRef { + id: "p1".into(), + url: "https://example.com/".into(), + title: None, + }], + steps: vec![], + }; + write_trace_v2(&output, &trace).unwrap(); + assert!(output.join("trace.json").exists()); + assert!(!output.join("pages/s1.vom.txt").exists()); + } + + #[test] + fn export_recorded_trace_accepts_v2() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("flow"); + let trace = RecordedTrace::V2(TraceV2 { + recorded_at: "2026-08-10T02:12:55.360Z".into(), + started_at: None, + purpose: None, + entry: bsk_protocol::tools::TraceEntry { + start_url: "https://example.com/".into(), + }, + pages: vec![], + steps: vec![], + }); + let exported = export_recorded_trace(&output, &trace).unwrap(); + assert_eq!(exported.trace_version, TRACE_VERSION_V2); + assert!(exported.pages_dir.is_none()); + assert!(output.join("trace.json").exists()); + } + #[test] fn record_await_ipc_timeout_covers_long_wait() { let got = record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS); diff --git a/crates/bsk-cli/src/daemon/state.rs b/crates/bsk-cli/src/daemon/state.rs index e9abced..c8fee7a 100644 --- a/crates/bsk-cli/src/daemon/state.rs +++ b/crates/bsk-cli/src/daemon/state.rs @@ -17,8 +17,8 @@ use super::ws::WsHandle; pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const PROTOCOL_VERSION: &str = "1.1"; -/// Lowest **protocol** version peers must speak (e.g. `"1.1"`). -pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.1"; +/// Lowest **protocol** version peers must speak (e.g. `"1.0"`). +pub const MIN_COMPATIBLE_PROTOCOL: &str = "1.0"; /// Legacy app-semver floor used only when `HandshakeResult.min_compatible_peer` /// is emitted for old extensions. New code ignores this on read. pub const LEGACY_MIN_COMPATIBLE_PEER: &str = "0.0.0"; diff --git a/crates/bsk-cli/tests/handshake_compat.rs b/crates/bsk-cli/tests/handshake_compat.rs index 934a0eb..7c8fd50 100644 --- a/crates/bsk-cli/tests/handshake_compat.rs +++ b/crates/bsk-cli/tests/handshake_compat.rs @@ -124,7 +124,7 @@ async fn handshake_ok_when_protocol_matches() { ); assert_eq!( result.min_compatible_protocol.as_deref(), - Some("1.1"), + Some("1.0"), "daemon must advertise protocol floor for new extensions" ); handle.shutdown().await; @@ -169,7 +169,7 @@ async fn handshake_skew_when_protocol_minor_differs() { } #[tokio::test] -async fn handshake_rejects_pre_trace_v3_extension() { +async fn handshake_skew_when_protocol_1_0_peer() { let (handle, _sock) = spawn_daemon().await; let mut ws = open_ws(handle.ws_addr()).await; let resp = send_handshake_with_floors( @@ -181,12 +181,15 @@ async fn handshake_rejects_pre_trace_v3_extension() { ) .await; match resp.body { - ResponseBody::Err(e) => { - assert_eq!(e.code, ErrorCode::VersionTooOld); - assert!(e.message.contains("below local min_compatible_protocol")); - } - other => panic!("pre-Trace-v3 extension must be rejected, got {other:?}"), + ResponseBody::Ok(_) => {} + other => panic!("protocol 1.0 peer should connect with skew, got {other:?}"), } + let state = handle.state(); + let client = state + .browsers + .get(&bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into())) + .expect("browser registered"); + assert!(client.version_skew); handle.shutdown().await; } diff --git a/crates/bsk-cli/tests/record_stop_retry.rs b/crates/bsk-cli/tests/record_stop_retry.rs index 4c5e6af..b6898d8 100644 --- a/crates/bsk-cli/tests/record_stop_retry.rs +++ b/crates/bsk-cli/tests/record_stop_retry.rs @@ -10,8 +10,8 @@ use bsk::daemon::{self, DaemonConfig}; use bsk::ipc_client::IpcClient; use bsk_protocol::system::{HandshakeParams, HandshakeResult}; use bsk_protocol::tools::{ - RecordStopResult, RecorderInfo, SessionStartParams, SessionStartResult, StopReason, Trace, - TraceEntry, TraceState, VOM_FORMAT_VERSION, + RecordStopResult, RecordedTrace, RecorderInfo, SessionStartParams, SessionStartResult, + StopReason, Trace, TraceEntry, TraceState, VOM_FORMAT_VERSION, }; use bsk_protocol::{ BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError, @@ -139,7 +139,7 @@ fn run_extension( } else { ResponseBody::Ok( serde_json::to_value(RecordStopResult { - trace: sample_trace(), + trace: RecordedTrace::V3(sample_trace()), }) .unwrap(), ) diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index a5c77a1..622e270 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -7,7 +7,7 @@ ], "properties": { "trace": { - "$ref": "#/definitions/Trace" + "$ref": "#/definitions/RecordedTrace" } }, "definitions": { @@ -41,6 +41,38 @@ "browser" ] }, + "PageRef": { + "description": "Page context dictionary entry — referenced by steps via `page` id.", + "type": "object", + "required": [ + "id", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + } + }, + "RecordedTrace": { + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" + }, + { + "$ref": "#/definitions/Trace" + } + ] + }, "RecorderInfo": { "type": "object", "required": [ @@ -76,6 +108,24 @@ } } }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "Step": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ @@ -355,6 +405,19 @@ } ] }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, "StepResult": { "type": "object", "required": [ @@ -366,6 +429,247 @@ } } }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, "StopReason": { "type": "string", "enum": [ @@ -406,6 +710,48 @@ } } }, + "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" + } + } + }, "Trace": { "description": "Persisted user-action trace exported by `tool.record_stop` / `await`.", "type": "object", @@ -512,6 +858,49 @@ "type": "string" } } + }, + "TraceV2": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRef" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + } } } } diff --git a/crates/bsk-protocol/schema/tool_record_start_params.json b/crates/bsk-protocol/schema/tool_record_start_params.json index 8f902a9..e81c261 100644 --- a/crates/bsk-protocol/schema/tool_record_start_params.json +++ b/crates/bsk-protocol/schema/tool_record_start_params.json @@ -36,6 +36,15 @@ ], "format": "int64" }, + "trace_version": { + "description": "Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, "url": { "type": [ "string", diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index 56d1ba2..49d84c0 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -7,7 +7,7 @@ ], "properties": { "trace": { - "$ref": "#/definitions/Trace" + "$ref": "#/definitions/RecordedTrace" } }, "definitions": { @@ -41,6 +41,38 @@ "browser" ] }, + "PageRef": { + "description": "Page context dictionary entry — referenced by steps via `page` id.", + "type": "object", + "required": [ + "id", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + } + }, + "RecordedTrace": { + "oneOf": [ + { + "$ref": "#/definitions/TraceV2" + }, + { + "$ref": "#/definitions/Trace" + } + ] + }, "RecorderInfo": { "type": "object", "required": [ @@ -76,6 +108,24 @@ } } }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "Step": { "description": "One recorded user action — discriminated union by `op`.", "oneOf": [ @@ -355,6 +405,19 @@ } ] }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, "StepResult": { "type": "object", "required": [ @@ -366,6 +429,247 @@ } } }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, "StopReason": { "type": "string", "enum": [ @@ -406,6 +710,48 @@ } } }, + "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" + } + } + }, "Trace": { "description": "Persisted user-action trace exported by `tool.record_stop` / `await`.", "type": "object", @@ -512,6 +858,49 @@ "type": "string" } } + }, + "TraceV2": { + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRef" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + } } } } diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json new file mode 100644 index 0000000..22b0b32 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -0,0 +1,405 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.", + "type": "object", + "required": [ + "entry", + "pages", + "recorded_at", + "steps" + ], + "properties": { + "entry": { + "$ref": "#/definitions/TraceEntry" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/PageRef" + } + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "recorded_at": { + "description": "RFC 3339 timestamp when recording stopped.", + "type": "string" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/StepV2" + } + } + }, + "definitions": { + "KeyModifier": { + "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", + "type": "string", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "PageRef": { + "description": "Page context dictionary entry — referenced by steps via `page` id.", + "type": "object", + "required": [ + "id", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + } + }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + }, + "TraceEntry": { + "description": "Recording entry point — first URL the flow starts from.", + "type": "object", + "required": [ + "start_url" + ], + "properties": { + "start_url": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index de9b7f2..2071a05 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -113,6 +113,7 @@ fn main() { dump!(RequestHelpParams, "tool_request_help_params"); dump!(RequestHelpResult, "tool_request_help_result"); + dump!(TraceV2, "trace_v2"); dump!(Trace, "trace"); dump!(Step, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index a9c04db..67e8f53 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -9,6 +9,7 @@ pub mod navigation; pub mod network; pub mod observation; pub mod record; +pub mod record_v2; pub mod script; pub mod session; pub mod tabs; diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index 5598a51..74cd634 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -8,7 +8,13 @@ use serde::{Deserialize, Serialize}; use super::interaction::KeyModifier; +pub use crate::record_v2::{ + PageRef, SelectedOptionV2, StepCommonV2, StepEffectV2, StepV2, TargetDescriptorV2, TraceV2, +}; + pub const TRACE_VERSION: u32 = 3; +pub const TRACE_VERSION_V2: u32 = 2; +pub const DEFAULT_TRACE_VERSION: u32 = 2; pub const VOM_FORMAT_VERSION: u32 = 1; // --------------------------------------------------------------------------- @@ -204,6 +210,9 @@ pub struct RecordStartParams { pub max_page_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub redact_values: Option, + /// Desired trace export format. Omitted ⇒ v2; `3` ⇒ state-linked v3 bundle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_version: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -217,9 +226,100 @@ pub struct RecordStopParams { pub session_id: String, } +/// Wire trace payload — v2 (legacy `pages[]`) or v3 (`version: 3`, `states[]`). +#[derive(Debug, Clone, PartialEq)] +pub enum RecordedTrace { + V2(TraceV2), + V3(Trace), +} + +impl RecordedTrace { + pub fn classify_value(v: &serde_json::Value) -> Result { + if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) { + if ver == u64::from(TRACE_VERSION) { + return serde_json::from_value(v.clone()) + .map(RecordedTrace::V3) + .map_err(|e| e.to_string()); + } + return Err(format!("unsupported trace version {ver}")); + } + if v.get("pages").is_some() && v.get("states").is_none() { + return serde_json::from_value(v.clone()) + .map(RecordedTrace::V2) + .map_err(|e| e.to_string()); + } + if v.get("states").is_some() { + return serde_json::from_value(v.clone()) + .map(RecordedTrace::V3) + .map_err(|e| e.to_string()); + } + Err("ambiguous or unparseable trace".into()) + } + + pub fn is_v3(&self) -> bool { + matches!(self, RecordedTrace::V3(_)) + } + + pub fn as_v3(&self) -> Option<&Trace> { + match self { + RecordedTrace::V3(t) => Some(t), + RecordedTrace::V2(_) => None, + } + } + + pub fn as_v2(&self) -> Option<&TraceV2> { + match self { + RecordedTrace::V2(t) => Some(t), + RecordedTrace::V3(_) => None, + } + } +} + +impl Serialize for RecordedTrace { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + RecordedTrace::V2(t) => t.serialize(serializer), + RecordedTrace::V3(t) => t.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RecordedTrace { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + Self::classify_value(&value).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for RecordedTrace { + fn schema_name() -> String { + "RecordedTrace".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + one_of: Some(vec![ + generator.subschema_for::(), + generator.subschema_for::(), + ]), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordStopResult { - pub trace: Trace, + pub trace: RecordedTrace, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -231,7 +331,7 @@ pub struct RecordAwaitParams { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RecordAwaitResult { - pub trace: Trace, + pub trace: RecordedTrace, } // --------------------------------------------------------------------------- @@ -441,6 +541,44 @@ mod tests { assert!(v["target"].get("ref").is_none()); } + #[test] + fn recorded_trace_classifies_v2_and_v3() { + let v2 = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [] + }); + match RecordedTrace::classify_value(&v2).unwrap() { + RecordedTrace::V2(_) => {} + other => panic!("expected v2, got {other:?}"), + } + + 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 recorded_trace_schema_includes_v2_and_v3() { + let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + let variants = schema["definitions"]["RecordedTrace"]["oneOf"] + .as_array() + .expect("RecordedTrace schema should use oneOf"); + + assert_eq!(variants.len(), 2); + } + #[test] fn extension_trace_deserializes() { let v = json!({ 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 0000000..77b3a37 --- /dev/null +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -0,0 +1,215 @@ +//! Trace v2 — record-only user-action log with `pages[]` and step `page` refs. +//! +//! Legacy wire format: no top-level `version` field. New peers omit +//! `trace_version` on `record_start` to request this shape. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::interaction::KeyModifier; +use super::record::TraceEntry; + +/// Stable semantic handle for an interacted element (v2). +/// +/// `name` and `nearby_label` are **untrusted page text**. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetDescriptorV2 { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_attr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nearby_label: Option, +} + +/// Page context dictionary entry — referenced by steps via `page` id. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PageRef { + pub id: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// One selected option (`select` op). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SelectedOptionV2 { + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +/// Observed navigation after a step (objective fact only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepEffectV2 { + /// Reference into `pages[]` for the destination page. + pub navigated_to: String, +} + +/// Fields shared by every v2 step variant (flattened in JSON). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StepCommonV2 { + pub id: u32, + /// Reference into `pages[]`. + pub page: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effect: Option, +} + +/// One recorded user action — discriminated union by `op` (v2). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum StepV2 { + Navigate { + #[serde(flatten)] + common: StepCommonV2, + to: String, + }, + Click { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + }, + Fill { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + redacted: Option, + }, + Select { + #[serde(flatten)] + common: StepCommonV2, + target: TargetDescriptorV2, + selection: Vec, + }, + Press { + #[serde(flatten)] + common: StepCommonV2, + key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + modifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, +} + +/// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TraceV2 { + /// RFC 3339 timestamp when recording stopped. + pub recorded_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, + pub entry: TraceEntry, + pub pages: Vec, + pub steps: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_common(id: u32) -> StepCommonV2 { + StepCommonV2 { + id, + page: "p1".into(), + effect: None, + } + } + + fn sample_target() -> TargetDescriptorV2 { + TargetDescriptorV2 { + role: Some("button".into()), + name: Some("发布".into()), + tag: "button".into(), + name_attr: None, + placeholder: None, + nearby_label: None, + } + } + + #[test] + fn trace_v2_has_no_version_field() { + let trace = TraceV2 { + recorded_at: "2026-07-17T09:01:10Z".into(), + started_at: None, + purpose: None, + entry: TraceEntry { + start_url: "https://example.com/".into(), + }, + pages: vec![PageRef { + id: "p1".into(), + url: "https://example.com/".into(), + title: None, + }], + steps: vec![StepV2::Click { + common: sample_common(1), + target: sample_target(), + }], + }; + let v = serde_json::to_value(&trace).unwrap(); + assert!(v.get("version").is_none()); + assert!(v.get("pages").is_some()); + assert!(v.get("states").is_none()); + } + + #[test] + fn extension_v2_trace_deserializes() { + let v = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "started_at": "2026-07-21T07:59:00Z", + "purpose": "demo", + "entry": { "start_url": "https://example.com/editor" }, + "pages": [ + { "id": "p1", "url": "https://example.com/editor" }, + { "id": "p2", "url": "https://example.com/p/99" } + ], + "steps": [ + { + "op": "fill", + "id": 1, + "page": "p1", + "target": { "tag": "input", "role": "textbox", "name": "标题" }, + "value": "hello" + }, + { + "op": "click", + "id": 2, + "page": "p1", + "target": { "tag": "button", "role": "button", "name": "发布" }, + "effect": { "navigated_to": "p2" } + } + ] + }); + let trace: TraceV2 = serde_json::from_value(v).unwrap(); + assert_eq!(trace.pages.len(), 2); + assert_eq!(trace.steps.len(), 2); + } + + #[test] + fn historical_v2_rejects_hover_steps() { + let value = json!({ + "recorded_at": "2026-07-21T08:00:00Z", + "entry": { "start_url": "https://example.com/" }, + "pages": [{ "id": "p1", "url": "https://example.com/" }], + "steps": [{ + "op": "hover", + "id": 1, + "page": "p1", + "target": { "tag": "button", "name": "结束" } + }] + }); + + assert!(serde_json::from_value::(value).is_err()); + } +} diff --git a/skill/SKILL.md b/skill/SKILL.md index 8546a15..56f5119 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -265,7 +265,7 @@ retry — complete the task autonomously or stop gracefully. ### Recording — `bsk record` -Capture the user's own actions in the Agent Window to a **trace v3 bundle**, for later LLM-driven automation: +Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `pages/`. ```bash bsk record start --browser \ @@ -274,13 +274,14 @@ bsk record start --browser \ [--output trace] # `--url` is optional; default https://example.com/ when omitted (must be http(s)). # Blocks until the user clicks Finish in the recording panel, then writes: -# trace/trace.json — action chain + state index (version 3) -# trace/pages/ — one `.vom.txt` observe snapshot per settled page state +# trace/trace.json — action chain (+ state index when v3) +# trace/pages/ — v3 only: one `.vom.txt` observe snapshot per settled page state bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable ``` -- **Bundle layout:** `--output` is a directory (default `./trace`) containing `trace.json` and `pages/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `pages/sN.vom.txt` using the same VOM format as `bsk observe`. +- **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `pages/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `pages/sN.vom.txt` using the same VOM format as `bsk observe`. +- **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `pages/` observation files. Update the extension for full v3 bundles. - Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer). - `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead. - `--purpose` is optional context metadata; it does **not** change what gets captured.