fix(agent): bound push suppression to observed browser attention

This commit is contained in:
2026-10-14 23:06:34 -07:00
parent f534f76b23
commit 5833a0868e
13 changed files with 194 additions and 37 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ export const disableAgentBrowserPush = () => controller.disable();
export const refreshAgentBrowserPush = () => controller.refresh();
const presence = createAgentPresenceReporter({
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
});
let stopSession: (() => void) | undefined;
+1 -1
View File
@@ -145,7 +145,7 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
}
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
clientKind: "web", ...(agentPushUuid(presenceSessionId) ? { presenceSessionId } : {}) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
+19
View File
@@ -108,6 +108,20 @@ try {
assert.equal(sessionPaths.length, 2);
const secondPath = sessionPaths.find(path => !path.endsWith(firstSession));
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.focused === true));
// Playwright forces every target focused/visible. Supply controlled DOM state
// to exercise the production reporter's blur/visibility wiring deterministically.
const beforeBlur = requests.length;
await page.evaluate(() => {
Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false });
window.dispatchEvent(new Event("blur"));
});
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === false));
await page.evaluate(() => {
Object.defineProperty(document, "hidden", { configurable: true, value: true });
document.dispatchEvent(new Event("visibilitychange"));
});
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
await page.evaluate(() => { delete document.hasFocus; delete document.hidden; window.dispatchEvent(new Event("focus")); });
await second.getByRole("textbox").first().click();
await second.getByRole("textbox").first().pressSequentially("interaction");
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.interacted === true));
@@ -116,6 +130,11 @@ try {
assert.deepEqual(sequences, [...new Set(sequences)].sort((a, b) => a - b));
}
await page.bringToFront();
const beforeHide = requests.length;
await page.evaluate(() => window.dispatchEvent(new Event("pagehide")));
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
await page.evaluate(() => window.dispatchEvent(new Event("pageshow")));
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === true));
const worker = context.serviceWorkers().find(worker => new URL(worker.url()).pathname === "/sw.js");
assert.ok(worker, "actual service worker activated");
const push = async cursor => worker.evaluate(async ({ cursor, sourceId }) => {
+1 -1
View File
@@ -405,7 +405,7 @@ test("attention is per-tab, ordered, focused while idle, and recent input persis
c.interact(); const report = c.report(); failure.reject(new Error("offline")); await assert.rejects(report); await c.report();
const id = randomUUID(), frame = JSON.parse(agentAuthFrame("fixture-token", "2", id));
assert.equal(frame.clientKind, "web"); assert.equal(frame.presenceSessionId, id);
assert.equal(JSON.parse(agentAuthFrame(null, "0", "not-an-id")).clientKind, undefined);
assert.deepEqual(JSON.parse(agentAuthFrame(null, "0", "not-an-id")), { type: "auth", token: null, after: "0", clientKind: "web" });
});
test("read frames validate source/cursor and preserve existing read semantics", () => {
+2 -2
View File
@@ -254,8 +254,8 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
const url = client.websocketUrl("https://sybil.example/");
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
assert.equal(new URL(url).search, "");
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42", clientKind: "web" });
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42", clientKind: "web" });
});
test("client sends the pinned source and exposes409 without requiring a JSON response body", async () => {