Skip to content

Commit 877eba6

Browse files
authored
Merge pull request #529 from Tencent/codex/fix-527-cooperation
fix: settle observation captures and preserve completed input receipts
2 parents e58b95b + 071f72d commit 877eba6

12 files changed

Lines changed: 731 additions & 23 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ jobs:
333333
env:
334334
BSK_COOP_CHROME: ${{ env.BSK_VIDEO_CHROME }}
335335
run: pnpm exec vitest run src/content/__tests__/cooperation-capture.browser.test.ts
336+
- name: Validate thumbnail cancellation, synchronization and navigating clicks
337+
working-directory: packages/dsh-plugin-browserskill
338+
env:
339+
BSK_COOP_CHROME: ${{ env.BSK_VIDEO_CHROME }}
340+
BSK_COOP_BSK: ../../target/debug/bsk
341+
run: pnpm exec vitest run tests/cooperation.browser.test.ts
336342
- name: Validate task termination on tab detachment
337343
working-directory: apps/extension
338344
env:

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
55

66
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- Shared-control synchronization waits for cancelled observation thumbnails to
13+
settle. Viewport capture reads have a 10-second deadline with separate bounded
14+
cleanup; reaching that deadline returns `timeout` (exit 4) with screenshot-specific
15+
guidance. Browser/CDP capture failures remain `cdp_failed` (exit 3).
16+
- Completed inputs retain their successful receipts when they navigate, including
17+
clicks, Enter submissions, drag submissions, and forms submitted after filling
18+
or selecting a value. A new synchronization is still required before the next action.
19+
- CLI cancellation is armed before sending a browser command, so an immediate
20+
Ctrl-C can cancel the operation and return its outcome instead of terminating silently.
21+
822
## [0.4.0] - 2026-10-30
923

1024
### Changed

‎apps/extension/src/cooperation/controller.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,138 @@ describe("shared-tab dispatch fences", () => {
11291129
const fresh = await f.command("sync");
11301130
expect(await f.act(fresh.context_id)).toEqual({});
11311131
});
1132+
it.each([
1133+
"before-release",
1134+
"after-release",
1135+
])("preserves a completed click when navigation invalidates its document %s", async (navigation) => {
1136+
const f = await fixture();
1137+
const receipt = { tab_id: 7, used_ref: "e1", x: 4, y: 5 };
1138+
const result = await f.act(f.first.context_id, async (runner) => {
1139+
await runner.send(7, "Input.dispatchMouseEvent", {
1140+
type: "mousePressed",
1141+
button: "left",
1142+
x: 4,
1143+
y: 5,
1144+
});
1145+
if (navigation === "before-release") f.controller.invalidate(7);
1146+
await runner.send(7, "Input.dispatchMouseEvent", {
1147+
type: "mouseReleased",
1148+
button: "left",
1149+
x: 4,
1150+
y: 5,
1151+
});
1152+
if (navigation === "after-release") f.controller.invalidate(7);
1153+
return receipt;
1154+
});
1155+
expect(result).toEqual(receipt);
1156+
expect(f.native).toHaveBeenCalledTimes(2);
1157+
expect(await f.act()).toMatchObject({ code: "permission_denied" });
1158+
const fresh = await f.command("sync");
1159+
expect(await f.act(fresh.context_id)).toEqual({});
1160+
});
1161+
it.each([
1162+
"human",
1163+
"hold",
1164+
"stop",
1165+
"cancel",
1166+
"failed-release",
1167+
"more-input",
1168+
])("does not hide %s after a navigating click", async (intervention) => {
1169+
const f = await fixture();
1170+
const result = await f.act(f.first.context_id, async (runner) => {
1171+
await runner.send(7, "Input.dispatchMouseEvent", {
1172+
type: "mousePressed",
1173+
button: "left",
1174+
x: 4,
1175+
y: 5,
1176+
});
1177+
f.controller.invalidate(7);
1178+
if (intervention === "human") f.change({ human: 1, lastInput: 10000 });
1179+
if (intervention === "hold")
1180+
await f.controller.control(7, f.controller.view(7)!.revision, "hold");
1181+
if (intervention === "stop") f.controller.stopSession("shared");
1182+
if (intervention === "cancel") f.abort.abort();
1183+
if (intervention === "failed-release")
1184+
return { code: "cdp_failed", message: "release failed" };
1185+
await runner.send(7, "Input.dispatchMouseEvent", {
1186+
type: "mouseReleased",
1187+
button: "left",
1188+
x: 4,
1189+
y: 5,
1190+
});
1191+
if (intervention === "more-input")
1192+
await runner.send(7, "Input.insertText", { text: "stale" });
1193+
return { tab_id: 7, x: 4, y: 5 };
1194+
});
1195+
expect(result).toMatchObject({
1196+
code:
1197+
intervention === "cancel"
1198+
? "cancelled"
1199+
: intervention === "stop"
1200+
? "user_aborted"
1201+
: "permission_denied",
1202+
data: { effect_state: "unknown" },
1203+
});
1204+
expect(f.native).toHaveBeenCalledTimes(intervention === "failed-release" ? 1 : 2);
1205+
});
1206+
it("preserves a completed Enter submission after its document is replaced", async () => {
1207+
const f = await fixture();
1208+
const receipt = { tab_id: 7, key: "Enter", code: "Enter", modifiers: [] };
1209+
const result = await f.act(
1210+
f.first.context_id,
1211+
async (runner) => {
1212+
await runner.send(7, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "Enter" });
1213+
await runner.send(7, "Input.dispatchKeyEvent", { type: "char", key: "Enter", text: "\r" });
1214+
f.controller.invalidate(7);
1215+
await runner.send(7, "Input.dispatchKeyEvent", { type: "keyUp", key: "Enter" });
1216+
return receipt;
1217+
},
1218+
"tool.press",
1219+
);
1220+
expect(result).toEqual(receipt);
1221+
expect(await f.act()).toMatchObject({ code: "permission_denied" });
1222+
const fresh = await f.command("sync");
1223+
expect(await f.act(fresh.context_id)).toEqual({});
1224+
});
1225+
it.each([
1226+
"tool.drag",
1227+
"tool.select",
1228+
])("preserves completed %s receipts after navigation", async (method) => {
1229+
const f = await fixture();
1230+
const receipt = { tab_id: 7 };
1231+
const result = await f.act(
1232+
f.first.context_id,
1233+
async (runner) => {
1234+
if (method === "tool.drag") {
1235+
await runner.send(7, "Input.dispatchMouseEvent", {
1236+
type: "mousePressed",
1237+
button: "left",
1238+
x: 4,
1239+
y: 5,
1240+
});
1241+
await runner.send(7, "Input.dispatchMouseEvent", {
1242+
type: "mouseReleased",
1243+
button: "left",
1244+
x: 10,
1245+
y: 5,
1246+
});
1247+
} else {
1248+
await runner.beginDomMutation?.();
1249+
await runner.send(7, "Runtime.callFunctionOn", {
1250+
objectId: "select",
1251+
functionDeclaration: "function() { this.dispatchEvent(new Event('change')); }",
1252+
});
1253+
}
1254+
f.controller.invalidate(7);
1255+
return receipt;
1256+
},
1257+
method,
1258+
);
1259+
expect(result).toEqual(receipt);
1260+
expect(await f.act()).toMatchObject({ code: "permission_denied" });
1261+
const fresh = await f.command("sync");
1262+
expect(await f.act(fresh.context_id)).toEqual({});
1263+
});
11321264
it("still blocks input after a navigation invalidates the action's document", async () => {
11331265
const f = await fixture();
11341266
const result = await f.act(

‎apps/extension/src/cooperation/controller.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,15 +1321,16 @@ export class CooperationController implements ControlAuthority {
13211321
data: { effect_state: sent ? "unknown" : "none" },
13221322
};
13231323
if (["tool.evaluate", "tool.upload"].includes(req.method)) await this.refresh(state);
1324-
// Navigation replaces its observed document. Return the handler's completed
1325-
// receipt without restoring that context or allowing any further input.
1324+
// A completed navigation or input can replace the observed
1325+
// document. Preserve the handler's receipt without authorizing further input.
13261326
// Human intervention, cancellation and failed dispatch still take priority.
13271327
const settledNavigation =
13281328
sent &&
13291329
!isRpcError(result) &&
1330-
["tool.navigate", "tool.navigate_back", "tool.navigate_forward", "tool.reload"].includes(
1330+
(["tool.navigate", "tool.navigate_back", "tool.navigate_forward", "tool.reload"].includes(
13311331
req.method,
1332-
);
1332+
) ||
1333+
nativeInputStarted);
13331334
const reason = revoked ?? interruption(settledNavigation);
13341335
if (reason) return failure(reason, sent ? "unknown" : "none");
13351336
return result;

‎apps/extension/src/tools/__tests__/viewport-screenshot.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,84 @@ describe("controlled viewport capture", () => {
9393
expect(f.bridge.mock.calls.at(-1)?.[1].phase).toBe("end");
9494
});
9595

96+
it.each([
97+
"Page.getFrameTree",
98+
"Page.createIsolatedWorld",
99+
"Runtime.evaluate",
100+
"Page.captureScreenshot",
101+
])("cancels a pending %s without waiting for Chrome's reply", async (method) => {
102+
const f = fixture();
103+
let release!: () => void;
104+
const pending = new Promise<void>((resolve) => {
105+
release = resolve;
106+
});
107+
f.hook.command = async (name) => {
108+
if (name === method) await pending;
109+
};
110+
const abort = new AbortController();
111+
const result = f.run(abort.signal);
112+
await vi.waitFor(() =>
113+
expect(f.send.mock.calls.some(([, name]) => name === method)).toBe(true),
114+
);
115+
abort.abort();
116+
try {
117+
await expect(result).resolves.toMatchObject({ code: "cancelled" });
118+
const commands = f.send.mock.calls.length;
119+
release();
120+
await Promise.resolve();
121+
expect(f.send.mock.calls).toHaveLength(commands);
122+
if (method === "Page.captureScreenshot")
123+
expect(f.bridge.mock.calls.at(-1)?.[1].phase).toBe("end");
124+
f.hook.command = async () => {};
125+
expect(await f.run()).toMatchObject({ image_base64: PNG });
126+
} finally {
127+
release();
128+
}
129+
});
130+
it.each([
131+
"screenshot",
132+
"before-capture identity",
133+
"after-capture identity",
134+
])("times out hung %s and bounds overlay restoration", async (stage) => {
135+
vi.useFakeTimers();
136+
const f = fixture();
137+
let identities = 0;
138+
f.hook.command = async (method) => {
139+
if (method === "Runtime.evaluate") identities++;
140+
if (
141+
(stage === "screenshot" && method === "Page.captureScreenshot") ||
142+
(method === "Runtime.evaluate" &&
143+
identities ===
144+
(stage === "before-capture identity" ? 2 : stage === "after-capture identity" ? 3 : 0))
145+
)
146+
await new Promise(() => {});
147+
};
148+
f.hook.bridge = async (phase) => {
149+
if (phase === "end") await new Promise(() => {});
150+
};
151+
try {
152+
const result = f.run();
153+
const assertion = expect(result).resolves.toMatchObject({ code: "timeout" });
154+
await vi.advanceTimersByTimeAsync(11_000);
155+
await assertion;
156+
expect(f.bridge.mock.calls.at(-1)?.[1].phase).toBe("end");
157+
} finally {
158+
vi.useRealTimers();
159+
}
160+
});
161+
it("cancels a pending overlay begin and restores it without capturing", async () => {
162+
const f = fixture();
163+
f.hook.bridge = async (phase) => {
164+
if (phase === "begin") await new Promise(() => {});
165+
};
166+
const abort = new AbortController();
167+
const result = f.run(abort.signal);
168+
await vi.waitFor(() => expect(f.bridge).toHaveBeenCalled());
169+
abort.abort();
170+
await expect(result).resolves.toMatchObject({ code: "cancelled" });
171+
expect(f.bridge.mock.calls.map(([, message]) => message.phase)).toEqual(["begin", "end"]);
172+
expect(f.send.mock.calls.some(([, method]) => method === "Page.captureScreenshot")).toBe(false);
173+
});
96174
it("reports capture failure without retrying another target or backend", async () => {
97175
const f = fixture();
98176
f.hook.command = async (method) => {

‎apps/extension/src/tools/viewport-screenshot.ts‎

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { RpcError } from "@/transport/types";
66
import { rpcError } from "./errors";
77
import { parsePngDimensions } from "./png";
88
import type { CdpRunner } from "./shared";
9+
import { BoundedWaitError, waitBounded } from "./transfer-transaction";
910
import { isAbortError, throwIfAborted } from "./vom/capture-abort";
1011
import { verifyDocumentIdentity } from "./vom/document-identity";
1112
import type { DocumentIdentity } from "./vom/facts";
@@ -88,19 +89,59 @@ export async function captureControlledViewport(
8889
signal?: AbortSignal,
8990
sendToTab?: CaptureSuppressSendToTab,
9091
): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
91-
try {
92+
const deadline = Date.now() + 10_000;
93+
const wait = <T>(work: Promise<T>, cleanup = false) =>
94+
waitBounded(
95+
work,
96+
cleanup ? Date.now() + 500 : deadline,
97+
cleanup ? undefined : signal,
98+
"Viewport capture timed out",
99+
);
100+
const check = () => {
92101
throwIfAborted(signal);
93-
if (!(await stillControlled())) return changed();
94-
const identity = await readDocumentIdentity(cdp, tabId, signal);
102+
if (Date.now() >= deadline) throw new BoundedWaitError("timeout", "Viewport capture timed out");
103+
};
104+
// Chrome cannot cancel dispatched reads. Retire their waiters and consume late
105+
// replies; never continue the pipeline after its cancellation or deadline.
106+
const captureCdp: CdpRunner = {
107+
getAttachmentId: (id) => cdp.getAttachmentId?.(id),
108+
send: (id, method, params) => {
109+
const cleanup = method === "Runtime.releaseObjectGroup";
110+
if (!cleanup) check();
111+
return wait(cdp.send(id, method, params), cleanup);
112+
},
113+
};
114+
const controlled = () => {
115+
check();
116+
return wait(stillControlled());
117+
};
118+
const send = sendToTab ?? ((id, message) => chrome.tabs.sendMessage(id, message));
119+
const overlay: CaptureSuppressSendToTab = async (id, message) => {
120+
if (message.phase === "end") return wait(send(id, message), true);
121+
check();
122+
try {
123+
return await wait(send(id, message));
124+
} catch (error) {
125+
// The content script hides the overlay before acknowledging begin. Restore
126+
// it even if that acknowledgement never arrives, with a separate budget.
127+
if (error instanceof BoundedWaitError)
128+
await wait(send(id, { ...message, phase: "end" }), true).catch(() => {});
129+
throw error;
130+
}
131+
};
132+
try {
133+
check();
134+
if (!(await controlled())) return changed();
135+
const identity = await readDocumentIdentity(captureCdp, tabId, signal);
95136
if (!identity) return changed();
96-
const bound = boundToAttachment(cdp, tabId, identity.attachmentId);
137+
const bound = boundToAttachment(captureCdp, tabId, identity.attachmentId);
97138
const current = async () => {
98-
throwIfAborted(signal);
99-
return (
100-
(await stillControlled()) &&
101-
(await verifyDocumentIdentity(bound, identity, signal)) === "current" &&
102-
cdp.getAttachmentId?.(tabId) === identity.attachmentId
103-
);
139+
if (!(await controlled())) return false;
140+
const verified = await verifyDocumentIdentity(bound, identity, signal);
141+
// Identity verification treats failed reads as unavailable. Preserve this
142+
// transaction's timeout instead of reporting a document change.
143+
check();
144+
return verified === "current" && cdp.getAttachmentId?.(tabId) === identity.attachmentId;
104145
};
105146
const shot = await withExtensionOverlayHidden(
106147
tabId,
@@ -112,11 +153,11 @@ export async function captureControlledViewport(
112153
captureBeyondViewport: false,
113154
});
114155
},
115-
sendToTab,
156+
overlay,
116157
);
117158
throwIfAborted(signal);
118159
// Validate after overlay restoration as well: navigation/return can race it.
119-
if (!shot || !(await current()) || !(await stillControlled())) return changed();
160+
if (!shot || !(await current()) || !(await controlled())) return changed();
120161
throwIfAborted(signal);
121162
if (cdp.getAttachmentId?.(tabId) !== identity.attachmentId) return changed();
122163
const dims = shot.data ? parsePngDimensions(shot.data) : null;
@@ -133,7 +174,7 @@ export async function captureControlledViewport(
133174
return { code: "cancelled", message: "screenshot aborted" };
134175
}
135176
return rpcError(
136-
"cdp_failed",
177+
error instanceof BoundedWaitError && error.kind === "timeout" ? "timeout" : "cdp_failed",
137178
"screenshot_capture_failed",
138179
error instanceof Error ? error.message : String(error),
139180
);

‎crates/bsk-cli/src/cli/business_rpc.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ where
150150

151151
let outcome = tokio::select! {
152152
biased;
153-
res = &mut main_fut => res?,
153+
// Arm cancellation before the RPC can dispatch browser input.
154154
sig = wait_for_cancel(parent_cancel) => {
155155
sig.context("wait for cancellation").map_err(CliError::Local)?;
156156
CANCEL_REQUESTED.store(true, Ordering::Relaxed);
@@ -171,6 +171,7 @@ where
171171
}
172172
}
173173
}
174+
res = &mut main_fut => res?,
174175
};
175176

176177
outcome.map_err(CliError::from_rpc)

0 commit comments

Comments
 (0)