From 3106ceff1798581a6acb96d2b3477010adeacd10 Mon Sep 17 00:00:00 2001 From: neo-preview Date: Mon, 24 Aug 2026 01:44:47 +0200 Subject: [PATCH] feat(agentos): defer wakes that would type into a pending interactive dialog (#17629) --- ai/daemons/wake/localWakeAdapters.mjs | 66 ++++++ ai/daemons/wake/receiver.mjs | 50 ++++- .../daemons/wake/localWakeAdapters.spec.mjs | 16 +- .../wake/localWakeAdaptersDialogGate.spec.mjs | 203 ++++++++++++++++++ 4 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 test/playwright/unit/ai/daemons/wake/localWakeAdaptersDialogGate.spec.mjs diff --git a/ai/daemons/wake/localWakeAdapters.mjs b/ai/daemons/wake/localWakeAdapters.mjs index 68176440d1..fc52b5cf99 100644 --- a/ai/daemons/wake/localWakeAdapters.mjs +++ b/ai/daemons/wake/localWakeAdapters.mjs @@ -588,6 +588,57 @@ async function deliverWebhook({digest, effects, meta, signal}) { if (!response.ok) throw new Error(`local webhook adapter failed with HTTP ${response.status}`); } +/** + * @summary Builds the dialog-gate probe argv: one read-only System Events query that asks + * whether the target seat's focused UI element is a text-entry field. + * + * A pending interactive prompt (selection dialog, permission sheet) moves focus off the composer's + * text area; injecting keystrokes then feeds the wake TO the dialog, which submits it as the + * operator's answer — the destroyed-intent failure this gate exists to prevent. Reading before + * writing is the smallest delta that honors the temporal half of the verified-process invariant. + * + * Failure semantics are deliberately split: + * - readable role that is NOT a text field → `interactive dialog pending` error → the caller DEFERS; + * - unreadable state (`missing value`) or any probe throw that does not name a dialog → the caller + * FAILS OPEN and delivers, because silent non-delivery is the dead-realm failure mode and the + * mailbox stays authoritative. + * + * The `-- interactiveDialogProbe` comment is load-bearing twice over: it marks the emitted argv for + * red-capable fixtures, and it names the gate for receiver logs triaging a deferred wake. + * + * @param {Object} config + * @param {String} config.appName Canonical harness app name. + * @param {Number|null} config.instancePid Resolved seat pid when addressType is pid/userDataDir. + * @returns {String[]} `osascript` argv fragments. + * @private + */ +function buildDialogGateArgs({appName, instancePid}) { + const escapedAppName = escapeAppleScript(appName); + const targetPid = instancePid ? String(instancePid) : ''; + + return [ + '-e', ` set targetAppName to "${escapedAppName}"`, + '-e', ' set targetBundleId to ""', + '-e', ' try', + '-e', ` set targetBundleId to id of application "${escapedAppName}"`, + '-e', ' end try', + '-e', ` set targetProcessId to "${targetPid}"`, + '-e', ' -- interactiveDialogProbe: readable non-text focus means a prompt owns the input path', + '-e', ' tell application "System Events"', + ...resolveTargetProcessLines(' '), + '-e', ' tell targetProcess', + '-e', ' set focusedRole to missing value', + '-e', ' try', + '-e', ' set focusedRole to role of focused element of window 1', + '-e', ' end try', + '-e', ' if focusedRole is not missing value and focusedRole is not in {"AXTextArea", "AXTextField"} then', + '-e', ' error "interactive dialog pending at phase before input"', + '-e', ' end if', + '-e', ' end tell', + '-e', ' end tell' + ]; +} + /** * @summary Delivers through the existing draft-preserving, frontmost-verified macOS path. * @private @@ -623,6 +674,21 @@ async function deliverOsascript({digest, effects, meta, record}) { instancePid = target.pid; } + // Dialog gate — read before writing. A readable non-text focus means a pending + // interactive prompt owns the input path: defer (the receiver parks and reschedules under a + // bound) rather than type the wake into the operator's dialog. Any probe failure that does not + // name a dialog fails open into normal delivery. + try { + await effects.spawnAsync('osascript', buildDialogGateArgs({appName, instancePid})); + } catch (error) { + if (/interactive dialog pending/.test(String(error?.message || ''))) { + return {outcome: 'deferred', outcomeReason: 'interactive-dialog-pending'}; + } + effects.log.warn?.( + `[Wake Receiver] dialog gate could not probe ${record.subscriptionId}; delivering fail-open` + ); + } + const args = buildOsascriptArgs({ appName, digest, diff --git a/ai/daemons/wake/receiver.mjs b/ai/daemons/wake/receiver.mjs index 30c691631a..cfa05f531e 100644 --- a/ai/daemons/wake/receiver.mjs +++ b/ai/daemons/wake/receiver.mjs @@ -21,6 +21,15 @@ import {evaluateContextGate} from './contextGatePolicy.mjs'; import {dispatchLocalWake, probeSessionContext} from './localWakeAdapters.mjs'; const DEFAULT_MAX_BODY_BYTES = 256 * 1024; + +/** + * Dialog-gate bound: how many times a wake deferred for a pending interactive prompt may + * re-park before it exhausts into an observable failure. Dialogs are transient (seconds to + * minutes), so the default covers a pending question across many drain cycles; a seat parked on a + * dialog for good surfaces as `dialog-defer-bound-exhausted` rather than parking silently. + * @type {Number} + */ +const DIALOG_DEFER_BOUND = 20; /** * Coalesces the burst of filesystem events one atomic publish produces into a single reload. * @type {Number} @@ -315,10 +324,49 @@ export function createWakeReceiver({ outcomeReason = String(result.outcomeReason); } - if (!['delivered', 'skipped', 'failed', 'unknown'].includes(outcome)) { + if (!['delivered', 'skipped', 'failed', 'unknown', 'deferred'].includes(outcome)) { outcomeReason = `invalid-adapter-outcome:${String(outcome)}`; outcome = 'failed'; } + + // The dialog gate: an adapter that read a pending interactive prompt on + // the target seat DEFERS instead of typing the wake into the operator's dialog. + // Same park-and-reschedule contract as the context gate above, but bounded — + // a dialog is a transient state, so the wake retries on subsequent drains until + // either the seat returns to composer state (deliver, envelope identity intact) + // or the bound exhausts into an OBSERVABLE failure. A defer that can park + // forever is the silent non-delivery failure mode wearing a polite name. + if (outcome === 'deferred') { + const deferCount = (dispatching.deferCount || 0) + 1; + + if (deferCount > DIALOG_DEFER_BOUND) { + const exhausted = `dialog-defer-bound-exhausted:${outcomeReason || 'unknown'}`; + + await state.transition(record.recordKey, 'dispatching', 'failed', { + outcomeReason : exhausted, + deferCount, + dispatchFinishedAt: new Date().toISOString() + }); + logger.error?.( + `[Wake Receiver] dialog gate EXHAUSTED ${record.subscriptionId} after ` + + `${deferCount} deferrals (${outcomeReason}); surfacing as failed — ` + + 'the mailbox stays authoritative' + ); + continue; + } + + await state.transition(record.recordKey, 'dispatching', 'pending', { + deferCount, + deferredAt : new Date().toISOString(), + deferReason: outcomeReason || 'interactive-dialog-pending' + }); + logger.warn?.( + `[Wake Receiver] dialog gate DEFERRED ${record.subscriptionId}: a pending ` + + `interactive prompt holds the seat's input path; the wake waits for ` + + `composer state (defer #${deferCount}/${DIALOG_DEFER_BOUND})` + ); + continue; + } } catch (error) { outcomeReason = error?.code || error?.name || 'adapter-error'; outcome = 'failed'; diff --git a/test/playwright/unit/ai/daemons/wake/localWakeAdapters.spec.mjs b/test/playwright/unit/ai/daemons/wake/localWakeAdapters.spec.mjs index 7e19cdf7aa..7bdadb5460 100644 --- a/test/playwright/unit/ai/daemons/wake/localWakeAdapters.spec.mjs +++ b/test/playwright/unit/ai/daemons/wake/localWakeAdapters.spec.mjs @@ -311,7 +311,8 @@ test.describe.serial('ai/daemons/wake/localWakeAdapters', () => { }); test('a post-submit draft-restore focus race is delivered and never retried', async () => { - let attempts = 0; + let probeAttempts = 0; + let deliveryAttempts = 0; const uiRecord = record('osascript', { route: { agentIdentity, @@ -323,12 +324,19 @@ test.describe.serial('ai/daemons/wake/localWakeAdapters', () => { expect(await dispatchLocalWake(uiRecord, { platform : 'darwin', getDefaultTarget: async () => ({status: 'resolved', pid: 4321, instanceCount: 1, bundleName: 'Claude'}), - spawnAsync : async () => { - attempts++; + // The dialog-gate probe is read-only and runs once before delivery; its own + // failure must fail OPEN without consuming the delivery retry budget. + spawnAsync : async (command, args) => { + if (args.some(a => String(a).includes('interactiveDialogProbe'))) { + probeAttempts++; + throw new Error('AX tree unavailable for dialog probe'); + } + deliveryAttempts++; throw new Error('Target app lost frontmost status before user input restore paste (-2700)'); } })).toBe('delivered'); - expect(attempts).toBe(1); + expect(deliveryAttempts).toBe(1); + expect(probeAttempts).toBe(1); }); test('a terminal osascript failure reports the captured stderr, in the log and on the outcome (#16259)', async () => { diff --git a/test/playwright/unit/ai/daemons/wake/localWakeAdaptersDialogGate.spec.mjs b/test/playwright/unit/ai/daemons/wake/localWakeAdaptersDialogGate.spec.mjs new file mode 100644 index 0000000000..1294c2fa00 --- /dev/null +++ b/test/playwright/unit/ai/daemons/wake/localWakeAdaptersDialogGate.spec.mjs @@ -0,0 +1,203 @@ +import {test, expect} from '@playwright/test'; + +import {dispatchLocalWake} from '../../../../../../ai/daemons/wake/localWakeAdapters.mjs'; +import {createWakeReceiver} from '../../../../../../ai/daemons/wake/receiver.mjs'; +import {WakeReceiverState, getWakeRecordKey} from '../../../../../../ai/daemons/wake/receiverState.mjs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * A wake arriving while the target seat holds a pending interactive prompt must not be + * typed into that prompt. Red-capable pair: pre-fix, the delivery argv executed and the envelope + * became the dialog's answer; post-fix, the adapter classifies the dialog-pending state as a + * DEFERRED outcome and the receiver parks-and-reschedules it under a bounded count. + */ + +const DIALOG_PENDING_MESSAGE = 'interactive dialog pending at phase before input'; + +const baseRecord = subscriptionId => ({ + subscriptionId, + envelope: { + payload : {totalEvents: 1, latestMessage: {subject: 'probe', priority: 'normal'}}, + identity: '@neo-preview' + }, + route: { + agentIdentity : '@neo-preview', + harnessTargetMetadata: { + adapter : 'osascript', + appName : 'Claude', + addressType : 'pid', + instanceAddress: '/Users/tobiasuhlig/Library/Application Support/Claude' + }, + adapterConfig: {attemptTimeoutMs: 10_000} + } +}); + +const baseEffects = overrides => ({ + platform : 'darwin', + log : {log() {}, warn() {}, error() {}}, + homedir : os.homedir, + fs, + fetch : globalThis.fetch, + getDefaultTarget : async () => ({status: 'ok', pid: 4242}), + resolveGuiInstancePid: async () => 4242, + spawnAsync : async () => '', + ...overrides +}); + +test.describe('#17629 — wake vs pending interactive dialog', () => { + + test('adapter DEFERS instead of typing when the accessibility probe reports a prompt structure', async () => { + const seen = []; + const result = await dispatchLocalWake(baseRecord('WAKE_SUB:dialog-defer-a'), baseEffects({ + spawnAsync: async (command, args) => { + seen.push({command, args}); + // The final phase executes the paste+submit keystroke block; its presence proves + // typing happened. Pre-fix this line IS reached; post-fix it must never be. + if (args.some(a => String(a).includes('keystroke "v"'))) { + return ''; + } + if (args.some(a => String(a).includes('interactiveDialogProbe'))) { + throw new Error(DIALOG_PENDING_MESSAGE); + } + return ''; + } + })); + + expect(result).toEqual({ + outcome : 'deferred', + outcomeReason: 'interactive-dialog-pending' + }); + const typed = seen.filter(s => s.args.some(a => String(a).includes('keystroke "v"'))); + expect(typed).toHaveLength(0); + }); + + test('a composer-state probe passes through and delivery proceeds unchanged', async () => { + let pasteReached = false; + const result = await dispatchLocalWake(baseRecord('WAKE_SUB:dialog-defer-b'), baseEffects({ + spawnAsync: async (command, args) => { + if (args.some(a => String(a).includes('interactiveDialogProbe'))) return 'composer'; + if (args.some(a => String(a).includes('keystroke "v"'))) pasteReached = true; + return ''; + } + })); + + expect(result).toBe('delivered'); + expect(pasteReached).toBe(true); + }); + + test('probe failure fails open (delivers) rather than withholding coordination', async () => { + let pasteReached = false; + const result = await dispatchLocalWake(baseRecord('WAKE_SUB:dialog-defer-c'), baseEffects({ + spawnAsync: async (command, args) => { + if (args.some(a => String(a).includes('interactiveDialogProbe'))) { + throw new Error('AX probe unavailable'); + } + if (args.some(a => String(a).includes('keystroke "v"'))) pasteReached = true; + return ''; + } + })); + + expect(result).toBe('delivered'); + expect(pasteReached).toBe(true); + }); +}); + +test.describe('#17629 — receiver parks and bounds deferred wakes', () => { + + const buildReceiver = async dispatchImpl => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wake-dialog-gate-')); + const state = new WakeReceiverState({stateDir: dir}); + const manifest = { + schemaVersion: 1, + routes : { + 'WAKE_SUB:dialog-defer-r': { + agentIdentity : '@neo-preview', + signingKey : 'k'.repeat(64), + harnessTargetMetadata: {adapter: 'test'}, + adapterConfig : {attemptTimeoutMs: 1000} + } + } + }; + const lines = []; + const receiver = createWakeReceiver({ + manifest, + state, + dispatch: dispatchImpl, + logger : {warn: m => lines.push(m), error: m => lines.push(m), log: () => {}} + }); + await state.init(); + return {state, receiver, lines}; + }; + + test('deferred outcome returns the record to pending with named defer metadata', async () => { + const {state, receiver} = await buildReceiver(async () => ({ + outcome : 'deferred', + outcomeReason: 'interactive-dialog-pending' + })); + + await state.accept({ + subscriptionId: 'WAKE_SUB:dialog-defer-r', + eventId : 'evt-defer-a', + envelope : {payload: {totalEvents: 1}} + }); + + await receiver.drain(); + const record = await state.read(getWakeRecordKey({subscriptionId: 'WAKE_SUB:dialog-defer-r', eventId: 'evt-defer-a'})); + + expect(record.state).toBe('pending'); + expect(record.deferCount).toBe(1); + expect(record.deferReason).toContain('interactive-dialog-pending'); + }); + + test('the defer bound exhausts into an observable failure, never silent parking', async () => { + const {state, receiver, lines} = await buildReceiver(async () => ({ + outcome : 'deferred', + outcomeReason: 'interactive-dialog-pending' + })); + + await state.accept({ + subscriptionId: 'WAKE_SUB:dialog-defer-r', + eventId : 'evt-defer-b', + envelope : {payload: {totalEvents: 1}} + }); + + let record; + for (let i = 0; i < 20; i++) { + await receiver.drain(); + record = await state.read(getWakeRecordKey({subscriptionId: 'WAKE_SUB:dialog-defer-r', eventId: 'evt-defer-b'})); + expect(record.state).toBe('pending'); + } + expect(record.deferCount).toBe(20); + + await receiver.drain(); + record = await state.read(getWakeRecordKey({subscriptionId: 'WAKE_SUB:dialog-defer-r', eventId: 'evt-defer-b'})); + expect(record.state).toBe('failed'); + expect(record.outcomeReason).toContain('dialog-defer-bound-exhausted'); + expect(lines.some(l => l.includes('WAKE_SUB:dialog-defer-r'))).toBe(true); + }); + + test('envelope identity survives the defer round-trip byte-for-byte', async () => { + let captured; + let calls = 0; + const {state, receiver} = await buildReceiver(async record => { + calls += 1; + if (calls === 1) return {outcome: 'deferred', outcomeReason: 'interactive-dialog-pending'}; + captured = record; + return 'delivered'; + }); + + const envelope = {payload: {totalEvents: 3, latestMessage: {subject: 'identity-probe'}}, signature: 'sig'}; + await state.accept({ + subscriptionId: 'WAKE_SUB:dialog-defer-r', + eventId : 'evt-defer-c', + envelope + }); + + await receiver.drain(); + await receiver.drain(); + + expect(captured?.envelope).toEqual(envelope); + }); +});