Compare commits

..
Author SHA1 Message Date
Zhang GH d05279f991 Merge pull request #98 from NianJiuZst/codex/fix-daemon-idle-close-race
fix(daemon): make idle disconnect updates atomic
2026-08-23 21:25:24 +08:00
Zhang GH 2719e76438 Merge pull request #97 from NianJiuZst/codex/cancel-session-lifecycle-rpcs
fix(session): cancel lifecycle RPCs cooperatively
2026-08-22 14:51:38 +08:00
polarday dd9fc9edba Merge pull request #100 from Tencent/feat/record-vom-observation
feat(vom): expose record-safe semantic observations
2026-08-21 16:21:41 +08:00
Ljy-0827 005a6fd534 fix(vom): add record safe observations, unify definition and construction of safe DTO 2026-08-21 11:26:52 +08:00
“haonan” 20554508db fix(vom): omit raw AX/DOM trees from record-safe observations
Return only frame identity and node geometry for matching so a serialized capture result cannot leak form values.
2026-08-20 20:45:19 +08:00
NianJiuZst 8352c3c67a fix(daemon): refresh stale idle-task comment
After folding the IPC connection count and activity timestamp into a
single mutex-protected IpcActivityState, the idle task no longer
references the connection count directly; should_exit_for_idle already
returns false while a connection is open or within the idle window.
Update the comment to describe the post-refactor control flow so it
matches the code.
2026-08-20 13:53:23 +08:00
NianJiuZst 4eedd98c1d fix(daemon): make idle disconnect updates atomic 2026-08-20 13:53:23 +08:00
“haonan” 6aa931b60a chore: retrigger opensource scan 2026-08-19 19:56:07 +08:00
Ljy-0827 3df2738280 fix(vom): fix CI 2026-08-19 15:05:55 +08:00
Ljy-0827 f86e506d47 feat(vom): update contract 2026-08-19 15:05:55 +08:00
“haonan” 45a246c41b feat(vom): expose record-safe semantic observations
Share the VOM capture pipeline with recording, enrich rendered refs, and support value redaction without regressing cooperative cancellation.
2026-08-19 15:05:55 +08:00
NianJiuZst 7035e46024 fix(session): cancel lifecycle RPCs cooperatively 2026-08-17 15:13:53 +08:00
22 changed files with 1702 additions and 175 deletions
@@ -203,6 +203,55 @@ describe("ToolDispatcher", () => {
expect(sent[0]).toEqual({ id: "r-1", result: {} });
});
it("forwards daemon cancellation into an in-flight session stop", async () => {
const { transport, sent, deliver } = fakeTransport();
const remove = vi.fn(async () => {});
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove,
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
let finishDetach: () => void = () => {};
const detach = new Promise<void>((resolve) => {
finishDetach = resolve;
});
const cdp = {
send: vi.fn(),
detachSession: vi.fn(() => detach),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
setDeviceMetricsOverride: vi.fn(async () => {}),
clearDeviceMetricsOverride: vi.fn(async () => {}),
setUserAgentOverride: vi.fn(async () => {}),
setTouchEmulationEnabled: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
dispatcher.start();
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
await vi.waitFor(() => expect(cdp.detachSession).toHaveBeenCalledWith("aa11"));
deliver({ id: "cancel-stop", method: "cancel", params: { rpc_id: "r-1" } });
await flushMicrotasks();
finishDetach();
await flushMicrotasks();
expect(sent).toContainEqual({ id: "cancel-stop", result: { cancelled: true } });
expect(sent).toContainEqual({
id: "r-1",
error: expect.objectContaining({ code: "cancelled" }),
});
expect(sessions.has("aa11")).toBe(true);
expect(remove).not.toHaveBeenCalled();
});
it("returns not_found when stopping an unknown session", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
@@ -8,6 +8,7 @@ import {
buildFrameVomScene,
buildVomScene,
type CdpAxNode,
captureVomObservation,
handleGetHtml,
handleObserve,
handleScreenshot,
@@ -1329,7 +1330,7 @@ describe("buildVomScene", () => {
expect(rendered).toContain(' @e2 textbox "验证码"');
});
it("does not mark non-password inputs sensitive from password-like labels", () => {
it("marks current-password autocomplete as sensitive even for text inputs", () => {
const axNodes: CdpAxNode[] = [
{
nodeId: "1",
@@ -1364,7 +1365,7 @@ describe("buildVomScene", () => {
expect(buildVomScene(axNodes, captured).nodes[0]).toEqual(
expect.objectContaining({
id: 10,
sensitive: false,
sensitive: true,
}),
);
});
@@ -1659,7 +1660,9 @@ describe("buildVomScene", () => {
);
const rendered = renderVom(scene);
expect(rendered.text).toContain('@e1 button "close"');
expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
{ ref: "e1", backendNodeId: 20 },
]);
});
it("does not promote a clickable container that wraps a real interactive control", () => {
@@ -1803,7 +1806,9 @@ describe("buildVomScene", () => {
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
const rendered = renderVom(scene);
expect(rendered.text).toContain('@e1 button "收藏"');
expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([
{ ref: "e1", backendNodeId: 20 },
]);
});
it("builds active scope blocks from active aria-controls relationships", () => {
@@ -2904,10 +2909,360 @@ describe("handleSnapshot", () => {
],
};
}
function secretsSnapshotReply(secrets: {
email: string;
password: string;
otp: string;
card: string;
}) {
const S = [
"html",
"body",
"input",
"position",
"static",
"pointer-events",
"auto",
"type",
"text",
"password",
"autocomplete",
"one-time-code",
"cc-number",
"value",
secrets.email,
secrets.password,
secrets.otp,
secrets.card,
];
const i = (s: string) => S.indexOf(s);
const style = [i("static"), i("auto"), i("auto")];
return {
strings: S,
documents: [
{
nodes: {
parentIndex: [-1, 0, 1, 1, 1, 1],
nodeName: [i("html"), i("body"), i("input"), i("input"), i("input"), i("input")],
backendNodeId: [10, 11, 12, 13, 14, 15],
attributes: [
[],
[],
[i("type"), i("text"), i("value"), i(secrets.email)],
[i("type"), i("password"), i("value"), i(secrets.password)],
[
i("type"),
i("text"),
i("autocomplete"),
i("one-time-code"),
i("value"),
i(secrets.otp),
],
[
i("type"),
i("text"),
i("autocomplete"),
i("cc-number"),
i("value"),
i(secrets.card),
],
],
inputValue: {
index: [2, 3, 4, 5],
value: [i(secrets.email), i(secrets.password), i(secrets.otp), i(secrets.card)],
},
},
layout: {
nodeIndex: [1, 2, 3, 4, 5],
styles: [style, style, style, style, style],
bounds: [
[0, 0, 1000, 800],
[10, 10, 200, 32],
[10, 50, 200, 32],
[10, 90, 200, 32],
[10, 130, 200, 32],
],
paintOrders: [0, 1, 2, 3, 4],
},
},
],
};
}
const VP_METRICS = {
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
};
function makeFrameAwareDeps() {
const strings = ["html", "body", "iframe", "button", "title", "Remote", "static", "auto"];
const i = (value: string) => strings.indexOf(value);
const styles = [i("static"), i("auto"), i("auto")];
const snapshot = {
strings,
documents: [
{
frameId: "main",
nodes: {
parentIndex: [-1, 0, 1],
nodeName: [i("html"), i("body"), i("iframe")],
backendNodeId: [10, 11, 12],
attributes: [[], [], [i("title"), i("Remote")]],
contentDocumentIndex: { index: [2], value: [1] },
},
layout: {
nodeIndex: [0, 1, 2],
styles: [styles, styles, styles],
bounds: [
[0, 0, 1000, 800],
[0, 0, 1000, 800],
[100, 100, 400, 300],
],
paintOrders: [0, 0, 1],
},
},
{
frameId: "child",
nodes: {
parentIndex: [-1, 0, 1],
nodeName: [i("html"), i("body"), i("button")],
backendNodeId: [20, 21, 22],
attributes: [[], [], []],
},
layout: {
nodeIndex: [0, 1, 2],
styles: [styles, styles, styles],
bounds: [
[0, 0, 400, 300],
[0, 0, 400, 300],
[20, 30, 120, 40],
],
paintOrders: [0, 0, 1],
},
},
],
};
const mainAx: CdpAxNode[] = [
{
nodeId: "main-root",
role: { type: "role", value: "RootWebArea" },
backendDOMNodeId: 11,
childIds: ["frame-owner"],
},
{
nodeId: "frame-owner",
parentId: "main-root",
role: { type: "role", value: "Iframe" },
name: { type: "computedString", value: "Remote" },
backendDOMNodeId: 12,
},
];
const childAx: CdpAxNode[] = [
{
nodeId: "child-root",
role: { type: "role", value: "RootWebArea" },
backendDOMNodeId: 21,
childIds: ["child-button"],
},
{
nodeId: "child-button",
parentId: "child-root",
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Frame action" },
backendDOMNodeId: 22,
},
];
const send = vi.fn(async (_tabId: number, method: string) => {
if (method === "Page.getLayoutMetrics") return VP_METRICS;
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
if (method === "Accessibility.getFullAXTree") return { nodes: mainAx };
throw new Error(`unexpected root CDP method ${method}`);
});
const sendToTarget = vi.fn(async (_target, method: string) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: childAx };
throw new Error(`unexpected child CDP method ${method}`);
});
const cdp = {
send: send as unknown as CdpRunner["send"],
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 12,
target: { tabId: 4, sessionId: "child-session" },
},
],
})),
trackSessionTab: vi.fn(),
} satisfies CdpRunner;
return {
cdp,
tabsApi: {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
};
}
it("exposes the frame-aware observation result for recording", async () => {
const ax: CdpAxNode[] = [
{
nodeId: "root",
role: { type: "role", value: "RootWebArea" },
backendDOMNodeId: 11,
childIds: ["password"],
},
{
nodeId: "password",
parentId: "root",
role: { type: "role", value: "textbox" },
name: { type: "computedString", value: "Password" },
value: { value: "hunter2" },
backendDOMNodeId: 13,
},
];
const deps = makeOverlayDeps(ax, loginSnapshotReply(), VP_METRICS);
const result = await captureVomObservation(deps.cdp, 4, "https://example.com", {
redactValues: true,
});
expect(result.text).not.toContain("hunter2");
expect(result.text).toContain("•••");
expect(result.refs[0]).toMatchObject({
backendNodeId: 13,
role: "textbox",
name: "Password",
line: expect.any(Number),
});
expect(result.rootFrameId).toBe("root");
expect(result).not.toHaveProperty("frameDocuments");
expect(result.frames).toEqual([{ frameId: "root", target: { tabId: 4 } }]);
expect(result.matchNodes.find((node) => node.backendNodeId === 13)).toMatchObject({
frameId: "root",
backendNodeId: 13,
tag: "input",
rect: { x: 400, y: 300, w: 200, h: 40 },
localRect: { x: 400, y: 300, w: 200, h: 40 },
});
});
it("does not leak form secrets through the record-safe observation payload", async () => {
const secrets = {
email: "user@example.com",
password: "hunter2",
otp: "847291",
card: "4111111111111111",
};
const ax: CdpAxNode[] = [
{
nodeId: "root",
role: { type: "role", value: "RootWebArea" },
backendDOMNodeId: 11,
childIds: ["email", "password", "otp", "card"],
},
{
nodeId: "email",
parentId: "root",
role: { type: "role", value: "textbox" },
name: { type: "computedString", value: "Email" },
value: { value: secrets.email },
backendDOMNodeId: 12,
},
{
nodeId: "password",
parentId: "root",
role: { type: "role", value: "textbox" },
name: { type: "computedString", value: "Password" },
value: { value: secrets.password },
backendDOMNodeId: 13,
},
{
nodeId: "otp",
parentId: "root",
role: { type: "role", value: "textbox" },
name: { type: "computedString", value: "Code" },
value: { value: secrets.otp },
backendDOMNodeId: 14,
},
{
nodeId: "card",
parentId: "root",
role: { type: "role", value: "textbox" },
name: { type: "computedString", value: "Card" },
value: { value: secrets.card },
backendDOMNodeId: 15,
},
];
const deps = makeOverlayDeps(ax, secretsSnapshotReply(secrets), VP_METRICS);
const result = await captureVomObservation(deps.cdp, 4, "https://example.com", {
redactValues: true,
});
const dumped = JSON.stringify(result);
expect(result).not.toHaveProperty("frameDocuments");
expect(dumped).not.toContain(secrets.email);
expect(dumped).not.toContain(secrets.password);
expect(dumped).not.toContain(secrets.otp);
expect(dumped).not.toContain(secrets.card);
expect(dumped).not.toContain("formValue");
expect(dumped).not.toContain("formDefaultValue");
expect(dumped).not.toContain("axNodes");
expect(dumped).not.toContain("domNodes");
expect(dumped).not.toContain("attrs");
expect(result.text).toContain("•••");
});
it("keeps frames and rendered refs on the same frame identity", async () => {
const deps = makeFrameAwareDeps();
const result = await captureVomObservation(deps.cdp, 4, "https://example.com");
expect(result.rootFrameId).toBe("main");
expect(result.frames).toHaveLength(2);
expect(result.frames.find((frame) => frame.frameId === "child")).toEqual({
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 12,
target: { tabId: 4, sessionId: "child-session" },
});
expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({
frameId: "child",
tag: "button",
localRect: { x: 20, y: 30, w: 120, h: 40 },
});
expect(result.refs.find((ref) => ref.backendNodeId === 22)).toMatchObject({
frameId: "child",
name: "Frame action",
});
});
it("stores iframe refs with their owning CDP session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const deps = makeFrameAwareDeps();
const result = await handleSnapshot(sm, { session_id: "aa11" }, deps);
if ("code" in result) throw new Error(`unexpected error: ${JSON.stringify(result)}`);
expect(result.text).toContain('@e1 button "Frame action"');
const frameRef = [...ctx.refStore.entries()].find(([, entry]) => entry.backendNodeId === 22);
expect(frameRef?.[1]).toMatchObject({
tabId: 4,
frameId: "child",
cdpSessionId: "child-session",
});
});
it("renders a blocking login overlay as the focused top layer", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
@@ -66,6 +66,52 @@ function makeApis(
}
describe("handleSessionStop with auto-return", () => {
it("leaves the session untouched when cancellation arrived before teardown", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
await sm.start("aa11");
const cdp = { detachSession: vi.fn(async () => {}) };
const controller = new AbortController();
controller.abort();
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ cdp, signal: controller.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(sm.has("aa11")).toBe(true);
expect(cdp.detachSession).not.toHaveBeenCalled();
expect(aw.remove).not.toHaveBeenCalled();
});
it("does not close the Agent Window when cancellation lands during CDP detach", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
await sm.start("aa11");
const controller = new AbortController();
let finishDetach: () => void = () => {};
const detach = new Promise<void>((resolve) => {
finishDetach = resolve;
});
const cdp = { detachSession: vi.fn(() => detach) };
const stopping = handleSessionStop(
sm,
{ session_id: "aa11" },
{ cdp, signal: controller.signal },
);
await vi.waitFor(() => expect(cdp.detachSession).toHaveBeenCalledWith("aa11"));
controller.abort();
finishDetach();
const res = await stopping;
expect(res).toMatchObject({ code: "cancelled" });
expect(sm.has("aa11")).toBe(true);
expect(aw.remove).not.toHaveBeenCalled();
});
it("returns every borrowed tab and closes the Agent Window in the right order", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
@@ -0,0 +1,9 @@
export {
type CaptureVomObservationOptions,
captureVomObservation,
} from "./observation";
export {
type CaptureVomFrame,
type CaptureVomMatchNode,
type CaptureVomObservationResult,
} from "./vom/record-safe-observation";
+1
View File
@@ -290,6 +290,7 @@ export class ToolDispatcher {
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
cdp: this.cdp,
signal,
});
}
case "tool.tab_list":
+81 -45
View File
@@ -10,6 +10,7 @@ import {
isVomReferenceNode,
renderVom,
type VomNode,
type VomOptions,
type VomScene,
} from "@browser-skill/vom";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
@@ -53,6 +54,10 @@ import {
collectOverlayExcludedBackendIds,
} from "./vom/capture";
import { type CapturedFrameDocument, captureFrameData } from "./vom/frame-capture";
import {
type CaptureVomObservationResult,
projectRecordSafeObservation,
} from "./vom/record-safe-observation";
// ---------------------------------------------------------------------------
// Shared helpers (legacy aliases — observation.ts kept exporting these
@@ -411,7 +416,15 @@ function isModalSignal(
function isSensitive(capturedNode: CapturedNode | undefined): boolean {
const attrs = capturedNode?.attrs ?? {};
return (attrs.type ?? "").toLowerCase() === "password";
const type = (attrs.type ?? "").toLowerCase();
if (type === "password") return true;
const autocomplete = (attrs.autocomplete ?? "").toLowerCase();
return (
autocomplete.startsWith("cc-") ||
autocomplete === "one-time-code" ||
autocomplete === "current-password" ||
autocomplete === "new-password"
);
}
interface AxNodeSignals {
@@ -461,7 +474,13 @@ const AX_TEXT_STOP_ROLES = new Set([
"columnheader",
"rowheader",
]);
const SENSITIVE_AX_INPUT_TYPES = new Set(["password", "credit-card"]);
const SENSITIVE_AX_INPUT_TYPES = new Set([
"password",
"credit-card",
"one-time-code",
"current-password",
"new-password",
]);
function axPropertyString(axNode: CdpAxNode, name: string): string | undefined {
const prop = axNode.properties?.find((item) => item.name === name);
@@ -1537,14 +1556,13 @@ async function fallbackCapturedViewModel(
try {
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
throwIfAborted(signal, "observation");
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
const source = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
viewport = {
width: vpSrc.clientWidth ?? 0,
height: vpSrc.clientHeight ?? 0,
width: source.clientWidth ?? 0,
height: source.clientHeight ?? 0,
};
} catch (err) {
if (isAbortError(err)) throw err;
// viewport stays zero-sized
} catch (error) {
if (isAbortError(error)) throw error;
}
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId, signal);
throwIfAborted(signal, "observation");
@@ -1554,22 +1572,58 @@ async function fallbackCapturedViewModel(
async function captureForVom(
cdp: CdpRunner,
tabId: number,
conditionalSurfaceProbe: boolean,
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>,
signal?: AbortSignal,
options: CaptureVomObservationOptions,
): Promise<CapturedViewModel> {
try {
return await captureViewModel(cdp, tabId, {
conditionalSurfaceProbe,
hoverProbeBypassOverlay,
signal,
conditionalSurfaceProbe: options.conditionalSurfaceProbe ?? false,
hoverProbeBypassOverlay: options.hoverProbeBypassOverlay,
signal: options.signal,
});
} catch (err) {
if (isAbortError(err)) throw err;
return fallbackCapturedViewModel(cdp, tabId, signal);
} catch (error) {
if (isAbortError(error)) throw error;
return fallbackCapturedViewModel(cdp, tabId, options.signal);
}
}
export interface CaptureVomObservationOptions extends VomOptions {
conditionalSurfaceProbe?: boolean;
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
signal?: AbortSignal;
}
export async function captureVomObservation(
cdp: CdpRunner,
tabId: number,
url: string | undefined,
options: CaptureVomObservationOptions = {},
): Promise<CaptureVomObservationResult> {
throwIfAborted(options.signal, "observation");
await cdp.ensureAttachedToUrl?.(tabId, url);
throwIfAborted(options.signal, "observation");
const captured = await captureForVom(cdp, tabId, options);
throwIfAborted(options.signal, "observation");
const documents = await captureFrameData<CdpAxNode>(cdp, tabId, captured, options.signal);
throwIfAborted(options.signal, "observation");
const scene =
captured.rootFrameId && captured.frameNodes?.size
? buildFrameVomScene(documents, captured, { pageUrl: url })
: buildVomScene(documents[0]?.axNodes ?? [], captured, { pageUrl: url });
const rendered = renderVom(scene, {
maxDepth: options.maxDepth,
maxTokens: options.maxTokens,
redactValues: options.redactValues,
activeRegionPolicy: options.activeRegionPolicy,
});
throwIfAborted(options.signal, "observation");
return projectRecordSafeObservation({
rootFrameId: captured.rootFrameId ?? documents[0]?.frameId ?? "root",
frameDocuments: documents,
rendered,
surfaceProbes: captured.surfaceProbes,
});
}
async function handleVomObservation(
manager: SessionManager,
params: SnapshotParams | ObserveParams,
@@ -1599,40 +1653,22 @@ async function handleVomObservation(
try {
throwIfAborted(signal, toolName);
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
throwIfAborted(signal, toolName);
const effectiveConditionalSurfaceProbe =
deps.conditionalSurfaceProbe ?? conditionalSurfaceProbe;
const captured = await captureForVom(
deps.cdp,
target.tabId,
effectiveConditionalSurfaceProbe,
deps.hoverProbeBypassOverlay,
signal,
);
throwIfAborted(signal, toolName);
const frameDocuments = await captureFrameData<CdpAxNode>(
deps.cdp,
target.tabId,
captured,
signal,
);
throwIfAborted(signal, toolName);
const scene =
captured.rootFrameId && captured.frameNodes?.size
? buildFrameVomScene(frameDocuments, captured, { pageUrl: target.url })
: buildVomScene(frameDocuments[0]?.axNodes ?? [], captured, { pageUrl: target.url });
const rendered = renderVom(scene, {
const observation = await captureVomObservation(deps.cdp, target.tabId, target.url, {
maxDepth: params.max_depth,
maxTokens: params.max_tokens,
activeRegionPolicy: true,
conditionalSurfaceProbe: effectiveConditionalSurfaceProbe,
hoverProbeBypassOverlay: deps.hoverProbeBypassOverlay,
signal,
});
throwIfAborted(signal, toolName);
const targetByFrameId = new Map(
frameDocuments.map((document) => [document.frameId, document.target]),
observation.frames.map((frame) => [frame.frameId, frame.target]),
);
ctx.refStore.replace(
rendered.refs.map((ref) => {
observation.refs.map((ref) => {
const refTarget = ref.frameId ? targetByFrameId.get(ref.frameId) : undefined;
return [
ref.ref,
@@ -1646,14 +1682,14 @@ async function handleVomObservation(
}),
);
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
text: rendered.text,
ref_count: rendered.refs.length,
text: observation.text,
ref_count: observation.refs.length,
tab_id: target.tabId,
truncated: rendered.truncated,
truncated: observation.truncated,
...(toolName === "observe" && (params as ObserveParams).debug_surfaces
? {
debug: {
surface_probes: (captured.surfaceProbes ?? []).map((probe) => ({
surface_probes: (observation.surfaceProbes ?? []).map((probe) => ({
trigger_backend_node_id: probe.triggerBackendNodeId,
...(probe.triggerPoint ? { trigger_point: probe.triggerPoint } : {}),
trigger_action: probe.triggerAction,
+20
View File
@@ -76,6 +76,7 @@ export interface SessionStopResult {
}
export interface SessionStopDeps {
signal?: AbortSignal;
cdp?: {
detachSession(sessionId: string): Promise<void>;
};
@@ -177,6 +178,9 @@ export async function handleSessionStop(
message: `session ${params.session_id} unknown`,
};
}
if (deps.signal?.aborted) {
return { code: "cancelled", message: "session_stop aborted before teardown" };
}
// Step 1: auto-return borrowed tabs. Iterate over a snapshot of the
// ids so deletions during iteration do not break the Map iterator.
@@ -187,6 +191,7 @@ export async function handleSessionStop(
try {
const tabManagement = {
...(deps.tabManagement ?? {}),
signal: deps.signal,
isAgentWindowId:
deps.tabManagement?.isAgentWindowId ??
((windowId: number) => manager.findByWindowId(windowId) !== null),
@@ -214,6 +219,17 @@ export async function handleSessionStop(
}
}
if (deps.signal?.aborted) {
const nonCancelFailures = returnFailures?.filter((failure) => failure.code !== "cancelled");
if (nonCancelFailures && nonCancelFailures.length > 0) {
return {
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
return_failures: nonCancelFailures,
};
}
return { code: "cancelled", message: "session_stop aborted during tab return" };
}
const result: SessionStopResult = {};
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
if (returnFailures && returnFailures.length > 0) {
@@ -233,6 +249,10 @@ export async function handleSessionStop(
// Step 3: detach CDP sessions this session opened (no-op if none).
await deps.cdp?.detachSession(params.session_id);
if (deps.signal?.aborted) {
return { code: "cancelled", message: "session_stop aborted before window close" };
}
// Step 4: close the Agent Window and drop the context.
await manager.stop(params.session_id);
@@ -77,9 +77,12 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
): FrameDocument<T>[] {
const frames = frameList(graph, batches, captured);
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
const rootFrameId = graph?.rootFrameId ?? captured.rootFrameId ?? frames[0]?.frameId;
const domNodesForFrame = (frameId: string): CapturedNode[] =>
captured.frameNodes?.get(frameId) ?? (frameId === rootFrameId ? captured.nodes : []);
const backendOwner = new Map<string, string>();
for (const frame of frames) {
for (const node of captured.frameNodes?.get(frame.frameId) ?? []) {
for (const node of domNodesForFrame(frame.frameId)) {
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
}
}
@@ -165,6 +168,6 @@ export function buildFrameDocuments<T extends FrameOwnedAxNode>(
...frame,
contextScopeId: frame.frameId,
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
domNodes: captured.frameNodes?.get(frame.frameId) ?? [],
domNodes: domNodesForFrame(frame.frameId),
}));
}
@@ -0,0 +1,77 @@
import type { Rect, RenderedRef, VomResult } from "@browser-skill/vom";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { CapturedSurfaceProbe } from "./capture";
import type { CapturedFrameDocument, FrameAxNode } from "./frame-capture";
/** Frame identity needed to resolve an `@eN` that lives in an iframe. */
export interface CaptureVomFrame {
frameId: string;
target: CdpTarget;
parentFrameId?: string;
ownerBackendNodeId?: number;
}
/** Allowlisted geometry used to match a recorded action to a rendered ref. */
export interface CaptureVomMatchNode {
frameId: string;
backendNodeId: number;
tag: string;
/** Top-level viewport-relative geometry. */
rect: Rect | null;
/** Frame-local viewport-relative geometry, when available. */
localRect?: Rect | null;
}
/**
* Raw AX/DOM trees are never returned. When `redactValues` is enabled by the
* caller, form values in `text` are masked as well.
*/
export interface CaptureVomObservationResult {
text: string;
refs: RenderedRef[];
truncated: boolean;
rootFrameId: string;
frames: CaptureVomFrame[];
matchNodes: CaptureVomMatchNode[];
surfaceProbes?: CapturedSurfaceProbe[];
}
function projectFrames(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomFrame[] {
return documents.map((document) => ({
frameId: document.frameId,
target: document.target,
...(document.parentFrameId ? { parentFrameId: document.parentFrameId } : {}),
...(document.ownerBackendNodeId !== undefined
? { ownerBackendNodeId: document.ownerBackendNodeId }
: {}),
}));
}
function projectMatchNodes(documents: CapturedFrameDocument<FrameAxNode>[]): CaptureVomMatchNode[] {
return documents.flatMap((document) =>
document.domNodes.map((node) => ({
frameId: document.frameId,
backendNodeId: node.backendNodeId,
tag: node.tag,
rect: node.rect,
...(node.localRect !== undefined ? { localRect: node.localRect } : {}),
})),
);
}
export function projectRecordSafeObservation(input: {
rootFrameId: string;
frameDocuments: CapturedFrameDocument<FrameAxNode>[];
rendered: VomResult;
surfaceProbes?: CapturedSurfaceProbe[];
}): CaptureVomObservationResult {
return {
text: input.rendered.text,
refs: input.rendered.refs,
truncated: input.rendered.truncated,
rootFrameId: input.rootFrameId,
frames: projectFrames(input.frameDocuments),
matchNodes: projectMatchNodes(input.frameDocuments),
surfaceProbes: input.surfaceProbes,
};
}
+88 -9
View File
@@ -206,15 +206,15 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
Ok(v) => ResponseBody::Ok(v),
Err(e) => ResponseBody::Err(e),
},
Method::SessionStart => match handle_session_start(&state, params).await {
Method::SessionStart => match handle_session_start(&state, rpc_id, params).await {
Ok(v) => ResponseBody::Ok(v),
Err(e) => ResponseBody::Err(e),
},
Method::SessionStop => match handle_session_stop(&state, params).await {
Method::SessionStop => match handle_session_stop(&state, rpc_id, params).await {
Ok(v) => ResponseBody::Ok(v),
Err(e) => ResponseBody::Err(e),
},
Method::SessionStopAll => match handle_session_stop_all(&state).await {
Method::SessionStopAll => match handle_session_stop_all_rpc(&state, rpc_id).await {
Ok(v) => ResponseBody::Ok(v),
Err(e) => ResponseBody::Err(e),
},
@@ -434,8 +434,9 @@ async fn handle_wait_ms(
/// cancellation surfaces (M10.2 + review C2):
///
/// 1. [`AbortRegistry`] — answers daemon-local cancellable runners
/// (currently `tool.wait_ms`). If a token is registered we trip it
/// and stop.
/// (`tool.wait_ms` and `session.*` lifecycle calls). If a token is
/// registered we trip it and stop. Lifecycle handlers forward their
/// own WS cancel after preserving request-before-cancel ordering.
/// 2. [`super::inflight::ToolInflightRegistry`] — every IPC-tracked
/// `tool.*` RPC, registered the moment the IPC handler accepts the
/// request. Trips the entry's cancel token regardless of whether
@@ -634,7 +635,20 @@ fn clamp_browser_wait(wait_ms: Option<u64>) -> Option<Duration> {
))
}
async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> {
async fn handle_session_start(
state: &Arc<DaemonState>,
rpc_id: RpcId,
params: Value,
) -> Result<Value, RpcError> {
let abort_guard = state
.abort_registry
.register(rpc_id)
.map_err(|err| RpcError {
code: ErrorCode::ProtocolError,
message: format!("session.start cancellation registration failed: {err:?}"),
data: None,
})?;
let cancel = abort_guard.token().clone();
let params: CliSessionStartParams = if params.is_null() {
CliSessionStartParams {
browser_instance_id: None,
@@ -673,6 +687,7 @@ async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result
},
state.config.extension_connect_wait,
DEFAULT_RPC_TIMEOUT,
Some(cancel),
)
.await
{
@@ -696,6 +711,8 @@ fn map_start_error(err: StartSessionError) -> RpcError {
StartSessionError::AmbiguousBrowserLabel { .. } => ErrorCode::InvalidParams,
StartSessionError::IdExhausted => ErrorCode::ProtocolError,
StartSessionError::Timeout => ErrorCode::Timeout,
StartSessionError::Cancelled => ErrorCode::Cancelled,
StartSessionError::CleanupFailed { .. } => ErrorCode::ProtocolError,
StartSessionError::TransportClosed => ErrorCode::ProtocolError,
StartSessionError::ExtensionError(inner) => inner.code,
};
@@ -711,6 +728,16 @@ fn map_start_error(err: StartSessionError) -> RpcError {
"label": label,
"instance_ids": instance_ids,
})),
StartSessionError::CleanupFailed {
session_id,
agent_window_id,
..
} => Some(serde_json::json!({
"reason": "cleanup_failed",
"session_id": session_id.0,
"agent_window_id": agent_window_id,
})),
StartSessionError::ExtensionError(inner) => inner.data.clone(),
_ => None,
};
RpcError {
@@ -720,7 +747,20 @@ fn map_start_error(err: StartSessionError) -> RpcError {
}
}
async fn handle_session_stop(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> {
async fn handle_session_stop(
state: &Arc<DaemonState>,
rpc_id: RpcId,
params: Value,
) -> Result<Value, RpcError> {
let abort_guard = state
.abort_registry
.register(rpc_id)
.map_err(|err| RpcError {
code: ErrorCode::ProtocolError,
message: format!("session.stop cancellation registration failed: {err:?}"),
data: None,
})?;
let cancel = abort_guard.token().clone();
let params: CliSessionStopParams = if params.is_null() {
CliSessionStopParams {
session_id: None,
@@ -734,7 +774,7 @@ async fn handle_session_stop(state: &Arc<DaemonState>, params: Value) -> Result<
})?
};
if params.all {
return handle_session_stop_all(state).await;
return handle_session_stop_all(state, Some(cancel)).await;
}
let session_id = match params.session_id {
Some(s) => SessionId(s),
@@ -753,6 +793,7 @@ async fn handle_session_stop(state: &Arc<DaemonState>, params: Value) -> Result<
&state.session_interrupts,
&session_id,
DEFAULT_SESSION_STOP_TIMEOUT,
Some(cancel),
)
.await
{
@@ -804,7 +845,25 @@ fn map_stop_error(err: StopSessionError) -> RpcError {
}
}
async fn handle_session_stop_all(state: &Arc<DaemonState>) -> Result<Value, RpcError> {
async fn handle_session_stop_all_rpc(
state: &Arc<DaemonState>,
rpc_id: RpcId,
) -> Result<Value, RpcError> {
let abort_guard = state
.abort_registry
.register(rpc_id)
.map_err(|err| RpcError {
code: ErrorCode::ProtocolError,
message: format!("session.stop_all cancellation registration failed: {err:?}"),
data: None,
})?;
handle_session_stop_all(state, Some(abort_guard.token().clone())).await
}
async fn handle_session_stop_all(
state: &Arc<DaemonState>,
cancel: Option<super::abort::AbortToken>,
) -> Result<Value, RpcError> {
let ids: Vec<SessionId> = state
.sessions
.snapshot()
@@ -816,6 +875,16 @@ async fn handle_session_stop_all(state: &Arc<DaemonState>) -> Result<Value, RpcE
let mut returned_tab_ids = Vec::new();
let mut return_failures = Vec::new();
for id in ids {
if cancel
.as_ref()
.is_some_and(super::abort::AbortToken::is_cancelled)
{
return Err(RpcError {
code: ErrorCode::Cancelled,
message: "session.stop_all was cancelled".into(),
data: None,
});
}
match stop_session(
&state.browsers,
&state.sessions,
@@ -823,6 +892,7 @@ async fn handle_session_stop_all(state: &Arc<DaemonState>) -> Result<Value, RpcE
&state.session_interrupts,
&id,
DEFAULT_SESSION_STOP_TIMEOUT,
cancel.clone(),
)
.await
{
@@ -848,6 +918,15 @@ async fn handle_session_stop_all(state: &Arc<DaemonState>) -> Result<Value, RpcE
returned_tab_ids.extend(stop.returned_tab_ids);
return_failures.extend(stop.return_failures);
}
Err(err)
if matches!(
&err,
StopSessionError::ExtensionError(inner)
if inner.code == ErrorCode::Cancelled
) =>
{
return Err(map_stop_error(err));
}
Err(err) => {
debug!(session = %id, ?err, "session.stop_all: failure (continuing)");
let rpc = map_stop_error(err);
+158 -17
View File
@@ -43,6 +43,7 @@ use serde_json::Value;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, warn};
use super::abort::AbortToken;
use super::browsers::BrowserRegistry;
use super::inflight::{PromoteOutcome, ToolInflightEntry};
use super::sessions::{SessionId, SessionRegistry};
@@ -83,6 +84,14 @@ pub struct ToolJob {
/// IPC request id (e.g. `session.stop`'s queued teardown call —
/// those already carry their own bespoke retry / abort path).
pub inflight: Option<Arc<ToolInflightEntry>>,
/// Cancellation token for session-lifecycle work registered in the
/// daemon-local abort registry. Unlike `inflight`, lifecycle callers
/// forward their own WS cancel after the request has been queued.
pub lifecycle_cancel: Option<AbortToken>,
/// How long cancellation keeps the worker busy while the extension
/// finishes compensation. Session teardown uses its full RPC budget
/// so daemon state is reconciled even after the CLI exits.
pub cancel_cleanup_timeout: Duration,
/// Owning session — used by `ToolInflightRegistry::cancel_session`
/// to drain queued jobs that have not yet been promoted to
/// "forwarded". Worker callers also have access to it via the
@@ -322,6 +331,8 @@ impl ToolQueueRegistry {
timeout,
false,
inflight,
None,
CANCEL_CLEANUP_TIMEOUT,
)
.await;
// A long-running request may outlive the idle threshold. Touching
@@ -360,6 +371,8 @@ impl ToolQueueRegistry {
timeout,
respond: respond_tx,
inflight,
lifecycle_cancel: None,
cancel_cleanup_timeout: CANCEL_CLEANUP_TIMEOUT,
session_id: sid.clone(),
};
match forward_one(sid, &self.browsers, &self.sessions, &job).await {
@@ -378,6 +391,7 @@ impl ToolQueueRegistry {
method: Method,
params: Value,
timeout: Duration,
cancel: Option<AbortToken>,
) -> Result<Value, DispatchError> {
let (sender, state) = {
let mut guard = self.queues.lock().expect("tool queue registry poisoned");
@@ -404,6 +418,8 @@ impl ToolQueueRegistry {
timeout,
true,
None,
cancel,
timeout,
)
.await
}
@@ -419,6 +435,8 @@ async fn dispatch_with_sender(
timeout: Duration,
wait_for_capacity: bool,
inflight: Option<Arc<ToolInflightEntry>>,
lifecycle_cancel: Option<AbortToken>,
cancel_cleanup_timeout: Duration,
) -> Result<Value, DispatchError> {
let (respond_tx, respond_rx) = oneshot::channel();
let job = ToolJob {
@@ -427,8 +445,11 @@ async fn dispatch_with_sender(
timeout,
respond: respond_tx,
inflight,
lifecycle_cancel,
cancel_cleanup_timeout,
session_id,
};
let lifecycle_cancellable = job.lifecycle_cancel.is_some();
if wait_for_capacity {
let waited = tokio::time::timeout(
timeout.saturating_add(Duration::from_secs(1)),
@@ -453,8 +474,15 @@ async fn dispatch_with_sender(
mpsc::error::TrySendError::Closed(_) => DispatchError::QueueClosed,
});
}
let waited =
tokio::time::timeout(timeout.saturating_add(Duration::from_secs(1)), respond_rx).await;
let mut response_timeout = timeout.saturating_add(Duration::from_secs(1));
if lifecycle_cancellable {
// A lifecycle cancel can arrive at the original RPC deadline and
// intentionally keeps the worker alive while extension cleanup
// settles. Do not let this outer waiter abandon that reconciliation
// early, which could reopen a daemon session whose window is gone.
response_timeout = response_timeout.saturating_add(cancel_cleanup_timeout);
}
let waited = tokio::time::timeout(response_timeout, respond_rx).await;
match waited {
Ok(Ok(Ok(v))) => Ok(v),
Ok(Ok(Err(rpc))) => Err(DispatchError::Rpc(rpc)),
@@ -492,11 +520,17 @@ async fn forward_one(
// / browser resolution work, and before any WS frame leaves the
// daemon (review C2). We keep the `tokio::select!` later so
// cancels arriving mid-WS-hop also unblock the worker.
if let Some(entry) = job.inflight.as_ref()
&& entry.is_cancelled()
if job
.inflight
.as_ref()
.is_some_and(|entry| entry.is_cancelled())
|| job
.lifecycle_cancel
.as_ref()
.is_some_and(AbortToken::is_cancelled)
{
return Err(cancelled_error(
Some(entry),
job.inflight.as_deref(),
"tool dispatch cancelled before forwarding",
));
}
@@ -560,11 +594,20 @@ async fn forward_one(
}
}
None => {
// Daemon-internal callers (e.g. queued session.stop drain)
// don't carry an inflight entry; they cannot be cancelled
// through the IPC `cancel` surface, so a plain sink send
// is sufficient and keeps the existing transport-error
// behaviour.
// Daemon-internal callers do not carry a tool-inflight entry.
// Session lifecycle jobs instead use the abort-registry token
// below; other internal callers keep the plain send path.
if job
.lifecycle_cancel
.as_ref()
.is_some_and(AbortToken::is_cancelled)
{
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(cancelled_error(
None,
"session lifecycle cancelled before forwarding",
));
}
if client.sink.send(request).is_err() {
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(RpcError {
@@ -573,19 +616,45 @@ async fn forward_one(
data: None,
});
}
None
job.lifecycle_cancel.clone()
}
};
let forward_lifecycle_cancel = || {
if job.lifecycle_cancel.is_none() {
return;
}
let cancel = Frame::Request(RequestFrame {
id: format!("cancel-{rpc_id}"),
method: Method::Cancel,
params: Some(serde_json::json!({ "rpc_id": rpc_id })),
});
if let Err(err) = client.sink.send(cancel) {
warn!(%rpc_id, ?err, "failed to forward session lifecycle cancel");
}
};
let on_abort: Option<&(dyn Fn() + Sync)> = job
.lifecycle_cancel
.as_ref()
.map(|_| &forward_lifecycle_cancel as &(dyn Fn() + Sync));
let waited = await_with_optional_cancel(
job.timeout,
CANCEL_CLEANUP_TIMEOUT,
job.cancel_cleanup_timeout,
waiter,
cancel_token.as_ref(),
on_abort,
)
.await;
let response = match waited {
WaitOutcome::Response(resp) => resp,
WaitOutcome::CancelledAfterResponse(resp) => {
if job.method == Method::ToolSessionStop
&& let ResponseBody::Ok(value) = resp.body
{
// Teardown crossed its final irreversible boundary before
// the cancel landed. Commit the real extension result so the
// daemon does not retain a session whose window is gone.
return Ok(value);
}
// Cancellation wins the external verdict, but only after the
// extension's original RPC has settled. Preserve any non-cancel
// error so compensation failures remain explicit instead of
@@ -600,13 +669,33 @@ async fn forward_one(
"tool dispatch cancelled after extension cleanup",
));
}
WaitOutcome::TimedOutAfterResponse(resp) => match resp.body {
ResponseBody::Ok(value) if job.method == Method::ToolSessionStop => {
// The close crossed its irreversible boundary during the
// timeout cleanup grace. Reconcile daemon state instead of
// reopening a session whose window is already gone.
return Ok(value);
}
ResponseBody::Err(err)
if !matches!(err.code, ErrorCode::Cancelled | ErrorCode::UserAborted) =>
{
return Err(err);
}
_ => {
return Err(RpcError {
code: ErrorCode::Timeout,
message: format!("tool RPC timed out after {:?}", job.timeout),
data: None,
});
}
},
WaitOutcome::CleanupTimeout => {
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(RpcError {
code: ErrorCode::Timeout,
message: format!(
"cancelled tool did not finish cleanup within {:?}",
CANCEL_CLEANUP_TIMEOUT
job.cancel_cleanup_timeout
),
data: Some(serde_json::json!({ "reason": "cancel_cleanup_timeout" })),
});
@@ -638,6 +727,7 @@ async fn forward_one(
enum WaitOutcome {
Response(bsk_protocol::ResponseFrame),
CancelledAfterResponse(bsk_protocol::ResponseFrame),
TimedOutAfterResponse(bsk_protocol::ResponseFrame),
CleanupTimeout,
WaiterClosed,
Timeout,
@@ -648,6 +738,7 @@ async fn await_with_optional_cancel(
cleanup_timeout: Duration,
mut waiter: oneshot::Receiver<bsk_protocol::ResponseFrame>,
cancel: Option<&super::abort::AbortToken>,
on_abort: Option<&(dyn Fn() + Sync)>,
) -> WaitOutcome {
match cancel {
Some(token) => {
@@ -659,6 +750,9 @@ async fn await_with_optional_cancel(
// after compensation or the cleanup deadline expires.
biased;
_ = token.cancelled() => {
if let Some(on_abort) = on_abort {
on_abort();
}
match tokio::time::timeout(cleanup_timeout, &mut waiter).await {
Ok(Ok(resp)) => WaitOutcome::CancelledAfterResponse(resp),
Ok(Err(_)) => WaitOutcome::WaiterClosed,
@@ -669,7 +763,17 @@ async fn await_with_optional_cancel(
Ok(resp) => WaitOutcome::Response(resp),
Err(_) => WaitOutcome::WaiterClosed,
},
_ = &mut deadline => WaitOutcome::Timeout,
_ = &mut deadline => {
let Some(on_abort) = on_abort else {
return WaitOutcome::Timeout;
};
on_abort();
match tokio::time::timeout(cleanup_timeout, &mut waiter).await {
Ok(Ok(resp)) => WaitOutcome::TimedOutAfterResponse(resp),
Ok(Err(_)) => WaitOutcome::WaiterClosed,
Err(_) => WaitOutcome::CleanupTimeout,
}
},
}
}
None => match tokio::time::timeout(timeout, &mut waiter).await {
@@ -759,6 +863,7 @@ mod await_with_optional_cancel_tests {
Duration::from_secs(1),
rx,
Some(&token),
None,
)
.await;
assert!(
@@ -781,6 +886,7 @@ mod await_with_optional_cancel_tests {
Duration::from_secs(1),
rx,
Some(&token),
None,
)
.await;
match outcome {
@@ -795,9 +901,14 @@ mod await_with_optional_cancel_tests {
async fn no_cancel_token_still_returns_response() {
let (tx, rx) = oneshot::channel();
tx.send(dummy_response()).unwrap();
let outcome =
await_with_optional_cancel(Duration::from_secs(10), Duration::from_secs(1), rx, None)
.await;
let outcome = await_with_optional_cancel(
Duration::from_secs(10),
Duration::from_secs(1),
rx,
None,
None,
)
.await;
assert!(matches!(outcome, WaitOutcome::Response(_)));
}
@@ -812,6 +923,7 @@ mod await_with_optional_cancel_tests {
Duration::from_secs(1),
rx,
Some(&token),
None,
)
.await
});
@@ -835,10 +947,37 @@ mod await_with_optional_cancel_tests {
Duration::from_millis(10),
rx,
Some(&token),
None,
)
.await;
assert!(matches!(outcome, WaitOutcome::CleanupTimeout));
}
#[tokio::test]
async fn lifecycle_timeout_forwards_abort_and_waits_for_cleanup_response() {
use std::sync::atomic::{AtomicBool, Ordering};
let token = AbortToken::new();
let (tx, rx) = oneshot::channel();
let abort_forwarded = AtomicBool::new(false);
let forward_abort = || abort_forwarded.store(true, Ordering::SeqCst);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
tx.send(dummy_response()).unwrap();
});
let outcome = await_with_optional_cancel(
Duration::from_millis(5),
Duration::from_millis(100),
rx,
Some(&token),
Some(&forward_abort),
)
.await;
assert!(abort_forwarded.load(Ordering::SeqCst));
assert!(matches!(outcome, WaitOutcome::TimedOutAfterResponse(_)));
}
}
#[cfg(test)]
@@ -901,6 +1040,8 @@ mod tool_job_session_id_tests {
timeout: Duration::from_secs(1),
respond: tx,
inflight: None,
lifecycle_cancel: None,
cancel_cleanup_timeout: CANCEL_CLEANUP_TIMEOUT,
session_id: SessionId("sess-A".into()),
};
assert_eq!(job.session_id.0, "sess-A");
+223 -26
View File
@@ -12,13 +12,14 @@ use bsk_protocol::system::{BrowserStatusEntry, SessionStatusEntry};
use bsk_protocol::tools::{
SessionStartParams, SessionStartResult, SessionStopParams, SessionStopResult,
};
use bsk_protocol::{Frame, RequestFrame, ResponseBody, RpcError, RpcId};
use bsk_protocol::{ErrorCode, Frame, Method, RequestFrame, ResponseBody, RpcError, RpcId};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tokio::time::{Duration, timeout};
use super::abort::AbortToken;
use super::browsers::{BrowserClient, BrowserId, BrowserRegistry, SelectError};
use super::queue::{DispatchError, ToolQueueRegistry};
use super::queue::{CANCEL_CLEANUP_TIMEOUT, DispatchError, ToolQueueRegistry};
use super::session_interrupt::SessionInterruptRegistry;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -339,6 +340,14 @@ pub enum StartSessionError {
IdExhausted,
#[error("session creation timed out waiting for extension")]
Timeout,
#[error("session creation was cancelled")]
Cancelled,
#[error("session creation cancellation cleanup failed for {session_id}: {message}")]
CleanupFailed {
session_id: SessionId,
agent_window_id: Option<i64>,
message: String,
},
#[error("extension rejected tool.session_start: {0:?}")]
ExtensionError(RpcError),
#[error("transport closed while waiting for extension response")]
@@ -354,6 +363,8 @@ impl StartSessionError {
StartSessionError::AmbiguousBrowserLabel { .. } => "invalid_params",
StartSessionError::IdExhausted => "protocol_error",
StartSessionError::Timeout => "timeout",
StartSessionError::Cancelled => "cancelled",
StartSessionError::CleanupFailed { .. } => "protocol_error",
StartSessionError::TransportClosed => "protocol_error",
StartSessionError::ExtensionError(err) => match err.code {
bsk_protocol::ErrorCode::Timeout => "timeout",
@@ -407,6 +418,7 @@ pub struct AgentWindowOptions {
/// On success the matching per-session dispatch queue (M6.5,
/// design §5) is also spawned so that subsequent `tool.*` RPCs serialise
/// against this session's Agent Window / ref-store.
#[allow(clippy::too_many_arguments)]
pub async fn start_session(
registry: &Arc<BrowserRegistry>,
sessions: &Arc<SessionRegistry>,
@@ -415,24 +427,32 @@ pub async fn start_session(
window: AgentWindowOptions,
connect_wait: Duration,
timeout_dur: Duration,
cancel: Option<AbortToken>,
) -> Result<Session, StartSessionError> {
let client: Arc<BrowserClient> = registry
.select_with_connect_wait(requested, connect_wait)
.await
.map_err(|e| match e {
SelectError::NoBrowserConnected => StartSessionError::NoBrowserConnected,
SelectError::MultipleBrowsersOnline => StartSessionError::MultipleBrowsersOnline {
browsers: snapshot_status_entries(registry, sessions),
},
SelectError::NotFound => StartSessionError::BrowserNotFound,
SelectError::AmbiguousLabel {
label,
instance_ids,
} => StartSessionError::AmbiguousBrowserLabel {
label,
instance_ids,
},
})?;
let selection = registry.select_with_connect_wait(requested, connect_wait);
tokio::pin!(selection);
let selected = match cancel.as_ref() {
Some(token) => tokio::select! {
biased;
_ = token.cancelled() => return Err(StartSessionError::Cancelled),
selected = &mut selection => selected,
},
None => selection.await,
};
let client: Arc<BrowserClient> = selected.map_err(|e| match e {
SelectError::NoBrowserConnected => StartSessionError::NoBrowserConnected,
SelectError::MultipleBrowsersOnline => StartSessionError::MultipleBrowsersOnline {
browsers: snapshot_status_entries(registry, sessions),
},
SelectError::NotFound => StartSessionError::BrowserNotFound,
SelectError::AmbiguousLabel {
label,
instance_ids,
} => StartSessionError::AmbiguousBrowserLabel {
label,
instance_ids,
},
})?;
let session_id = sessions
.reserve_id(client.id.clone(), SESSION_ID_MAX_RESERVE_ATTEMPTS, now_ms)
.ok_or(StartSessionError::IdExhausted)?;
@@ -453,22 +473,71 @@ pub async fn start_session(
let mut pending = client.pending.lock().unwrap();
pending.register(rpc_id.clone())
};
if cancel.as_ref().is_some_and(AbortToken::is_cancelled) {
sessions.cancel_reservation(&session_id);
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(StartSessionError::Cancelled);
}
if client.sink.send(Frame::Request(request)).is_err() {
sessions.cancel_reservation(&session_id);
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(StartSessionError::TransportClosed);
}
let response = match timeout(timeout_dur, waiter).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => {
let mut waiter = waiter;
enum StartWaitOutcome {
Response(bsk_protocol::ResponseFrame),
WaiterClosed,
Cancelled,
Timeout,
}
let wait_outcome = match cancel.as_ref() {
Some(token) => {
let deadline = tokio::time::sleep(timeout_dur);
tokio::pin!(deadline);
tokio::select! {
biased;
_ = token.cancelled() => StartWaitOutcome::Cancelled,
response = &mut waiter => match response {
Ok(response) => StartWaitOutcome::Response(response),
Err(_) => StartWaitOutcome::WaiterClosed,
},
_ = &mut deadline => StartWaitOutcome::Timeout,
}
}
None => match timeout(timeout_dur, &mut waiter).await {
Ok(Ok(response)) => StartWaitOutcome::Response(response),
Ok(Err(_)) => StartWaitOutcome::WaiterClosed,
Err(_) => StartWaitOutcome::Timeout,
},
};
let response = match wait_outcome {
StartWaitOutcome::Response(response) => response,
StartWaitOutcome::WaiterClosed => {
sessions.cancel_reservation(&session_id);
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(StartSessionError::TransportClosed);
}
Err(_) => {
sessions.cancel_reservation(&session_id);
client.pending.lock().unwrap().cancel(&rpc_id);
return Err(StartSessionError::Timeout);
StartWaitOutcome::Cancelled => {
return Err(finish_aborted_start(
&client,
sessions,
&session_id,
&rpc_id,
waiter,
StartAbortReason::Cancelled,
)
.await);
}
StartWaitOutcome::Timeout => {
return Err(finish_aborted_start(
&client,
sessions,
&session_id,
&rpc_id,
waiter,
StartAbortReason::Timeout,
)
.await);
}
};
let agent_window_id = match response.body {
@@ -504,6 +573,124 @@ pub async fn start_session(
Ok(session)
}
#[derive(Clone, Copy)]
enum StartAbortReason {
Cancelled,
Timeout,
}
impl StartAbortReason {
fn error(self) -> StartSessionError {
match self {
Self::Cancelled => StartSessionError::Cancelled,
Self::Timeout => StartSessionError::Timeout,
}
}
}
async fn finish_aborted_start(
client: &Arc<BrowserClient>,
sessions: &Arc<SessionRegistry>,
session_id: &SessionId,
rpc_id: &RpcId,
mut waiter: tokio::sync::oneshot::Receiver<bsk_protocol::ResponseFrame>,
reason: StartAbortReason,
) -> StartSessionError {
sessions.cancel_reservation(session_id);
let cancel = Frame::Request(RequestFrame {
id: format!("cancel-{rpc_id}"),
method: Method::Cancel,
params: Some(serde_json::json!({ "rpc_id": rpc_id })),
});
if client.sink.send(cancel).is_err() {
client.pending.lock().unwrap().cancel(rpc_id);
return StartSessionError::TransportClosed;
}
match timeout(CANCEL_CLEANUP_TIMEOUT, &mut waiter).await {
Ok(Ok(response)) => match response.body {
ResponseBody::Err(err)
if matches!(err.code, ErrorCode::Cancelled | ErrorCode::UserAborted) =>
{
reason.error()
}
ResponseBody::Err(err) => StartSessionError::ExtensionError(err),
ResponseBody::Ok(value) => {
let agent_window_id = serde_json::from_value::<SessionStartResult>(value)
.ok()
.and_then(|result| result.agent_window_id);
match rollback_extension_session(client, session_id).await {
Ok(()) => reason.error(),
Err(message) => StartSessionError::CleanupFailed {
session_id: session_id.clone(),
agent_window_id,
message,
},
}
}
},
Ok(Err(_)) => {
client.pending.lock().unwrap().cancel(rpc_id);
StartSessionError::TransportClosed
}
Err(_) => {
// The cancel frame is already queued behind session_start. Even
// if chrome.windows.create is still blocked, the extension will
// observe the aborted signal and compensate before replying.
client.pending.lock().unwrap().cancel(rpc_id);
reason.error()
}
}
}
async fn rollback_extension_session(
client: &Arc<BrowserClient>,
session_id: &SessionId,
) -> Result<(), String> {
let rollback_id = next_rpc_id("sess-start-rollback");
let waiter = {
let mut pending = client.pending.lock().unwrap();
pending.register(rollback_id.clone())
};
let request = RequestFrame {
id: rollback_id.clone(),
method: Method::ToolSessionStop,
params: Some(
serde_json::to_value(SessionStopParams {
session_id: session_id.0.clone(),
})
.expect("serialize session_start rollback params"),
),
};
if client.sink.send(Frame::Request(request)).is_err() {
client.pending.lock().unwrap().cancel(&rollback_id);
return Err("browser disconnected before rollback was queued".into());
}
let response = match timeout(CANCEL_CLEANUP_TIMEOUT, waiter).await {
Ok(Ok(response)) => response,
Ok(Err(_)) => return Err("browser disconnected during rollback".into()),
Err(_) => {
client.pending.lock().unwrap().cancel(&rollback_id);
return Err(format!(
"extension did not finish rollback within {CANCEL_CLEANUP_TIMEOUT:?}"
));
}
};
match response.body {
ResponseBody::Ok(value) => {
let result: SessionStopResult = serde_json::from_value(value)
.map_err(|err| format!("invalid rollback response: {err}"))?;
if result.return_failures.is_empty() {
Ok(())
} else {
Err("rollback reported borrowed-tab return failures".into())
}
}
ResponseBody::Err(err) if err.code == ErrorCode::NotFound => Ok(()),
ResponseBody::Err(err) => Err(format!("rollback failed: {} ({:?})", err.message, err.code)),
}
}
/// Tear down a single session id (round-trips `tool.session_stop` to the
/// owning extension and unregisters on success). Also tears down the
/// per-session dispatch queue so the worker task exits.
@@ -514,6 +701,7 @@ pub async fn stop_session(
interrupts: &Arc<SessionInterruptRegistry>,
session_id: &SessionId,
timeout_dur: Duration,
cancel: Option<AbortToken>,
) -> Result<SessionStopResult, StopSessionError> {
let session = sessions.get(session_id).ok_or(StopSessionError::NotFound)?;
if registry.get(&session.browser_id).is_none() {
@@ -524,12 +712,17 @@ pub async fn stop_session(
session_id: session_id.0.clone(),
})
.unwrap();
// Internal callers (idle reaping and shutdown) do not have a CLI-owned
// token, but still need timeout-driven WS cancellation so a late window
// close cannot leave daemon state pointing at a vanished session.
let lifecycle_cancel = Some(cancel.unwrap_or_default());
match queues
.dispatch_after_closing(
session_id,
bsk_protocol::Method::ToolSessionStop,
params,
timeout_dur,
lifecycle_cancel,
)
.await
{
@@ -567,6 +760,10 @@ pub async fn stop_session(
drop_session_local(queues, interrupts, session_id);
return Ok(SessionStopResult::default());
}
if err.code == bsk_protocol::ErrorCode::Timeout {
queues.reopen(session_id);
return Err(StopSessionError::Timeout);
}
queues.reopen(session_id);
Err(StopSessionError::ExtensionError(err))
}
+96 -27
View File
@@ -15,7 +15,6 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
@@ -291,8 +290,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
});
let started_at = Instant::now();
let activity: Arc<Mutex<Instant>> = Arc::new(Mutex::new(started_at));
let active_ipc_connections = Arc::new(AtomicUsize::new(0));
let activity = Arc::new(Mutex::new(IpcActivityState::new(started_at)));
let (ipc_shutdown_tx, ipc_shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let status = ipc::DaemonStatus {
started_at,
@@ -314,18 +312,14 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let handler = handler.clone();
let ipc_open = {
let activity = activity.clone();
let active = active_ipc_connections.clone();
move || {
active.fetch_add(1, Ordering::SeqCst);
record_activity(&activity);
record_ipc_open(&activity);
}
};
let ipc_close = {
let activity = activity.clone();
let active = active_ipc_connections.clone();
move || {
active.fetch_sub(1, Ordering::SeqCst);
record_activity(&activity);
record_ipc_close(&activity);
}
};
tokio::spawn(ipc::serve(
@@ -344,7 +338,6 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let idle_task = {
let activity = activity.clone();
let state = Arc::clone(&state);
let active_ipc_connections = active_ipc_connections.clone();
let daemon_idle = cfg.daemon_idle;
tokio::spawn(async move {
let tick = (daemon_idle / 4).max(Duration::from_millis(250));
@@ -352,22 +345,19 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let last = match activity.lock() {
Ok(a) => *a,
Err(p) => *p.into_inner(),
};
if last.elapsed() < daemon_idle {
let ipc_is_idle =
lock_activity(&activity).should_exit_for_idle(Instant::now(), daemon_idle);
if !ipc_is_idle {
continue;
}
// Bridge from M2/M3 (which only knew about connection
// counts) to M4/M5 (browser + session registries):
// hold the daemon alive while any IPC client is
// connected, any browser is paired, or any session
// is live (design §3.2).
if active_ipc_connections.load(Ordering::SeqCst) > 0
|| !state.browsers.is_empty()
|| !state.sessions.is_empty()
{
// IPC liveness (open-connection count + idle window) is already
// enforced above by `should_exit_for_idle`, which reads
// `IpcActivityState` under one lock and returns true only when
// there are zero connections AND the idle interval has elapsed.
// Here we additionally hold the daemon alive while any browser
// is paired or any session is live (design §3.2, M4/M5
// registries).
if !state.browsers.is_empty() || !state.sessions.is_empty() {
continue;
}
info!(
@@ -491,6 +481,7 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
&state.session_interrupts,
&session_id,
Duration::from_secs(10),
None,
)
.await
{
@@ -681,10 +672,55 @@ fn restart_start_args(cfg: &DaemonConfig) -> StartArgs {
}
}
fn record_activity(activity: &Arc<Mutex<Instant>>) {
if let Ok(mut a) = activity.lock() {
*a = Instant::now();
#[derive(Debug)]
struct IpcActivityState {
last_activity: Instant,
active_connections: usize,
}
impl IpcActivityState {
fn new(now: Instant) -> Self {
Self {
last_activity: now,
active_connections: 0,
}
}
fn touch(&mut self, now: Instant) {
self.last_activity = now;
}
fn opened(&mut self, now: Instant) {
self.active_connections = self.active_connections.saturating_add(1);
self.last_activity = now;
}
fn closed(&mut self, now: Instant) {
self.active_connections = self.active_connections.saturating_sub(1);
self.last_activity = now;
}
fn should_exit_for_idle(&self, now: Instant, idle: Duration) -> bool {
self.active_connections == 0 && now.saturating_duration_since(self.last_activity) >= idle
}
}
fn lock_activity(
activity: &Arc<Mutex<IpcActivityState>>,
) -> std::sync::MutexGuard<'_, IpcActivityState> {
activity.lock().unwrap_or_else(|poison| poison.into_inner())
}
fn record_activity(activity: &Arc<Mutex<IpcActivityState>>) {
lock_activity(activity).touch(Instant::now());
}
fn record_ipc_open(activity: &Arc<Mutex<IpcActivityState>>) {
lock_activity(activity).opened(Instant::now());
}
fn record_ipc_close(activity: &Arc<Mutex<IpcActivityState>>) {
lock_activity(activity).closed(Instant::now());
}
/// Initialise tracing for the daemon: write a daily-rotated file in
@@ -1067,4 +1103,37 @@ mod tests {
assert_eq!(args.session_idle, Some(Duration::from_secs(11)));
assert_eq!(args.daemon_idle, Some(Duration::from_secs(22)));
}
#[test]
fn ipc_close_restarts_the_idle_window_atomically() {
let started = Instant::now();
let idle = Duration::from_secs(10);
let mut activity = IpcActivityState::new(started);
activity.opened(started);
let closed_at = started + Duration::from_secs(30);
assert!(
!activity.should_exit_for_idle(closed_at, idle),
"an open IPC connection must hold the daemon alive"
);
activity.closed(closed_at);
assert_eq!(activity.active_connections, 0);
assert_eq!(activity.last_activity, closed_at);
assert!(!activity.should_exit_for_idle(closed_at, idle));
assert!(!activity.should_exit_for_idle(closed_at + idle - Duration::from_millis(1), idle));
assert!(activity.should_exit_for_idle(closed_at + idle, idle));
}
#[test]
fn ipc_activity_keeps_the_daemon_alive_until_a_full_idle_interval_passes() {
let started = Instant::now();
let idle = Duration::from_secs(10);
let mut activity = IpcActivityState::new(started);
let request_at = started + Duration::from_secs(20);
activity.touch(request_at);
assert!(!activity.should_exit_for_idle(request_at + Duration::from_secs(9), idle));
assert!(activity.should_exit_for_idle(request_at + idle, idle));
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ pub struct DaemonState {
/// `stop_session` / browser disconnect.
pub tool_queues: Arc<ToolQueueRegistry>,
/// Per-rpc-id cancellation tokens for daemon-side long-runners
/// (M9.3 — currently only `tool.wait_ms`). The CLI's `cancel
/// { rpc_id }` consults this registry first.
/// (`tool.wait_ms` plus `session.*` lifecycle calls). The CLI's
/// `cancel { rpc_id }` consults this registry first.
pub abort_registry: Arc<AbortRegistry>,
/// Tracks `tool.*` RPCs that have been forwarded to an extension
/// over WS but have not yet received a response. Indexed by the
@@ -325,6 +325,7 @@ async fn session_stop_fast_fails_while_tool_is_in_flight() {
&state.session_interrupts,
&sid,
Duration::from_secs(5),
None,
)
.await;
assert!(
@@ -366,6 +367,7 @@ async fn session_stop_fast_fails_while_tool_is_in_flight() {
&state.session_interrupts,
&sid,
Duration::from_secs(5),
None,
)
.await
.expect("session.stop ok after tool completes");
+368 -10
View File
@@ -10,9 +10,14 @@ use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::cancel::{CancelParams, CancelResult};
use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult};
use bsk_protocol::tools::{SessionStartParams, SessionStartResult, SessionStopParams};
use bsk_protocol::{BrowserPeerInfo, Frame, Method, RequestFrame, ResponseBody, ResponseFrame};
use bsk_protocol::tools::{
SessionStartParams, SessionStartResult, SessionStopParams, SessionStopResult,
};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
@@ -23,6 +28,9 @@ use support::{wait_for_browser_count, wait_for_no_sessions};
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
type TestWs =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
@@ -56,9 +64,7 @@ async fn spawn_daemon_with_session_idle(session_idle: Duration) -> (daemon::Daem
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
async fn connect_ext(addr: std::net::SocketAddr) -> TestWs {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
@@ -76,11 +82,7 @@ async fn connect_ext(
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
async fn handshake_as_ext(ws: &mut TestWs) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
@@ -114,6 +116,128 @@ async fn handshake_as_ext(
}
}
async fn next_extension_request(ws: &mut TestWs) -> RequestFrame {
loop {
let message = ws.next().await.expect("daemon closed websocket").unwrap();
match message {
Message::Text(text) => match serde_json::from_str::<Frame>(&text).unwrap() {
Frame::Request(request) => return request,
Frame::Response(_) | Frame::Event(_) => continue,
},
Message::Ping(payload) => ws.send(Message::Pong(payload)).await.unwrap(),
Message::Close(_) => panic!("daemon closed websocket before sending request"),
_ => {}
}
}
}
async fn send_extension_response(ws: &mut TestWs, response: ResponseFrame) {
ws.send(Message::Text(serde_json::to_string(&response).unwrap()))
.await
.unwrap();
}
async fn acknowledge_extension_cancel(ws: &mut TestWs, cancel: RequestFrame, target_rpc_id: &str) {
assert_eq!(cancel.method, Method::Cancel);
let params: CancelParams = serde_json::from_value(cancel.params.unwrap()).unwrap();
assert_eq!(params.rpc_id, target_rpc_id);
send_extension_response(
ws,
ResponseFrame {
id: cancel.id,
body: ResponseBody::Ok(serde_json::to_value(CancelResult { cancelled: true }).unwrap()),
},
)
.await;
}
async fn respond_to_aborted_start(
ws: &mut TestWs,
start_seen: Option<tokio::sync::oneshot::Sender<()>>,
agent_window_id: i64,
) {
let start = next_extension_request(ws).await;
assert_eq!(start.method, Method::ToolSessionStart);
let params: SessionStartParams = serde_json::from_value(start.params.clone().unwrap()).unwrap();
if let Some(start_seen) = start_seen {
start_seen.send(()).unwrap();
}
let cancel = next_extension_request(ws).await;
acknowledge_extension_cancel(ws, cancel, &start.id).await;
// Model the hardest race: chrome.windows.create completed just as
// cancellation arrived, so the extension's original call succeeds.
send_extension_response(
ws,
ResponseFrame {
id: start.id,
body: ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(agent_window_id),
})
.unwrap(),
),
},
)
.await;
let rollback = next_extension_request(ws).await;
assert_eq!(rollback.method, Method::ToolSessionStop);
let rollback_params: SessionStopParams =
serde_json::from_value(rollback.params.unwrap()).unwrap();
assert_eq!(rollback_params.session_id, params.session_id);
send_extension_response(
ws,
ResponseFrame {
id: rollback.id,
body: ResponseBody::Ok(serde_json::to_value(SessionStopResult::default()).unwrap()),
},
)
.await;
}
async fn respond_to_aborted_stop(
ws: &mut TestWs,
stop_seen: Option<tokio::sync::oneshot::Sender<()>>,
) {
let start = next_extension_request(ws).await;
assert_eq!(start.method, Method::ToolSessionStart);
send_extension_response(
ws,
ResponseFrame {
id: start.id,
body: ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(4242),
})
.unwrap(),
),
},
)
.await;
let stop = next_extension_request(ws).await;
assert_eq!(stop.method, Method::ToolSessionStop);
if let Some(stop_seen) = stop_seen {
stop_seen.send(()).unwrap();
}
let cancel = next_extension_request(ws).await;
acknowledge_extension_cancel(ws, cancel, &stop.id).await;
send_extension_response(
ws,
ResponseFrame {
id: stop.id,
body: ResponseBody::Err(RpcError {
code: ErrorCode::Cancelled,
message: "session_stop aborted before window close".into(),
data: None,
}),
},
)
.await;
}
#[tokio::test]
async fn session_start_stop_round_trip_via_ipc() {
let (handle, sock) = spawn_daemon().await;
@@ -250,6 +374,240 @@ async fn session_start_stop_round_trip_via_ipc() {
handle.shutdown().await;
}
#[tokio::test]
async fn cancelling_session_start_rolls_back_a_late_extension_success() {
const START_RPC_ID: &str = "session-start-cancel";
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let (start_seen_tx, start_seen_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let responder = tokio::spawn(async move {
respond_to_aborted_start(&mut ws, Some(start_seen_tx), 4242).await;
let _ = release_rx.await;
});
let mut start_ipc = IpcClient::connect(&sock).await.unwrap();
let start_call = tokio::spawn(async move {
let outcome: Result<serde_json::Value, RpcError> = start_ipc
.call_with_id(
START_RPC_ID.to_string(),
Method::SessionStart,
Some(serde_json::json!({ "focused": false })),
Duration::from_secs(5),
)
.await
.unwrap();
outcome
});
start_seen_rx.await.unwrap();
let mut cancel_ipc = IpcClient::connect(&sock).await.unwrap();
let cancel: CancelResult = cancel_ipc
.call(
"cancel-start",
Method::Cancel,
Some(CancelParams {
rpc_id: START_RPC_ID.to_string(),
}),
Duration::from_secs(2),
)
.await
.unwrap()
.unwrap();
assert!(cancel.cancelled);
let start_error = start_call.await.unwrap().unwrap_err();
assert_eq!(start_error.code, ErrorCode::Cancelled);
let mut status_ipc = IpcClient::connect(&sock).await.unwrap();
let status: StatusResult = status_ipc
.call::<(), _>(
"status-after-start-cancel",
Method::SystemStatus,
None,
Duration::from_secs(2),
)
.await
.unwrap()
.unwrap();
assert!(status.sessions.is_empty());
assert_eq!(status.browsers[0].session_count, 0);
let _ = release_tx.send(());
responder.await.unwrap();
handle.shutdown().await;
}
#[tokio::test]
async fn timing_out_session_start_rolls_back_a_late_extension_success() {
let (handle, _sock) = spawn_daemon().await;
let state = handle.state();
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let responder = tokio::spawn(async move {
respond_to_aborted_start(&mut ws, None, 4343).await;
let _ = release_rx.await;
});
let start = bsk::daemon::sessions::start_session(
&state.browsers,
&state.sessions,
&state.tool_queues,
None,
bsk::daemon::sessions::AgentWindowOptions::default(),
Duration::ZERO,
Duration::from_millis(20),
None,
)
.await;
assert!(matches!(
start,
Err(bsk::daemon::sessions::StartSessionError::Timeout)
));
assert!(state.sessions.is_empty());
let _ = release_tx.send(());
responder.await.unwrap();
handle.shutdown().await;
}
#[tokio::test]
async fn cancelling_session_stop_keeps_the_session_retryable() {
const STOP_RPC_ID: &str = "session-stop-cancel";
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let (stop_seen_tx, stop_seen_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let responder = tokio::spawn(async move {
respond_to_aborted_stop(&mut ws, Some(stop_seen_tx)).await;
let _ = release_rx.await;
});
let mut start_ipc = IpcClient::connect(&sock).await.unwrap();
let start: serde_json::Value = start_ipc
.call(
"start-before-stop-cancel",
Method::SessionStart,
Some(serde_json::json!({ "focused": false })),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
let session_id = start["session_id"].as_str().unwrap().to_string();
let mut stop_ipc = IpcClient::connect(&sock).await.unwrap();
let stop_session_id = session_id.clone();
let stop_call = tokio::spawn(async move {
let outcome: Result<serde_json::Value, RpcError> = stop_ipc
.call_with_id(
STOP_RPC_ID.to_string(),
Method::SessionStop,
Some(serde_json::json!({
"session_id": stop_session_id,
"all": false,
})),
Duration::from_secs(5),
)
.await
.unwrap();
outcome
});
stop_seen_rx.await.unwrap();
let mut cancel_ipc = IpcClient::connect(&sock).await.unwrap();
let cancel: CancelResult = cancel_ipc
.call(
"cancel-stop",
Method::Cancel,
Some(CancelParams {
rpc_id: STOP_RPC_ID.to_string(),
}),
Duration::from_secs(2),
)
.await
.unwrap()
.unwrap();
assert!(cancel.cancelled);
let stop_error = stop_call.await.unwrap().unwrap_err();
assert_eq!(stop_error.code, ErrorCode::Cancelled);
let mut status_ipc = IpcClient::connect(&sock).await.unwrap();
let status: StatusResult = status_ipc
.call::<(), _>(
"status-after-stop-cancel",
Method::SystemStatus,
None,
Duration::from_secs(2),
)
.await
.unwrap()
.unwrap();
assert_eq!(status.sessions.len(), 1);
assert_eq!(status.sessions[0].session_id, session_id);
let _ = release_tx.send(());
responder.await.unwrap();
handle.shutdown().await;
}
#[tokio::test]
async fn timing_out_session_stop_cancels_extension_teardown_and_keeps_session() {
let (handle, sock) = spawn_daemon().await;
let state = handle.state();
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let responder = tokio::spawn(async move {
respond_to_aborted_stop(&mut ws, None).await;
let _ = release_rx.await;
});
let mut start_ipc = IpcClient::connect(&sock).await.unwrap();
let start: serde_json::Value = start_ipc
.call(
"start-before-stop-timeout",
Method::SessionStart,
Some(serde_json::json!({ "focused": false })),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
let session_id =
bsk::daemon::sessions::SessionId(start["session_id"].as_str().unwrap().to_string());
let stop = bsk::daemon::sessions::stop_session(
&state.browsers,
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&session_id,
Duration::from_millis(20),
None,
)
.await;
assert!(matches!(
stop,
Err(bsk::daemon::sessions::StopSessionError::Timeout)
));
assert!(state.sessions.get(&session_id).is_some());
let _ = release_tx.send(());
responder.await.unwrap();
handle.shutdown().await;
}
#[tokio::test]
async fn session_idle_timeout_stops_and_unregisters_session() {
let (handle, _sock) = spawn_daemon_with_session_idle(Duration::from_millis(50)).await;
@@ -12,7 +12,7 @@
* events, and never move the registry's current pointer.
*/
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { readFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -78,6 +78,7 @@ const FAILURE_BACKOFF_THRESHOLD = 3;
const FRAME_RING_SIZE = 2;
export class ObservationService {
private readonly scratchNamespace = randomUUID();
private readonly observations = new Map<string, SessionObservation>();
private readonly listeners = new Set<(event: ObservationEvent) => void>();
private readonly captureTimers = new Map<string, unknown>();
@@ -304,9 +305,9 @@ export class ObservationService {
if (current === undefined || current.dead === true) return;
if (this.captureInFlight.has(sessionId)) return;
this.captureInFlight.add(sessionId);
// One fixed scratch path per session (overwritten each frame) and always
// unlinked after the bytes are read — /tmp must not grow without bound.
const outPath = join(tmpdir(), `bsk-obs-${sessionId}.png`);
// Stable within this service, isolated from captures owned by other instances.
const sessionKey = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
const outPath = join(tmpdir(), `bsk-obs-${this.scratchNamespace}-${sessionKey}.png`);
let writtenPath = outPath;
try {
const result = await this.deps.queue.run(sessionId, () =>
@@ -5,7 +5,7 @@
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, dirname } from "node:path";
import { describe, expect, it } from "vitest";
import {
actionForLabel,
@@ -548,8 +548,12 @@ describe("scratch files and thumbnail references", () => {
);
const firstOut = runner.calls.filter((c) => c.args[0] === "screenshot").at(-1)?.args;
const pathOf = (args: string[]) => args[args.indexOf("--out") + 1];
expect(pathOf(firstOut ?? [])).toBe(join(tmpdir(), "bsk-obs-s1.png"));
expect(existsSync(pathOf(firstOut ?? []))).toBe(false);
const firstPath = pathOf(firstOut ?? []);
expect(dirname(firstPath)).toBe(tmpdir());
expect(basename(firstPath)).toMatch(
/^bsk-obs-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9a-f]{16}\.png$/,
);
expect(existsSync(firstPath)).toBe(false);
// second frame: same path, deleted again
service.endAction("s1");
scheduler.runNext();
@@ -559,11 +563,33 @@ describe("scratch files and thumbnail references", () => {
scheduler.pending().length === 1,
);
const secondOut = runner.calls.filter((c) => c.args[0] === "screenshot").at(-1)?.args;
expect(pathOf(secondOut ?? [])).toBe(join(tmpdir(), "bsk-obs-s1.png"));
await waitFor(() => !existsSync(pathOf(secondOut ?? [])));
const secondPath = pathOf(secondOut ?? []);
expect(secondPath).toBe(firstPath);
await waitFor(() => !existsSync(secondPath));
service.dispose();
});
it("isolates scratch paths between service instances", async () => {
const first = setup({ scheduler: fakeScheduler(), runner: fakeRunner() });
const second = setup({ scheduler: fakeScheduler(), runner: fakeRunner() });
first.service.addSession("s1");
second.service.addSession("s1");
first.scheduler.runNext();
second.scheduler.runNext();
await waitFor(
() =>
first.service.getState()[0]?.thumbnailAttachmentId !== undefined &&
second.service.getState()[0]?.thumbnailAttachmentId !== undefined,
);
const pathOf = (runner: FakeRunner) => {
const args = runner.calls.find((call) => call.args[0] === "screenshot")?.args ?? [];
return args[args.indexOf("--out") + 1];
};
expect(pathOf(first.runner)).not.toBe(pathOf(second.runner));
first.service.dispose();
second.service.dispose();
});
it("keeps the last two distinct frames and evicts the third", async () => {
let n = 0;
const runner = fakeRunner({
+50 -14
View File
@@ -24,6 +24,10 @@ function scene(nodes: VomNode[]): VomScene {
};
}
function coreRefs(refs: ReturnType<typeof renderVom>["refs"]) {
return refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }));
}
describe("renderVom single-layer page", () => {
it("does not derive handle context across frame scopes", () => {
const out = renderVom(
@@ -130,7 +134,7 @@ describe("renderVom single-layer page", () => {
' @e2 button "加入购物车"',
].join("\n"),
);
expect(out.refs).toEqual([
expect(coreRefs(out.refs)).toEqual([
{ ref: "e1", backendNodeId: 3 },
{ ref: "e2", backendNodeId: 6 },
]);
@@ -151,7 +155,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).toContain(' heading "Title"');
expect(out.text).toContain(' img "Logo"');
expect(out.text).toContain(' @e1 button "Continue"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 4 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]);
});
it("assigns refs to listbox controls", () => {
@@ -163,7 +167,7 @@ describe("renderVom single-layer page", () => {
);
expect(out.text).toContain(' @e1 listbox "Choices"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
});
it("annotates external links regardless of role casing", () => {
@@ -200,7 +204,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).toContain(" alertdialog");
expect(out.text).toContain(" section");
expect(out.text).toContain(' @e1 button "Close"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 5 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 5 }]);
});
it("skips generic nodes without adding an extra indentation level", () => {
@@ -291,7 +295,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).toContain(" section");
expect(out.text).not.toContain("Too deep");
expect(out.text).toContain(' @e1 button "Later"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 4 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]);
});
it("truncates when maxTokens is exceeded while keeping refs in sync with rendered text", () => {
@@ -326,7 +330,7 @@ describe("renderVom single-layer page", () => {
);
expect(out.text).toContain('@e1 button "Open settings"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
});
it("keeps the deepest custom control when wrapper and child both look clickable", () => {
@@ -356,7 +360,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).not.toContain('@e1 button "Card"');
expect(out.text).toContain('@e1 button "Details"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 3 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 3 }]);
});
it("does not recover clickable wrappers around native controls", () => {
@@ -379,7 +383,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).not.toContain('@e1 button "Checkout"');
expect(out.text).toContain('@e1 button "Pay"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 3 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 3 }]);
});
it("adds context to duplicate weak action labels", () => {
@@ -442,7 +446,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).toContain("@layers 1 focus=L1");
expect(out.text).toContain('@e1 button "Background"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
});
it("can filter refs blocked by active positioned regions without changing layer output", () => {
@@ -485,7 +489,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).not.toContain("active-region");
expect(out.text).not.toContain("Background");
expect(out.text).toContain('@e1 button "Foreground"');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 4 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]);
});
it("keeps paint-order comparisons inside their frame document", () => {
@@ -600,7 +604,7 @@ describe("renderVom single-layer page", () => {
});
expect(out.text).toContain('@e1 button "Products" [hover first: Shoes | Bags | Accessories]');
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
});
it("injects active scope blocks immediately after their trigger without refs", () => {
@@ -626,7 +630,7 @@ describe("renderVom single-layer page", () => {
" Bob - great sound",
].join("\n"),
);
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
});
it("does not render redundant children inside named ref controls", () => {
@@ -640,7 +644,7 @@ describe("renderVom single-layer page", () => {
expect(out.text).toContain('@e1 button "Save"');
expect(out.text).not.toContain("disk icon");
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 2 }]);
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 2 }]);
});
it("does not render redundant children inside native text inputs", () => {
@@ -807,7 +811,7 @@ describe("renderVom double-layer page", () => {
].join("\n"),
);
expect(out.text).not.toContain("底层按钮");
expect(out.refs).toEqual([
expect(coreRefs(out.refs)).toEqual([
{ ref: "e1", backendNodeId: 4 },
{ ref: "e2", backendNodeId: 5 },
]);
@@ -859,4 +863,36 @@ describe("renderVom double-layer page", () => {
expect(out.text).toContain(' @e2 textbox "密码" [filled] ="•••"');
expect(out.refs.map((r) => r.backendNodeId)).toEqual([101, 102]);
});
it("redacts form values and returns semantic metadata for rendered refs", () => {
const out = renderVom(
scene([
node({ id: 1, role: "RootWebArea", name: "Form" }),
node({
id: 2,
backendNodeId: 42,
frameId: "child-frame",
parentId: 1,
role: "textbox",
name: "Email",
value: "user@example.com",
inputState: "filled",
tag: "input",
}),
]),
{ redactValues: true },
);
expect(out.text).toContain('[filled] ="•••"');
expect(out.text).not.toContain("user@example.com");
expect(out.refs[0]).toMatchObject({
ref: "e1",
backendNodeId: 42,
frameId: "child-frame",
role: "textbox",
name: "Email",
line: expect.any(Number),
});
expect(out.text.split("\n")[out.refs[0]?.line ?? -1]).toContain('@e1 textbox "Email"');
});
});
+1
View File
@@ -5,6 +5,7 @@ export type {
CondSurface,
LayerKind,
Rect,
RenderedRef,
Viewport,
VomNode,
VomOptions,
+18 -9
View File
@@ -4,9 +4,9 @@ import type {
BlockingLayer,
CondSurface,
Rect,
RenderedRef,
VomNode,
VomOptions,
VomRef,
VomResult,
VomScene,
} from "./types";
@@ -615,6 +615,7 @@ function renderNodeLine(
ref: string | undefined,
context: string[] = [],
surface: CondSurface | undefined = undefined,
redactValues = false,
): string {
let line = `${" ".repeat(depth)}${ref ? `@${ref} ` : ""}${node.role}`;
@@ -636,13 +637,14 @@ function renderNodeLine(
}
const placeholder = cleaned(node.placeholder);
if (placeholder) line += ` placeholder=${JSON.stringify(placeholder)}`;
const sensitiveHasValue =
const hasValue =
rawValue !== undefined || node.inputState === "filled" || node.inputState === "default";
const value = node.sensitive
? sensitiveHasValue
? SENSITIVE_MASK
: undefined
: rawValue?.slice(0, MAX_VALUE_LENGTH);
const value =
node.sensitive || redactValues
? hasValue
? SENSITIVE_MASK
: undefined
: rawValue?.slice(0, MAX_VALUE_LENGTH);
if (value !== undefined) line += ` =${JSON.stringify(value)}`;
if (surface && surface.subItems.length > 0) {
@@ -695,11 +697,12 @@ function shouldSkipRedundantChildren(node: VomNode, state: RenderState): boolean
interface RenderState {
lines: string[];
refs: VomRef[];
refs: RenderedRef[];
nextRef: number;
tokens: number;
maxDepth: number;
maxTokens: number;
redactValues: boolean;
truncated: boolean;
stopped: boolean;
children: Map<number | null, VomNode[]>;
@@ -764,7 +767,7 @@ function renderTree(
const ref = isVomReferenceNode(node) ? `e${state.nextRef}` : undefined;
const context = ref ? handleContext(node, state) : [];
const surface = state.surfaceMap.get(node.id);
const line = renderNodeLine(node, depth, ref, context, surface);
const line = renderNodeLine(node, depth, ref, context, surface, state.redactValues);
const nextTokens = state.tokens + estimateTokens(line);
if (nextTokens > state.maxTokens) {
state.truncated = true;
@@ -775,10 +778,15 @@ function renderTree(
state.lines.push(line);
state.tokens = nextTokens;
if (ref) {
const name = cleaned(node.name);
state.refs.push({
ref,
backendNodeId: node.backendNodeId ?? node.id,
...(node.frameId ? { frameId: node.frameId } : {}),
...(node.role ? { role: node.role } : {}),
...(name ? { name } : {}),
...(context.length > 0 ? { ctx: context.join(" > ") } : {}),
line: state.lines.length - 1,
});
state.nextRef += 1;
}
@@ -808,6 +816,7 @@ function renderNodes(
tokens: estimateTokens(initialLines.join("\n")),
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
redactValues: options.redactValues ?? false,
truncated: false,
stopped: false,
children,
+13 -1
View File
@@ -64,6 +64,10 @@ export interface VomScene {
export interface VomOptions {
maxDepth?: number;
maxTokens?: number;
/**
* When true, form values are rendered as masks instead of literal values.
*/
redactValues?: boolean;
/**
* Experimental: filter refs that are geometrically blocked by foreground
* fixed/absolute/sticky regions. Disabled by default and does not alter the
@@ -90,9 +94,17 @@ export interface VomRef {
frameId?: string;
}
export interface RenderedRef extends VomRef {
role?: string;
name?: string;
ctx?: string;
/** Zero-based line index in `VomResult.text`. */
line: number;
}
export interface VomResult {
text: string;
refs: VomRef[];
refs: RenderedRef[];
truncated: boolean;
}