From 28f1c944d82d89d8051a15ab143b93850e4df686 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 15:58:22 +0300 Subject: [PATCH 01/13] Fix social click guards and verify X message delivery --- docs/social-publication-contract.md | 8 + src/chrome/src/agent/adapters.js | 20 +- src/chrome/src/agent/agent.js | 144 ++++++++--- src/chrome/src/agent/submit-click-guard.js | 4 + src/chrome/src/cdp/cdp-client.js | 5 +- src/chrome/src/content/content.js | 122 ++++++++- src/firefox/src/agent/adapters.js | 20 +- src/firefox/src/agent/agent.js | 123 +++++++-- src/firefox/src/agent/submit-click-guard.js | 4 + src/firefox/src/content/content.js | 123 ++++++++- .../fixtures/message-recipient-navigation.mjs | 241 ++++++++++++++++++ test/run.js | 26 +- test/social-publish-contract-dom.mjs | 60 ++++- test/social-publish-contract.mjs | 58 +++++ 14 files changed, 885 insertions(+), 73 deletions(-) diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index 5b5ad41a9..1cafbfd79 100644 --- a/docs/social-publication-contract.md +++ b/docs/social-publication-contract.md @@ -8,6 +8,8 @@ X and Bluesky publication use the agent's selected provider for language underst Prior user context includes at most four recent whole turns, within the remaining 120,000-character serialized source budget. Current instructions and clarification pairs take priority. Selection stops at a turn that cannot fit; it never truncates a correction or skips back to an older instruction. Refreshes also bound previously cached history. Oversized current instructions still block compilation rather than being silently truncated. +Assistant draft references include ordinary assistant text and exact `done(summary)` arguments paired with a completed tool result. The tool result proves completion only; its summary and page-derived verification fields cannot replace the authored draft. This keeps references such as “publish the second option” intact across Ask-to-Act turns. An unresolved contract reports the missing detail and asks for complete text when needed, rather than another permission-only confirmation. + The contract records destinations, accounts, ordered post bodies, media constraints, reply/quote targets, prohibited destinations, and `all`, `any`, or conditional `fallback` requirements. Exact text uses source anchors so the runtime copies the original text instead of accepting a model-reconstructed excerpt. Repeated anchors accept optional `startOccurrence` and `endOccurrence` positive, one-based ordinals, each counted from the beginning of the named source (including overlapping matches). For a short literal with identical start/end anchors, `startOccurrence` alone selects that copy. Omitted ordinals still require unambiguous anchors; invalid or reversed selections are rejected. Unsupported or ambiguous requirements produce `clarify`; read-only or narrative requests produce `none`. Invalid contracts cannot fall back to lexical intent rules. A ready contract is not sufficient permission to dispatch. Before a separate publish click, the same selected provider independently checks the authentic request against the concrete composer snapshot. Approval is cached only for that contract, action, payload, account, and page. The runtime rereads the composer after the audit and immediately before execution. A changed snapshot needs a new audit. Rejections retain the checker's reason (or identify invalid/mismatched output) in the tool result so recovery can resolve the actual issue instead of rewriting a matching draft. Arbitrary JavaScript, bundled editing/submission, keyboard submission, and opaque callbacks cannot substitute for the observed publish control. @@ -15,6 +17,8 @@ A ready contract is not sufficient permission to dispatch. Before a separate pub ## Deterministic enforcement - Preserve exact body and alt text, accepting only NFC equivalence and CRLF normalization. Compose requests bind completion to the audited draft. +- App-observed non-submit controls such as X's “Add post” remain repeatable while composing a thread. Label-based duplicate-submit protection still applies to unresolved and actual publish controls. +- Chrome text replacement sends the browser's explicit Select All editing command before Delete, including on macOS, and still requires a proven empty field before inserting the replacement. - Read ProseMirror paragraph boundaries and hard breaks as document text, excluding its trailing caret placeholder. Field verification, field digests, Chrome CDP verification, and publication snapshots preserve the same spaces and line breaks. - Require complete composer/account/media/context observations and a pre-dispatch permalink baseline. Unassigned or shared thread media makes the observation incomplete. - Failed preflight reports `publicationValidation.issues`, distinguishing missing account/composer/baseline evidence from body, media, or context mismatches. Body mismatches include lengths and the first differing character position/code points without copying the observed draft into diagnostics. @@ -34,6 +38,8 @@ Malformed output or failed model calls block publication. Schema validation esta ## Validation +LinkedIn public-post controls with their own composer and audience selector are distinguished from private-message sends. The public Post button still uses the normal submission checks; adjacent messaging panels still require recipient verification. Local navigation fixtures cover both browser engines and reject ambiguous or conflicting composer evidence. + `npm test` includes the deterministic contract/runtime tests, using mocked responses to verify selected-provider routing, validation, caching, conditional progress, and dispatch/completion enforcement. `npm run test:social-contract:dom` runs both browser implementations against local Playwright fixtures, including their actual injected completion probes. It requires installed Playwright Chromium; every fixture request is fulfilled locally. The historical language cases from PR #340 are retained in `test/llm/fixtures/social-publication-intent.json`. They are evaluation inputs, not a claim of live-model accuracy. Evaluate the compiler and independent audit against the selected provider before drawing conclusions about multilingual understanding, false authorization rates, or latency. Some historical inputs omit the payload or parent URL and should legitimately require clarification. @@ -52,3 +58,5 @@ node test/llm/run-social-publication.mjs --config /private/provider.json --suite The config accepts the existing provider fields (`providerName`, `baseUrl`, `model`, `apiKey`, and optional provider settings). Alternatively supply `--base`, `--model`, `--provider`, and an API-key environment variable named by `--api-key-env`. Use the same provider/model/settings as the agent; the runner does not silently select a replacement. Anthropic native and OpenAI-compatible transports are supported. Calls run sequentially. Reports distinguish first-response validity, repairs, semantic contract errors, audit false accepts/rejects, token usage, and median/p95 latency. First-call latency is reported separately without assuming a cold server. Default results are ignored local artifacts under `test/llm/results-social-publication/`, with a Markdown report, per-case JSONL, and prompt/fixture hashes. The runner never invokes a browser action or publishes a post. + +X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient; a send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. diff --git a/src/chrome/src/agent/adapters.js b/src/chrome/src/agent/adapters.js index 2ad363c45..a59b82848 100644 --- a/src/chrome/src/agent/adapters.js +++ b/src/chrome/src/agent/adapters.js @@ -16215,12 +16215,22 @@ const ADAPTERS = [ { name: 'twitter', category: 'general', - revision: 1, + revision: 2, regions: ['global'], - jobs: ['publish-post'], + jobs: ['publish-post', 'send-message'], workflow: { schema: ADAPTER_WORKFLOW_SCHEMA, jobs: { + 'send-message': { + description: 'Send and verify a direct message in the active X conversation.', + template: 'message', + stateChange: true, + requiresSubmission: true, + requiresLedger: false, + stages: ['access_gate', 'scope', 'fill', 'review', 'commit', 'verify'], + successEvidence: ['A new outgoing message matches the reviewed body in the intended conversation and has the provider sent status.'], + partialEvidence: ['The recipient, composer state, and exact send or verification blocker are reported.'], + }, 'publish-post': { description: 'Prepare, publish, and verify an X post.', template: 'publish', @@ -16234,9 +16244,13 @@ const ADAPTERS = [ }, }, matches: (url) => /^https?:\/\/(www\.)?(twitter\.com|x\.com)\//.test(url), + messaging: { + verifyActiveRecipient: url => /^\/i\/chat\/[^/]+\/?$/.test(new URL(url).pathname), + }, fullPageCapture: { infiniteScroll: isTwitterInfiniteScrollUrl }, notes: ` -- The composer is a contenteditable, not a textarea. Character count is enforced client-side at 280 (or higher for Premium). +- On /i/chat/, the DM composer stays visible after sending. Verify the exact new outgoing message and its sent status in the same conversation; do not resend because the composer remains open. +- The public post composer is a contenteditable, not a textarea. Character count is enforced client-side at 280 (or higher for Premium). - On /compose/post, call wait_for_stable before filling the composer. After typing, re-read the visible accessibility tree and require the Post control to be enabled (no disabled=true) before clicking it. - If the exact text is visible but Post remains disabled, keep the composer open and refill the editor with type_text({selector:"[data-testid=\\\"tweetTextarea_0\\\"]", text:"", clear:true}); this uses the trusted Chrome typing path. Do not dismiss the composer to recover. - A click_ax result with verified:false or no observable posting evidence is not proof that the post was published. Keep the composer open and verify a new status URL or matching feed item before reporting success. diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index c327973b8..18b8574c9 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1856,7 +1856,9 @@ export class Agent extends LoopDetector { // whose source contains strong submit evidence. const isSubmit = name === 'execute_js' ? this._formValidationActionHasStrongSubmitEvidence(name, args, result, detectedSubmit) - : !!detectedSubmit?.isSubmit + : (executionContext?.messageRecipientGuardRequired === true + && !!executionContext?.messageRecipientDispatchBinding?.token) + || !!detectedSubmit?.isSubmit || this._formValidationActionLooksSubmit(name, args, result, detectedSubmit); if (!isSubmit) return null; const before = this._normalizeUrl(beforeUrl || ''); @@ -1995,7 +1997,7 @@ export class Agent extends LoopDetector { const guard = this._planExecutionGuards.get(tabId); const siteWorkflow = guard?.siteWorkflow; if (!guard?.enabled || siteWorkflow?.job?.requiresSubmission !== true || !pageUrl) return null; - if (SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) && !guard.socialPublication?.dispatch) return null; + if (siteWorkflow.job.template === 'publish' && SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) && !guard.socialPublication?.dispatch) return null; const live = resolveAdapterWorkflowJob(pageUrl, siteWorkflow.job.id); if (!this._sameAdapterWorkflowBinding(siteWorkflow, live)) return null; const recipientTarget = normalizeMessageTarget(guard.messaging); @@ -2153,6 +2155,10 @@ export class Agent extends LoopDetector { recipientTargets: recipientTarget.recipients.map(recipient => ({ ...recipient })), messageBody, messageBodyBaselineCount, + ...(siteWorkflow.adapterName === 'twitter' + && Array.isArray(executionContext.messageRecipientExistingMessageIds) + ? { preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds] } + : {}), ...(siteWorkflow.adapterName === 'gmail' && executionContext?.messageRecipientGmailComposeFlow === true ? { gmailComposeFlow: true } @@ -2182,7 +2188,7 @@ export class Agent extends LoopDetector { : []).map(item => String(item?.name || '').trim()).filter(Boolean).slice(-12), } : {}), - ...(guard.socialPublication?.dispatch && SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) ? { + ...(siteWorkflow.job.template === 'publish' && guard.socialPublication?.dispatch && SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) ? { socialPublication: { contractKey: guard.socialPublication.key, ...structuredClone(guard.socialPublication.dispatch) }, } : {}), ...(githubFileCommit ? { githubFileCommit } : {}), @@ -5494,17 +5500,34 @@ export class Agent extends LoopDetector { const sentStatusText = this._workflowTerminalText( Array.isArray(pageState?.liveRegionMessages) ? pageState.liveRegionMessages.join('\n') : '', ); - const sentStatusObserved = this._workflowMessageSentSignal(siteWorkflow, sentStatusText); + let sentStatusObserved = this._workflowMessageSentSignal(siteWorkflow, sentStatusText); const dispatchRecipientObserved = binding.recipientBound === true && messageTargetMatchesObservedIdentities(state.messaging, binding.recipientTargets); const requiresRecipientBinding = normalizeMessageTarget(state.messaging)?.target_kind === 'named'; const messageBodyBaselineCount = Number(binding.messageBodyBaselineCount); const matchingOutgoingMessageCount = Number(messageProbe?.matchingOutgoingMessageCount); - const exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) + let exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) && Number.isInteger(messageBodyBaselineCount) && messageBodyBaselineCount >= 0 && Number.isInteger(matchingOutgoingMessageCount) && matchingOutgoingMessageCount > messageBodyBaselineCount; + if (siteWorkflow.adapterName === 'twitter') { + // Visibility and delivery-status changes of older rows are not new + // sends. Include hidden/pending rows in the dispatch baseline and + // require the new sent row to follow the conversation's prior tail. + const priorIds = binding.preDispatchMessageIds; + const currentIds = messageProbe?.existingMessageIds; + const sentIds = messageProbe?.matchingOutgoingMessageIds; + const anchorIndex = Array.isArray(priorIds) && priorIds.length > 0 && Array.isArray(currentIds) + ? currentIds.indexOf(priorIds[priorIds.length - 1]) : -1; + exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) + && Array.isArray(priorIds) && Array.isArray(currentIds) && Array.isArray(sentIds) + && (priorIds.length === 0 || anchorIndex >= 0) + && sentIds.some(id => !priorIds.includes(id) && currentIds.indexOf(id) > anchorIndex); + sentStatusObserved = exactOutgoingBodyObserved + && recipientObserved + && this._normalizeUrl(pageUrl) === this._normalizeUrl(submit?.originatingUrl || ''); + } const gmailComposeBodyBound = siteWorkflow.adapterName === 'gmail' && binding.gmailComposeFlow === true && !!this._workflowMessageBody(binding.messageBody); @@ -5517,7 +5540,7 @@ export class Agent extends LoopDetector { && this._workflowMessageFieldsVerified(binding) && (requiresRecipientBinding ? (dispatchRecipientObserved && postDispatchRecipientObserved && sentStatusObserved && messageBodyObserved) - : (recipientObserved || sentStatusObserved)); + : (siteWorkflow.adapterName !== 'twitter' && (recipientObserved || sentStatusObserved))); source = requiresRecipientBinding ? (recipientObserved ? 'recipient_body_bound_dispatch_empty_composer_and_sent_confirmation' @@ -5644,8 +5667,14 @@ export class Agent extends LoopDetector { }; } - _completionPageWarning(tabId, summary, outcome, pageState, pageUrl = '') { + _completionPageWarning(tabId, summary, outcome, pageState, pageUrl = '', workflowEvidence = null) { if (normalizeDoneOutcome(outcome) !== 'success' || !pageState) return null; + // X keeps its composer open after delivery. Its new-message proof can + // override that heuristic; other adapters retain their own dialog checks. + const guard = this._planExecutionGuards.get(tabId); + if (guard?.siteWorkflow?.adapterName === 'twitter' + && workflowEvidence?.verificationKind === 'message_sent' + && this._workflowTerminalEvidenceMatchesState(guard, workflowEvidence)) return null; const dialogs = Number(pageState.openDialogCount || 0); const submissionEvidence = this._completionSubmissionEvidence(tabId, pageState, pageUrl); const { submit, liveSignals, relevantForms, observedSuccessSignal, verifiedFinalSubmit } = submissionEvidence; @@ -12653,6 +12682,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d completionBatchStartState, traceStep: step, jevBinding: jevPending?.call.binding, + detectedSubmitAction, promptTier, dispatchBinding: pipelineToolbarPreflight.probe?.dispatchBinding || null, ...messageRecipientExecutionContext, @@ -16887,8 +16917,13 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } _resolvePlannerSiteWorkflow(url, plan) { - if (!this.useSiteAdapters || plan?.request_kind !== 'execute' || !plan?.site_job) return null; - return resolveAdapterWorkflowJob(url, plan.site_job); + if (!this.useSiteAdapters || plan?.request_kind !== 'execute') return null; + // Older plans have no X message job, but their structured send intent is + // sufficient to select it on an actual conversation route. + const messageJob = !plan.site_job && plan.requires_submission === true + && normalizeMessageTarget(plan.messaging) + && getMessageRecipientGuardPolicy(url)?.adapterName === 'twitter'; + return resolveAdapterWorkflowJob(url, plan.site_job || (messageJob ? 'send-message' : '')); } _sameAdapterWorkflowBinding(left, right) { @@ -18710,12 +18745,38 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d .map(m => this._plannerUserAuthoredText(m)).filter(Boolean); priorRequests = requests.slice(0, -1).slice(-4).map((text, i) => [`prior_request${i}`, text]); } - const drafts = messages.filter(m => m.role === 'assistant' - && typeof m.content === 'string' && !m.tool_calls?.length).slice(-4); + // A displayed answer may live only in done(summary), not in assistant + // content. Recover its exact authored bytes from a completed call/result + // pair; arbitrary tool output and page-derived verification are not drafts. + const drafts = []; + const pendingDone = new Map(); + for (const message of messages) { + if (message.role === 'assistant') { + if (typeof message.content === 'string' && message.content.trim() && !message.tool_calls?.length) { + drafts.push(message.content); + } + for (const call of message.tool_calls || []) { + if (!call.id || (call.function?.name || call.name) !== 'done') continue; + try { + const args = JSON.parse(call.function?.arguments || call.arguments || '{}'); + if (typeof args.summary === 'string' && args.summary.trim()) pendingDone.set(call.id, args.summary); + } catch { /* malformed calls cannot supply draft text */ } + } + } else if (message.role === 'tool' && pendingDone.has(message.tool_call_id)) { + const summary = pendingDone.get(message.tool_call_id); + pendingDone.delete(message.tool_call_id); + try { + const result = JSON.parse(this._unwrapUntrusted(message.content)); + if (result?.done === true && result.success !== false && !result.blockedDone) drafts.push(summary); + } catch { /* incomplete results do not prove a completed answer */ } + } else if (message.role === 'user' && !this._isAgentInjectedUserMessage(message)) { + pendingDone.clear(); + } + } let draftIndex = Object.keys(sources).filter(key => /^draft\d+$/.test(key)).length; - drafts.forEach(m => { - if (!Object.entries(sources).some(([key, value]) => /^draft\d+$/.test(key) && value === m.content)) { - sources[`draft${draftIndex++}`] = m.content; + drafts.slice(-4).forEach(draft => { + if (!Object.entries(sources).some(([key, value]) => /^draft\d+$/.test(key) && value === draft)) { + sources[`draft${draftIndex++}`] = draft; } }); (guard?.socialPublicationClarifications || []).forEach((entry, i) => { @@ -18982,6 +19043,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const action = this._socialPublicationAction(guard); if (social?.needsRecompile || !action || action.platform !== live.adapterName || !publicationProgress(social?.contract, social?.outcomes).eligible.includes(action.id)) { + if (social?.contract?.status === 'clarify') { + return blocked('Publication details are unresolved: ' + social.contract.reason + + ' Resolve the missing detail, not just permission. If the post text is missing, include the complete proposed text in one clarification; a yes/no approval without that text cannot supply it.'); + } return blocked(social?.error || 'This publication is not authorized, its prerequisite has not completed, or a previous dispatch still needs verification. Clarify unresolved intent; never repeat an uncertain publication.'); } const snapshot = this._socialPublicationSnapshot(guard, detected.publicationSnapshot); @@ -23155,6 +23220,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d executionContext.messageRecipientDispatchBinding = binding; executionContext.messageRecipientBody = this._workflowMessageBody(probe.messageBody); executionContext.messageRecipientBodyBaselineCount = messageBodyBaselineCount; + if (policy.adapterName === 'twitter' && Array.isArray(probe.existingMessageIds)) { + executionContext.messageRecipientExistingMessageIds = [...probe.existingMessageIds]; + } if (probe.composerSubjectAvailable === true) { executionContext.messageRecipientSubject = this._workflowMetadataValue(probe.composerSubject); executionContext.messageRecipientSubjectAvailable = true; @@ -28355,7 +28423,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }, opts.costState || this.currentCostState.get(tabId) || null, { tabId, generationName: 'intent' }); const obj = Agent._extractFirstJsonObject(response?.content || ''); if (this._workflowJobStoresMetadataRequirements(siteWorkflow) - && !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName)) { + && (siteWorkflow?.job?.template !== 'publish' || !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName))) { const guard = this._planExecutionGuards.get(tabId); if (guard) { const details = this._normalizeWorkflowMetadataRequirementsDetails( @@ -28401,7 +28469,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return normalizeProgressIntent(obj, { taskText, pageScope, source: 'classifier' }); } catch { if (this._workflowJobStoresMetadataRequirements(siteWorkflow) - && !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName)) { + && (siteWorkflow?.job?.template !== 'publish' || !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName))) { const guard = this._planExecutionGuards.get(tabId); if (guard && guard.workflowMetadataRequirementsResolved !== true) { const extractedBody = this._extractWorkflowTaskBody(taskText, approvedPlanText, siteWorkflow?.adapterName); @@ -28603,7 +28671,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d async _ensureWorkflowMetadataRequirements(tabId, opts, taskText, pageScope) { const guard = this._planExecutionGuards.get(tabId); if (!guard?.enabled) return; - if (SOCIAL_PLATFORMS.includes(guard.siteWorkflow?.adapterName)) { + if (guard.siteWorkflow?.job?.template === 'publish' && SOCIAL_PLATFORMS.includes(guard.siteWorkflow?.adapterName)) { await this._ensureSocialPublicationContract(tabId, opts.provider); await this._adoptLiveSocialPublishWorkflow(tabId, opts.provider); return; @@ -28642,6 +28710,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : 'auto'; const plannerAction = normalizeProgressAction(opts.progressAction) || (expectedItems ? 'process_item' : ''); if (progressLedgerPolicy === 'disabled') { + // Disabling repeated-item tracking must not disable the independent + // field requirements used to verify a send or other workflow commit. + await this._ensureWorkflowMetadataRequirements(tabId, opts, taskText, pageScope); const session = this._inactiveProgressSession( tabId, taskText, @@ -28682,6 +28753,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return existing; } if (this._currentTaskIsProgressContinuation(tabId)) { + await this._ensureWorkflowMetadataRequirements(tabId, opts, taskText, pageScope); const session = this._deriveProgressSessionForCurrentTask(tabId); this._syncProgressSessionPrompt(tabId); return session; @@ -33242,7 +33314,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }); } catch {} }; - const dispatchEarlyCdpKeyPress = async ({ key, code, windowsVirtualKeyCode, modifiers = 0 }) => { + const dispatchEarlyCdpKeyPress = async ({ key, code, windowsVirtualKeyCode, modifiers = 0, commands = [] }) => { const keyParams = { key, code, windowsVirtualKeyCode, ...(modifiers ? { modifiers } : {}) }; const releaseKey = async () => { try { @@ -33258,6 +33330,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d await cdpClient.sendCommand(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...keyParams, + ...(commands.length ? { commands } : {}), }); } catch (error) { if (earlyCdpAbortSignal?.aborted) { @@ -35208,15 +35281,20 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // Synthesize a warning when summary claims completion but page // state contradicts it. - const completionPageBlock = this._completionPageWarning( - tabId, args.summary, outcome, pageState, probe?.url || '', - ); - const completionWarning = completionPageBlock?.warning || null; - const workflowTerminalEvidence = !completionWarning + const candidateWorkflowEvidence = workflowMessageKind === 'message_sent' ? this._workflowTerminalEvidenceFromDone( tabId, pageState, probe?.url || '', submissionEvidence, workflowMessageProbe, ) : null; + const completionPageBlock = this._completionPageWarning( + tabId, args.summary, outcome, pageState, probe?.url || '', candidateWorkflowEvidence, + ); + const completionWarning = completionPageBlock?.warning || null; + const workflowTerminalEvidence = completionWarning ? null + : workflowMessageKind === 'message_sent' ? candidateWorkflowEvidence + : this._workflowTerminalEvidenceFromDone( + tabId, pageState, probe?.url || '', submissionEvidence, workflowMessageProbe, + ); if (pageState && typeof pageState === 'object') { delete pageState.workflowPageText; delete pageState.workflowResourceUrls; @@ -37206,6 +37284,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const tab = await chrome.tabs.get(tabId); return tab?.url || ''; }, + Date.now, + dispatchContext.detectedSubmitAction || null, ); if (duplicateSubmit) { await this._releaseRichTextToolbarProbeTarget(tabId, dispatchBinding); @@ -37224,6 +37304,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const tab = await chrome.tabs.get(tabId); return tab?.url || ''; }, + Date.now, + dispatchContext.detectedSubmitAction || null, ); throwIfEarlyCdpAborted(); if (duplicateSubmit) return duplicateSubmit; @@ -37333,14 +37415,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // value, or aria-label — not just visible button/link text. const sels = 'a, button, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="treeitem"], input:not([type="hidden"]), textarea, select, input[type="button"], input[type="submit"], summary, label, [onclick], [data-action]'; const all = Array.from(document.querySelectorAll(sels)).filter(el => { - // Listbox/menu option roles are often kept mounted but hidden - // while a custom select is collapsed or virtualized - // (Radix/MUI/React-Select). Drop hidden ones from the primary - // pool so click({text}) can't match — and falsely "succeed" on — - // an invisible option; the open-listbox fallback below still - // surfaces them when the control is actually open. - const role = (el.getAttribute && el.getAttribute('role')) || ''; - if (role !== 'option' && role !== 'menuitemradio' && role !== 'menuitemcheckbox' && role !== 'treeitem') return true; + // Hidden controls remain mounted in chats and closed menus. + // Match the visible candidate set used by content preflight. try { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; @@ -37589,8 +37665,6 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const all = Array.from(document.querySelectorAll(sels)).filter(el => { // See primary path: drop hidden listbox/menu options so we // don't falsely "succeed" clicking a collapsed/virtualized one. - const role = (el.getAttribute && el.getAttribute('role')) || ''; - if (role !== 'option' && role !== 'menuitemradio' && role !== 'menuitemcheckbox' && role !== 'treeitem') return true; try { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; @@ -38544,7 +38618,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d throwIfEarlyCdpAborted(); dispatched = true; await dispatchEarlyCdpKeyPress({ - key: 'a', code: 'KeyA', modifiers: selectAllModifiers, windowsVirtualKeyCode: 65, + key: 'a', code: 'KeyA', modifiers: selectAllModifiers, windowsVirtualKeyCode: 65, commands: ['selectAll'], }); await dispatchEarlyCdpKeyPress({ key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46, @@ -38843,7 +38917,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d throwIfEarlyCdpAborted(); dispatched = true; await dispatchEarlyCdpKeyPress({ - key: 'a', code: 'KeyA', modifiers: selectAllModifiers, windowsVirtualKeyCode: 65, + key: 'a', code: 'KeyA', modifiers: selectAllModifiers, windowsVirtualKeyCode: 65, commands: ['selectAll'], }); await dispatchEarlyCdpKeyPress({ key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46, diff --git a/src/chrome/src/agent/submit-click-guard.js b/src/chrome/src/agent/submit-click-guard.js index 10701fa32..593da171c 100644 --- a/src/chrome/src/agent/submit-click-guard.js +++ b/src/chrome/src/agent/submit-click-guard.js @@ -18,8 +18,12 @@ export async function guardRecentSubmitClick( args, getCurrentUrl, now = Date.now, + detectedTarget = null, ) { if (!args?.text) return null; + // Only app-observed preflight evidence can exempt a control. A label such + // as "Add post" adds a thread editor; it does not publish the thread. + if (detectedTarget?.isSubmit === false && detectedTarget.resolvedNonSubmitTarget === true) return null; const rawText = String(args.text).trim(); if (!SUBMIT_LIKE_CLICK_RE.test(rawText)) return null; diff --git a/src/chrome/src/cdp/cdp-client.js b/src/chrome/src/cdp/cdp-client.js index 97f9ba0be..8d8eeb221 100644 --- a/src/chrome/src/cdp/cdp-client.js +++ b/src/chrome/src/cdp/cdp-client.js @@ -4715,7 +4715,7 @@ export class CDPClient { }); } catch {} }; - const dispatchKeyPress = async ({ key, code, windowsVirtualKeyCode, modifiers = 0 }) => { + const dispatchKeyPress = async ({ key, code, windowsVirtualKeyCode, modifiers = 0, commands = [] }) => { const keyParams = { key, code, windowsVirtualKeyCode, ...(modifiers ? { modifiers } : {}) }; const releaseKey = async () => { try { @@ -4730,6 +4730,7 @@ export class CDPClient { await this.sendCommand(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...keyParams, + ...(commands.length ? { commands } : {}), }); } catch (error) { if (actionExpired()) { @@ -5165,7 +5166,7 @@ export class CDPClient { throwIfAborted(); dispatched = true; await dispatchKeyPress({ - key: 'a', code: 'KeyA', modifiers: selectAllModifiers, windowsVirtualKeyCode: 65, + key: 'a', code: 'KeyA', modifiers: selectAllModifiers, windowsVirtualKeyCode: 65, commands: ['selectAll'], }); // Delete selection await dispatchKeyPress({ diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 2b33eb23e..5b46760ff 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -1384,6 +1384,7 @@ }; visit(scope); return candidates + .filter(e => _hasVisibleBox(e)) .map(e => ({ e, txt: _siteInteractionText(e).toLowerCase() })) .filter(candidate => candidate.txt); } @@ -4465,6 +4466,7 @@ identityKey, messageBody: String(dispatch.messageBody || ''), messageBodyBaselineCount: Number(dispatch.messageBodyBaselineCount || 0), + ...(Array.isArray(dispatch.existingMessageIds) ? { existingMessageIds: [...dispatch.existingMessageIds] } : {}), gmailComposeFlow: dispatch.gmailComposeFlow === true, composerSubject: String(dispatch.composerSubject || ''), composerSubjectAvailable: dispatch.composerSubjectAvailable === true, @@ -4533,6 +4535,8 @@ || liveIdentityKey !== expected.identityKey || !expected.messageBody || live?.messageBody !== expected.messageBody + || (expected.existingMessageIds + && JSON.stringify(live?.existingMessageIds) !== JSON.stringify(expected.existingMessageIds)) || (expected.gmailComposeFlow === true && live?.gmailComposeFlow !== true) || (expected.composerSubjectAvailable === true && (live?.composerSubjectAvailable !== true || live?.composerSubject !== expected.composerSubject))) { @@ -5032,9 +5036,34 @@ } return null; }; + const twitterConversation = params.adapterName === 'twitter' + && /^\/i\/chat\/[^/]+\/?$/.test(location.pathname); + const twitterLogs = twitterConversation + ? Array.from(document.querySelectorAll('[role="log"][data-testid="dm-message-scroller"]')).filter(visible) : []; + // Keep all mounted identities, including hidden and pending rows. A + // virtualized row becoming visible or an old retry becoming sent must + // never look like the message dispatched by this run. + const twitterRows = twitterLogs.length === 1 + ? Array.from(twitterLogs[0].querySelectorAll('[data-testid^="message-"]')) + .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) + : []; + const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); + const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 + && new Set(twitterMessageIds).size === twitterMessageIds.length; + const matchingTwitterMessageIds = expectedBody => { + const expected = normalizedMessageBody(expectedBody); + if (!expected || !twitterBaselineComplete) return []; + return twitterRows.filter(row => visible(row) && row.classList.contains('justify-end') + && row.getAttribute('data-send-status') === 'sent') + .filter(row => { + const body = row.querySelector('[data-testid^="message-text-"] span[dir="auto"]'); + return visible(body) && normalizedMessageBody(body.innerText || body.textContent) === expected; + }).map(row => row.getAttribute('data-testid')); + }; const matchingMessageBodyCount = (expectedBody, activeComposer = null) => { const expected = normalizedMessageBody(expectedBody); if (!expected) return 0; + if (twitterConversation) return matchingTwitterMessageIds(expected).length; let candidates = []; try { candidates = Array.from(document.querySelectorAll( @@ -5129,7 +5158,11 @@ || ((br.width * br.height) - (ar.width * ar.height)); }); const viewportHeight = Math.max(0, Number(window.innerHeight) || 0); - const layoutCandidate = composerCandidates[0] || null; + const twitterComposers = twitterConversation + ? composerCandidates.filter(el => el.matches('textarea[data-testid="dm-composer-textarea"]')) : []; + const layoutCandidate = twitterConversation + ? (twitterComposers.length === 1 ? twitterComposers[0] : null) + : composerCandidates[0] || null; const layoutCandidateRect = layoutCandidate?.getBoundingClientRect?.(); // A recipient/search field can be the only focused editable while the // real composer is temporarily hidden. Never promote an upper-page @@ -5331,6 +5364,57 @@ return !!hit && _isComposedAncestor(button, hit); }; + const verifiedTwitterNavigation = clicked => { + if (!twitterConversation) return false; + const control = _composedClosestElement(clicked, 'a[href],button,[role="button"]'); + if (!control || !visible(control) || control.disabled + || control.getAttribute('aria-disabled') === 'true' + || _composedClosestElement(control, 'form,dialog,[role="dialog"],[role="log"],[contenteditable="true"]') + || control.hasAttribute('form') || control.hasAttribute('download')) return false; + if (control.matches('button[data-testid="dm-conversation-back-button"]')) { + return String(control.getAttribute('type') || 'button').toLowerCase() === 'button'; + } + if (!control.matches('a[href]') || control.hasAttribute('onclick') || control.hasAttribute('data-action')) return false; + try { + const destination = new URL(control.getAttribute('href'), location.href); + if (!/^https?:$/.test(destination.protocol) || destination.username || destination.password + || !/^(?:www\.)?(?:x|twitter)\.com$/.test(destination.hostname)) return false; + const isProfile = /^\/[a-zA-Z0-9_]{1,15}\/?$/.test(destination.pathname); + const header = control.querySelector('[data-testid="dm-conversation-username"]'); + if (header && visible(header)) return isProfile; + return !!_composedClosestElement(control, 'nav,[role="navigation"]') + && (isProfile || /^\/(?:home|explore|notifications|messages|i\/chat)\/?$/.test(destination.pathname)); + } catch { return false; } + }; + + const verifiedLinkedInPublicPostControl = (clicked) => { + if (params.adapterName !== 'linkedin') return false; + const button = _composedClosestElement(clicked, 'button,[role="button"]'); + if (!button || !visible(button) || button.disabled + || button.getAttribute?.('aria-disabled') === 'true' + || String(button.getAttribute?.('type') || 'button').toLowerCase() !== 'button' + || button.form || button.hasAttribute?.('form')) return false; + const messageScope = 'form,[role="log"],[data-message-id],[data-thread-id],[data-conversation-id],' + + '.msg-form,.msg-overlay-conversation-bubble,.msg-convo-wrapper'; + if (_composedClosestElement(button, messageScope)) return false; + const labels = [button.innerText || button.textContent, button.getAttribute?.('aria-label')] + .map(value => compact(value).toLowerCase()).filter(Boolean); + if (!labels.length || !labels.every(label => label === 'post')) return false; + // Identify the public composer itself, not merely a nearby textbox. + // LinkedIn's dedicated compose route may render as a whole page. + const root = _composedClosestElement(button, 'dialog,[role="dialog"],.share-box') + || (/^\/sharing\/compose\/?$/.test(location.pathname) + ? (_composedClosestElement(button, 'main,[role="main"]') || document.body) : null); + if (!root) return false; + const owned = el => visible(el) && !_composedClosestElement(el, messageScope) + && (_composedClosestElement(el, 'dialog,[role="dialog"],.share-box') || root) === root; + const editors = Array.from(root.querySelectorAll('[contenteditable="true"],textarea')) + .filter(owned); + const audience = Array.from(root.querySelectorAll('button,[role="button"]')).some(el => owned(el) + && /^post to (?:anyone|connections(?: only)?)$/i.test(compact(el.getAttribute('aria-label') || el.innerText || el.textContent))); + return editors.length === 1 && audience; + }; + let composer = null; let messageSend = null; if (observationOnly) { @@ -5371,9 +5455,17 @@ if (!visible(control) || (modal && !_isComposedAncestor(modal, target))) { return { success: true, messageSend: null, conclusive: false, identityCandidates: [] }; } + if (verifiedTwitterNavigation(target)) { + return { success: true, messageSend: false, conclusive: true, navigation: true, identityCandidates: [] }; + } if (verifiedLinkedInPostEntry(target)) { return { success: true, messageSend: false, conclusive: true, composerSetup: true, identityCandidates: [] }; } + if (verifiedLinkedInPublicPostControl(target)) { + // Public publication still goes through the normal submission gates; + // a private-message recipient is irrelevant to this composer. + return { success: true, messageSend: false, conclusive: true, publicPost: true, identityCandidates: [] }; + } const linkedInNavigation = classifyLinkedInNavigation(target, modal); if (linkedInNavigation === 'navigation') { return { success: true, messageSend: false, conclusive: true, navigation: true, identityCandidates: [] }; @@ -5409,7 +5501,7 @@ identityCandidates: [], }; } - if (editable(target) && target !== composer) { + if (editable(target) && (target !== composer || twitterConversation)) { return { success: true, messageSend: false, conclusive: true, identityCandidates: [] }; } if (verifiedConversationSelection(target, composer)) { @@ -5651,6 +5743,26 @@ } } } + } else if (twitterConversation) { + const headers = Array.from(document.querySelectorAll('[data-testid="dm-conversation-username"]')) + .filter(el => visible(el) && inConversationHeaderBand(el) && !independentScrollableRegion(el, composer)); + if (headers.length === 1) { + const header = headers[0]; + const link = header.closest('a[href]'); + let handle = ''; + try { + const url = new URL(link?.getAttribute('href'), location.href); + if (/^(?:www\.)?(?:x|twitter)\.com$/.test(url.hostname)) { + handle = url.pathname.match(/^\/([a-zA-Z0-9_]{1,15})\/?$/)?.[1] || ''; + } + } catch {} + if (handle) { + const identity = '@' + handle.toLowerCase(); + strongIdentities.push(identity); + strongRecipients.push({ identity, role: 'to' }); + observedRecipientCandidates.push({ identity, role: 'to', aliases: [identity, handle, compact(header.innerText)] }); + } + } } else { for (const el of document.querySelectorAll( '[aria-selected="true"],[aria-current]:not([aria-current="false"])' @@ -5711,6 +5823,7 @@ const messageRecipientDispatchToken = params.bindDispatch === true && messageSend === true && !!messageBody + && (!twitterConversation || twitterBaselineComplete) && (params.supportsRecipientSets === true ? strongRecipients.length > 0 : strongRecipients.length === 1) @@ -5723,6 +5836,7 @@ supportsRecipientSets: params.supportsRecipientSets, messageBody, messageBodyBaselineCount, + ...(twitterConversation ? { existingMessageIds: twitterMessageIds } : {}), gmailComposeFlow, composerSubject, composerSubjectAvailable, @@ -5746,6 +5860,10 @@ composerSubjectAvailable, composerStatusMessages, matchingOutgoingMessageCount, + ...(twitterConversation && twitterBaselineComplete ? { + existingMessageIds: twitterMessageIds, + matchingOutgoingMessageIds: matchingTwitterMessageIds(params.expectedMessageBody), + } : {}), // Only recipient-specific header evidence is authoritative. Ordinary // message text, test-id containers, and other leaf content are never // returned as dispatch identities. diff --git a/src/firefox/src/agent/adapters.js b/src/firefox/src/agent/adapters.js index 458c88a36..ecf214489 100644 --- a/src/firefox/src/agent/adapters.js +++ b/src/firefox/src/agent/adapters.js @@ -16214,12 +16214,22 @@ const ADAPTERS = [ { name: 'twitter', category: 'general', - revision: 1, + revision: 2, regions: ['global'], - jobs: ['publish-post'], + jobs: ['publish-post', 'send-message'], workflow: { schema: ADAPTER_WORKFLOW_SCHEMA, jobs: { + 'send-message': { + description: 'Send and verify a direct message in the active X conversation.', + template: 'message', + stateChange: true, + requiresSubmission: true, + requiresLedger: false, + stages: ['access_gate', 'scope', 'fill', 'review', 'commit', 'verify'], + successEvidence: ['A new outgoing message matches the reviewed body in the intended conversation and has the provider sent status.'], + partialEvidence: ['The recipient, composer state, and exact send or verification blocker are reported.'], + }, 'publish-post': { description: 'Prepare, publish, and verify an X post.', template: 'publish', @@ -16233,9 +16243,13 @@ const ADAPTERS = [ }, }, matches: (url) => /^https?:\/\/(www\.)?(twitter\.com|x\.com)\//.test(url), + messaging: { + verifyActiveRecipient: url => /^\/i\/chat\/[^/]+\/?$/.test(new URL(url).pathname), + }, fullPageCapture: { infiniteScroll: isTwitterInfiniteScrollUrl }, notes: ` -- The composer is a contenteditable, not a textarea. Character count is enforced client-side at 280 (or higher for Premium). +- On /i/chat/, the DM composer stays visible after sending. Verify the exact new outgoing message and its sent status in the same conversation; do not resend because the composer remains open. +- The public post composer is a contenteditable, not a textarea. Character count is enforced client-side at 280 (or higher for Premium). - On /compose/post, call wait_for_stable before filling the composer. After typing, re-read the visible accessibility tree and require the Post control to be enabled (no disabled=true) before clicking it. - If the exact text is visible but Post remains disabled, keep the composer open and refill the editor with type_text({selector:"[data-testid=\\\"tweetTextarea_0\\\"]", text:"", clear:true}). Do not dismiss the composer to recover. - A click_ax result with verified:false or no observable posting evidence is not proof that the post was published. Keep the composer open and verify a new status URL or matching feed item before reporting success. diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 8ab349185..583ec01c1 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1574,7 +1574,9 @@ export class Agent extends LoopDetector { // whose source contains strong submit evidence. const isSubmit = name === 'execute_js' ? this._formValidationActionHasStrongSubmitEvidence(name, args, result, detectedSubmit) - : !!detectedSubmit?.isSubmit + : (executionContext?.messageRecipientGuardRequired === true + && !!executionContext?.messageRecipientDispatchBinding?.token) + || !!detectedSubmit?.isSubmit || this._formValidationActionLooksSubmit(name, args, result, detectedSubmit); if (!isSubmit) return null; const before = this._normalizeUrl(beforeUrl || ''); @@ -1713,7 +1715,7 @@ export class Agent extends LoopDetector { const guard = this._planExecutionGuards.get(tabId); const siteWorkflow = guard?.siteWorkflow; if (!guard?.enabled || siteWorkflow?.job?.requiresSubmission !== true || !pageUrl) return null; - if (SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) && !guard.socialPublication?.dispatch) return null; + if (siteWorkflow.job.template === 'publish' && SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) && !guard.socialPublication?.dispatch) return null; const live = resolveAdapterWorkflowJob(pageUrl, siteWorkflow.job.id); if (!this._sameAdapterWorkflowBinding(siteWorkflow, live)) return null; const recipientTarget = normalizeMessageTarget(guard.messaging); @@ -1871,6 +1873,10 @@ export class Agent extends LoopDetector { recipientTargets: recipientTarget.recipients.map(recipient => ({ ...recipient })), messageBody, messageBodyBaselineCount, + ...(siteWorkflow.adapterName === 'twitter' + && Array.isArray(executionContext.messageRecipientExistingMessageIds) + ? { preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds] } + : {}), ...(siteWorkflow.adapterName === 'gmail' && executionContext?.messageRecipientGmailComposeFlow === true ? { gmailComposeFlow: true } @@ -1900,7 +1906,7 @@ export class Agent extends LoopDetector { : []).map(item => String(item?.name || '').trim()).filter(Boolean).slice(-12), } : {}), - ...(guard.socialPublication?.dispatch && SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) ? { + ...(siteWorkflow.job.template === 'publish' && guard.socialPublication?.dispatch && SOCIAL_PLATFORMS.includes(siteWorkflow.adapterName) ? { socialPublication: { contractKey: guard.socialPublication.key, ...structuredClone(guard.socialPublication.dispatch) }, } : {}), ...(githubFileCommit ? { githubFileCommit } : {}), @@ -5212,17 +5218,34 @@ export class Agent extends LoopDetector { const sentStatusText = this._workflowTerminalText( Array.isArray(pageState?.liveRegionMessages) ? pageState.liveRegionMessages.join('\n') : '', ); - const sentStatusObserved = this._workflowMessageSentSignal(siteWorkflow, sentStatusText); + let sentStatusObserved = this._workflowMessageSentSignal(siteWorkflow, sentStatusText); const dispatchRecipientObserved = binding.recipientBound === true && messageTargetMatchesObservedIdentities(state.messaging, binding.recipientTargets); const requiresRecipientBinding = normalizeMessageTarget(state.messaging)?.target_kind === 'named'; const messageBodyBaselineCount = Number(binding.messageBodyBaselineCount); const matchingOutgoingMessageCount = Number(messageProbe?.matchingOutgoingMessageCount); - const exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) + let exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) && Number.isInteger(messageBodyBaselineCount) && messageBodyBaselineCount >= 0 && Number.isInteger(matchingOutgoingMessageCount) && matchingOutgoingMessageCount > messageBodyBaselineCount; + if (siteWorkflow.adapterName === 'twitter') { + // Visibility and delivery-status changes of older rows are not new + // sends. Include hidden/pending rows in the dispatch baseline and + // require the new sent row to follow the conversation's prior tail. + const priorIds = binding.preDispatchMessageIds; + const currentIds = messageProbe?.existingMessageIds; + const sentIds = messageProbe?.matchingOutgoingMessageIds; + const anchorIndex = Array.isArray(priorIds) && priorIds.length > 0 && Array.isArray(currentIds) + ? currentIds.indexOf(priorIds[priorIds.length - 1]) : -1; + exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) + && Array.isArray(priorIds) && Array.isArray(currentIds) && Array.isArray(sentIds) + && (priorIds.length === 0 || anchorIndex >= 0) + && sentIds.some(id => !priorIds.includes(id) && currentIds.indexOf(id) > anchorIndex); + sentStatusObserved = exactOutgoingBodyObserved + && recipientObserved + && this._normalizeUrl(pageUrl) === this._normalizeUrl(submit?.originatingUrl || ''); + } const gmailComposeBodyBound = siteWorkflow.adapterName === 'gmail' && binding.gmailComposeFlow === true && !!this._workflowMessageBody(binding.messageBody); @@ -5235,7 +5258,7 @@ export class Agent extends LoopDetector { && this._workflowMessageFieldsVerified(binding) && (requiresRecipientBinding ? (dispatchRecipientObserved && postDispatchRecipientObserved && sentStatusObserved && messageBodyObserved) - : (recipientObserved || sentStatusObserved)); + : (siteWorkflow.adapterName !== 'twitter' && (recipientObserved || sentStatusObserved))); source = requiresRecipientBinding ? (recipientObserved ? 'recipient_body_bound_dispatch_empty_composer_and_sent_confirmation' @@ -5362,8 +5385,14 @@ export class Agent extends LoopDetector { }; } - _completionPageWarning(tabId, summary, outcome, pageState, pageUrl = '') { + _completionPageWarning(tabId, summary, outcome, pageState, pageUrl = '', workflowEvidence = null) { if (normalizeDoneOutcome(outcome) !== 'success' || !pageState) return null; + // X keeps its composer open after delivery. Its new-message proof can + // override that heuristic; other adapters retain their own dialog checks. + const guard = this._planExecutionGuards.get(tabId); + if (guard?.siteWorkflow?.adapterName === 'twitter' + && workflowEvidence?.verificationKind === 'message_sent' + && this._workflowTerminalEvidenceMatchesState(guard, workflowEvidence)) return null; const dialogs = Number(pageState.openDialogCount || 0); const submissionEvidence = this._completionSubmissionEvidence(tabId, pageState, pageUrl); const { submit, liveSignals, relevantForms, observedSuccessSignal, verifiedFinalSubmit } = submissionEvidence; @@ -11192,6 +11221,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d completionBatchStartState, traceStep: step, jevBinding: jevPending?.call.binding, + detectedSubmitAction, promptTier, dispatchBinding: pipelineToolbarPreflight.probe?.dispatchBinding || null, ...messageRecipientExecutionContext, @@ -14693,8 +14723,13 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } _resolvePlannerSiteWorkflow(url, plan) { - if (!this.useSiteAdapters || plan?.request_kind !== 'execute' || !plan?.site_job) return null; - return resolveAdapterWorkflowJob(url, plan.site_job); + if (!this.useSiteAdapters || plan?.request_kind !== 'execute') return null; + // Older plans have no X message job, but their structured send intent is + // sufficient to select it on an actual conversation route. + const messageJob = !plan.site_job && plan.requires_submission === true + && normalizeMessageTarget(plan.messaging) + && getMessageRecipientGuardPolicy(url)?.adapterName === 'twitter'; + return resolveAdapterWorkflowJob(url, plan.site_job || (messageJob ? 'send-message' : '')); } _sameAdapterWorkflowBinding(left, right) { @@ -16516,12 +16551,38 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d .map(m => this._plannerUserAuthoredText(m)).filter(Boolean); priorRequests = requests.slice(0, -1).slice(-4).map((text, i) => [`prior_request${i}`, text]); } - const drafts = messages.filter(m => m.role === 'assistant' - && typeof m.content === 'string' && !m.tool_calls?.length).slice(-4); + // A displayed answer may live only in done(summary), not in assistant + // content. Recover its exact authored bytes from a completed call/result + // pair; arbitrary tool output and page-derived verification are not drafts. + const drafts = []; + const pendingDone = new Map(); + for (const message of messages) { + if (message.role === 'assistant') { + if (typeof message.content === 'string' && message.content.trim() && !message.tool_calls?.length) { + drafts.push(message.content); + } + for (const call of message.tool_calls || []) { + if (!call.id || (call.function?.name || call.name) !== 'done') continue; + try { + const args = JSON.parse(call.function?.arguments || call.arguments || '{}'); + if (typeof args.summary === 'string' && args.summary.trim()) pendingDone.set(call.id, args.summary); + } catch { /* malformed calls cannot supply draft text */ } + } + } else if (message.role === 'tool' && pendingDone.has(message.tool_call_id)) { + const summary = pendingDone.get(message.tool_call_id); + pendingDone.delete(message.tool_call_id); + try { + const result = JSON.parse(this._unwrapUntrusted(message.content)); + if (result?.done === true && result.success !== false && !result.blockedDone) drafts.push(summary); + } catch { /* incomplete results do not prove a completed answer */ } + } else if (message.role === 'user' && !this._isAgentInjectedUserMessage(message)) { + pendingDone.clear(); + } + } let draftIndex = Object.keys(sources).filter(key => /^draft\d+$/.test(key)).length; - drafts.forEach(m => { - if (!Object.entries(sources).some(([key, value]) => /^draft\d+$/.test(key) && value === m.content)) { - sources[`draft${draftIndex++}`] = m.content; + drafts.slice(-4).forEach(draft => { + if (!Object.entries(sources).some(([key, value]) => /^draft\d+$/.test(key) && value === draft)) { + sources[`draft${draftIndex++}`] = draft; } }); (guard?.socialPublicationClarifications || []).forEach((entry, i) => { @@ -16788,6 +16849,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const action = this._socialPublicationAction(guard); if (social?.needsRecompile || !action || action.platform !== live.adapterName || !publicationProgress(social?.contract, social?.outcomes).eligible.includes(action.id)) { + if (social?.contract?.status === 'clarify') { + return blocked('Publication details are unresolved: ' + social.contract.reason + + ' Resolve the missing detail, not just permission. If the post text is missing, include the complete proposed text in one clarification; a yes/no approval without that text cannot supply it.'); + } return blocked(social?.error || 'This publication is not authorized, its prerequisite has not completed, or a previous dispatch still needs verification. Clarify unresolved intent; never repeat an uncertain publication.'); } const snapshot = this._socialPublicationSnapshot(guard, detected.publicationSnapshot); @@ -20914,6 +20979,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d executionContext.messageRecipientDispatchBinding = binding; executionContext.messageRecipientBody = this._workflowMessageBody(probe.messageBody); executionContext.messageRecipientBodyBaselineCount = messageBodyBaselineCount; + if (policy.adapterName === 'twitter' && Array.isArray(probe.existingMessageIds)) { + executionContext.messageRecipientExistingMessageIds = [...probe.existingMessageIds]; + } if (probe.composerSubjectAvailable === true) { executionContext.messageRecipientSubject = this._workflowMetadataValue(probe.composerSubject); executionContext.messageRecipientSubjectAvailable = true; @@ -25969,7 +26037,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d }, opts.costState || this.currentCostState.get(tabId) || null, { tabId, generationName: 'intent' }); const obj = Agent._extractFirstJsonObject(response?.content || ''); if (this._workflowJobStoresMetadataRequirements(siteWorkflow) - && !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName)) { + && (siteWorkflow?.job?.template !== 'publish' || !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName))) { const guard = this._planExecutionGuards.get(tabId); if (guard) { const details = this._normalizeWorkflowMetadataRequirementsDetails( @@ -26015,7 +26083,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return normalizeProgressIntent(obj, { taskText, pageScope, source: 'classifier' }); } catch { if (this._workflowJobStoresMetadataRequirements(siteWorkflow) - && !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName)) { + && (siteWorkflow?.job?.template !== 'publish' || !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName))) { const guard = this._planExecutionGuards.get(tabId); if (guard && guard.workflowMetadataRequirementsResolved !== true) { const extractedBody = this._extractWorkflowTaskBody(taskText, approvedPlanText, siteWorkflow?.adapterName); @@ -26217,7 +26285,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d async _ensureWorkflowMetadataRequirements(tabId, opts, taskText, pageScope) { const guard = this._planExecutionGuards.get(tabId); if (!guard?.enabled) return; - if (SOCIAL_PLATFORMS.includes(guard.siteWorkflow?.adapterName)) { + if (guard.siteWorkflow?.job?.template === 'publish' && SOCIAL_PLATFORMS.includes(guard.siteWorkflow?.adapterName)) { await this._ensureSocialPublicationContract(tabId, opts.provider); await this._adoptLiveSocialPublishWorkflow(tabId, opts.provider); return; @@ -26256,6 +26324,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : 'auto'; const plannerAction = normalizeProgressAction(opts.progressAction) || (expectedItems ? 'process_item' : ''); if (progressLedgerPolicy === 'disabled') { + // Disabling repeated-item tracking must not disable the independent + // field requirements used to verify a send or other workflow commit. + await this._ensureWorkflowMetadataRequirements(tabId, opts, taskText, pageScope); const session = this._inactiveProgressSession( tabId, taskText, @@ -26296,6 +26367,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return existing; } if (this._currentTaskIsProgressContinuation(tabId)) { + await this._ensureWorkflowMetadataRequirements(tabId, opts, taskText, pageScope); const session = this._deriveProgressSessionForCurrentTask(tabId); this._syncProgressSessionPrompt(tabId); return session; @@ -31689,15 +31761,20 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // Synthesize a warning when summary claims completion but page // state contradicts it. - const completionPageBlock = this._completionPageWarning( - tabId, args.summary, outcome, pageState, pageState.url || '', - ); - const completionWarning = completionPageBlock?.warning || null; - const workflowTerminalEvidence = !completionWarning + const candidateWorkflowEvidence = workflowMessageKind === 'message_sent' ? this._workflowTerminalEvidenceFromDone( tabId, pageState, pageState.url || '', submissionEvidence, workflowMessageProbe, ) : null; + const completionPageBlock = this._completionPageWarning( + tabId, args.summary, outcome, pageState, pageState.url || '', candidateWorkflowEvidence, + ); + const completionWarning = completionPageBlock?.warning || null; + const workflowTerminalEvidence = completionWarning ? null + : workflowMessageKind === 'message_sent' ? candidateWorkflowEvidence + : this._workflowTerminalEvidenceFromDone( + tabId, pageState, pageState.url || '', submissionEvidence, workflowMessageProbe, + ); if (pageState && typeof pageState === 'object') { delete pageState.workflowPageText; delete pageState.workflowResourceUrls; @@ -33733,6 +33810,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const tab = await browser.tabs.get(tabId); return tab?.url || ''; }, + Date.now, + dispatchContext.detectedSubmitAction || null, ); if (duplicateSubmit) return duplicateSubmit; if (coordinatePoint && !dispatchBinding?.token) { diff --git a/src/firefox/src/agent/submit-click-guard.js b/src/firefox/src/agent/submit-click-guard.js index 10701fa32..593da171c 100644 --- a/src/firefox/src/agent/submit-click-guard.js +++ b/src/firefox/src/agent/submit-click-guard.js @@ -18,8 +18,12 @@ export async function guardRecentSubmitClick( args, getCurrentUrl, now = Date.now, + detectedTarget = null, ) { if (!args?.text) return null; + // Only app-observed preflight evidence can exempt a control. A label such + // as "Add post" adds a thread editor; it does not publish the thread. + if (detectedTarget?.isSubmit === false && detectedTarget.resolvedNonSubmitTarget === true) return null; const rawText = String(args.text).trim(); if (!SUBMIT_LIKE_CLICK_RE.test(rawText)) return null; diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index d4e7a2e14..52b15239d 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -1358,7 +1358,7 @@ } }; visit(scope); - const all = candidates.filter(_keepCandidate); + const all = candidates.filter(e => _hasVisibleBox(e) && _keepCandidate(e)); return all.map(e => ({ e, txt: _normTxt(e) })).filter(x => !!x.txt); } @@ -3778,6 +3778,7 @@ identityKey, messageBody: String(dispatch.messageBody || ''), messageBodyBaselineCount: Number(dispatch.messageBodyBaselineCount || 0), + ...(Array.isArray(dispatch.existingMessageIds) ? { existingMessageIds: [...dispatch.existingMessageIds] } : {}), gmailComposeFlow: dispatch.gmailComposeFlow === true, composerSubject: String(dispatch.composerSubject || ''), composerSubjectAvailable: dispatch.composerSubjectAvailable === true, @@ -3846,6 +3847,8 @@ || liveIdentityKey !== expected.identityKey || !expected.messageBody || live?.messageBody !== expected.messageBody + || (expected.existingMessageIds + && JSON.stringify(live?.existingMessageIds) !== JSON.stringify(expected.existingMessageIds)) || (expected.gmailComposeFlow === true && live?.gmailComposeFlow !== true) || (expected.composerSubjectAvailable === true && (live?.composerSubjectAvailable !== true || live?.composerSubject !== expected.composerSubject))) { @@ -4205,9 +4208,34 @@ } return null; }; + const twitterConversation = params.adapterName === 'twitter' + && /^\/i\/chat\/[^/]+\/?$/.test(location.pathname); + const twitterLogs = twitterConversation + ? Array.from(document.querySelectorAll('[role="log"][data-testid="dm-message-scroller"]')).filter(visible) : []; + // Keep all mounted identities, including hidden and pending rows. A + // virtualized row becoming visible or an old retry becoming sent must + // never look like the message dispatched by this run. + const twitterRows = twitterLogs.length === 1 + ? Array.from(twitterLogs[0].querySelectorAll('[data-testid^="message-"]')) + .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) + : []; + const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); + const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 + && new Set(twitterMessageIds).size === twitterMessageIds.length; + const matchingTwitterMessageIds = expectedBody => { + const expected = normalizedMessageBody(expectedBody); + if (!expected || !twitterBaselineComplete) return []; + return twitterRows.filter(row => visible(row) && row.classList.contains('justify-end') + && row.getAttribute('data-send-status') === 'sent') + .filter(row => { + const body = row.querySelector('[data-testid^="message-text-"] span[dir="auto"]'); + return visible(body) && normalizedMessageBody(body.innerText || body.textContent) === expected; + }).map(row => row.getAttribute('data-testid')); + }; const matchingMessageBodyCount = (expectedBody, activeComposer = null) => { const expected = normalizedMessageBody(expectedBody); if (!expected) return 0; + if (twitterConversation) return matchingTwitterMessageIds(expected).length; let candidates = []; try { candidates = Array.from(document.querySelectorAll( @@ -4302,7 +4330,11 @@ || ((br.width * br.height) - (ar.width * ar.height)); }); const viewportHeight = Math.max(0, Number(window.innerHeight) || 0); - const layoutCandidate = composerCandidates[0] || null; + const twitterComposers = twitterConversation + ? composerCandidates.filter(el => el.matches('textarea[data-testid="dm-composer-textarea"]')) : []; + const layoutCandidate = twitterConversation + ? (twitterComposers.length === 1 ? twitterComposers[0] : null) + : composerCandidates[0] || null; const layoutCandidateRect = layoutCandidate?.getBoundingClientRect?.(); // A recipient/search field can be the only focused editable while the // real composer is temporarily hidden. Never promote an upper-page @@ -4504,6 +4536,57 @@ return !!hit && _isComposedAncestor(button, hit); }; + const verifiedTwitterNavigation = clicked => { + if (!twitterConversation) return false; + const control = _composedClosestElement(clicked, 'a[href],button,[role="button"]'); + if (!control || !visible(control) || control.disabled + || control.getAttribute('aria-disabled') === 'true' + || _composedClosestElement(control, 'form,dialog,[role="dialog"],[role="log"],[contenteditable="true"]') + || control.hasAttribute('form') || control.hasAttribute('download')) return false; + if (control.matches('button[data-testid="dm-conversation-back-button"]')) { + return String(control.getAttribute('type') || 'button').toLowerCase() === 'button'; + } + if (!control.matches('a[href]') || control.hasAttribute('onclick') || control.hasAttribute('data-action')) return false; + try { + const destination = new URL(control.getAttribute('href'), location.href); + if (!/^https?:$/.test(destination.protocol) || destination.username || destination.password + || !/^(?:www\.)?(?:x|twitter)\.com$/.test(destination.hostname)) return false; + const isProfile = /^\/[a-zA-Z0-9_]{1,15}\/?$/.test(destination.pathname); + const header = control.querySelector('[data-testid="dm-conversation-username"]'); + if (header && visible(header)) return isProfile; + return !!_composedClosestElement(control, 'nav,[role="navigation"]') + && (isProfile || /^\/(?:home|explore|notifications|messages|i\/chat)\/?$/.test(destination.pathname)); + } catch { return false; } + }; + + const verifiedLinkedInPublicPostControl = (clicked) => { + if (params.adapterName !== 'linkedin') return false; + const button = _composedClosestElement(clicked, 'button,[role="button"]'); + if (!button || !visible(button) || button.disabled + || button.getAttribute?.('aria-disabled') === 'true' + || String(button.getAttribute?.('type') || 'button').toLowerCase() !== 'button' + || button.form || button.hasAttribute?.('form')) return false; + const messageScope = 'form,[role="log"],[data-message-id],[data-thread-id],[data-conversation-id],' + + '.msg-form,.msg-overlay-conversation-bubble,.msg-convo-wrapper'; + if (_composedClosestElement(button, messageScope)) return false; + const labels = [button.innerText || button.textContent, button.getAttribute?.('aria-label')] + .map(value => compact(value).toLowerCase()).filter(Boolean); + if (!labels.length || !labels.every(label => label === 'post')) return false; + // Identify the public composer itself, not merely a nearby textbox. + // LinkedIn's dedicated compose route may render as a whole page. + const root = _composedClosestElement(button, 'dialog,[role="dialog"],.share-box') + || (/^\/sharing\/compose\/?$/.test(location.pathname) + ? (_composedClosestElement(button, 'main,[role="main"]') || document.body) : null); + if (!root) return false; + const owned = el => visible(el) && !_composedClosestElement(el, messageScope) + && (_composedClosestElement(el, 'dialog,[role="dialog"],.share-box') || root) === root; + const editors = Array.from(root.querySelectorAll('[contenteditable="true"],textarea')) + .filter(owned); + const audience = Array.from(root.querySelectorAll('button,[role="button"]')).some(el => owned(el) + && /^post to (?:anyone|connections(?: only)?)$/i.test(compact(el.getAttribute('aria-label') || el.innerText || el.textContent))); + return editors.length === 1 && audience; + }; + let composer = null; let messageSend = null; if (observationOnly) { @@ -4544,9 +4627,17 @@ if (!visible(control) || (modal && !_isComposedAncestor(modal, target))) { return { success: true, messageSend: null, conclusive: false, identityCandidates: [] }; } + if (verifiedTwitterNavigation(target)) { + return { success: true, messageSend: false, conclusive: true, navigation: true, identityCandidates: [] }; + } if (verifiedLinkedInPostEntry(target)) { return { success: true, messageSend: false, conclusive: true, composerSetup: true, identityCandidates: [] }; } + if (verifiedLinkedInPublicPostControl(target)) { + // Public publication still goes through the normal submission gates; + // a private-message recipient is irrelevant to this composer. + return { success: true, messageSend: false, conclusive: true, publicPost: true, identityCandidates: [] }; + } const linkedInNavigation = classifyLinkedInNavigation(target, modal); if (linkedInNavigation === 'navigation') { return { success: true, messageSend: false, conclusive: true, navigation: true, identityCandidates: [] }; @@ -4582,7 +4673,7 @@ identityCandidates: [], }; } - if (editable(target) && target !== composer) { + if (editable(target) && (target !== composer || twitterConversation)) { return { success: true, messageSend: false, conclusive: true, identityCandidates: [] }; } if (verifiedConversationSelection(target, composer)) { @@ -4824,6 +4915,26 @@ } } } + } else if (twitterConversation) { + const headers = Array.from(document.querySelectorAll('[data-testid="dm-conversation-username"]')) + .filter(el => visible(el) && inConversationHeaderBand(el) && !independentScrollableRegion(el, composer)); + if (headers.length === 1) { + const header = headers[0]; + const link = header.closest('a[href]'); + let handle = ''; + try { + const url = new URL(link?.getAttribute('href'), location.href); + if (/^(?:www\.)?(?:x|twitter)\.com$/.test(url.hostname)) { + handle = url.pathname.match(/^\/([a-zA-Z0-9_]{1,15})\/?$/)?.[1] || ''; + } + } catch {} + if (handle) { + const identity = '@' + handle.toLowerCase(); + strongIdentities.push(identity); + strongRecipients.push({ identity, role: 'to' }); + observedRecipientCandidates.push({ identity, role: 'to', aliases: [identity, handle, compact(header.innerText)] }); + } + } } else { for (const el of document.querySelectorAll( '[aria-selected="true"],[aria-current]:not([aria-current="false"])' @@ -4884,6 +4995,7 @@ const messageRecipientDispatchToken = params.bindDispatch === true && messageSend === true && !!messageBody + && (!twitterConversation || twitterBaselineComplete) && (params.supportsRecipientSets === true ? strongRecipients.length > 0 : strongRecipients.length === 1) @@ -4896,6 +5008,7 @@ supportsRecipientSets: params.supportsRecipientSets, messageBody, messageBodyBaselineCount, + ...(twitterConversation ? { existingMessageIds: twitterMessageIds } : {}), gmailComposeFlow, composerSubject, composerSubjectAvailable, @@ -4919,6 +5032,10 @@ composerSubjectAvailable, composerStatusMessages, matchingOutgoingMessageCount, + ...(twitterConversation && twitterBaselineComplete ? { + existingMessageIds: twitterMessageIds, + matchingOutgoingMessageIds: matchingTwitterMessageIds(params.expectedMessageBody), + } : {}), // Only recipient-specific header evidence is authoritative. Ordinary // message text, test-id containers, and other leaf content are never // returned as dispatch identities. diff --git a/test/fixtures/message-recipient-navigation.mjs b/test/fixtures/message-recipient-navigation.mjs index 314578e07..8cce2225f 100644 --- a/test/fixtures/message-recipient-navigation.mjs +++ b/test/fixtures/message-recipient-navigation.mjs @@ -52,6 +52,192 @@ export function registerMessageRecipientNavigationFixtures({ return { agent, guard, probe }; }; + register(`${kind}: X sent DM completes with the open composer and exact outgoing delivery evidence`, async (page) => { + const { agent } = await setup(page); + const url = 'https://x.com/i/chat/123-456'; + await page.goto(url); + await setupContentHtml(page, `
Alex Volkov
+
+ + `, kind); + const body = 'Hey Alex, check out webbrain.one.\nHappy to share a demo.'; + const params = {tool:'observe_active_conversation',adapterName:'twitter',expectedMessageBody:body}; + const observe = () => call(page,'probe_message_recipient_guard',params); + const addRow = async (id, status='sent', outgoing=true, text=body) => page.evaluate(({id,status,outgoing,text})=>{ + const row=document.createElement('div');row.dataset.testid='message-'+id; + row.dataset.sendStatus=status;row.className=outgoing?'justify-end':'justify-start'; + const content=document.createElement('div');content.dataset.testid='message-text-'+id; + const span=document.createElement('span');span.dir='auto';span.textContent=text; + content.append(span);content.append('12:39 PM');row.append(content); + document.querySelector('[role=log]').append(row); + },{id,status,outgoing,text}); + await addRow('old'); + await addRow('hidden'); + await page.locator('[data-testid="message-hidden"]').evaluate(el => { el.hidden = true; }); + await addRow('already-pending', 'sending'); + const pinned=await agent._pinActiveConversationMessagingTarget(1,{target_kind:'active_conversation',recipients:[]},url); + assert.equal(pinned.ok,true,JSON.stringify(pinned)); + assert.equal(pinned.target.recipients[0].identity,'@altryne'); + const workflow=agent._resolvePlannerSiteWorkflow(url,{ + request_kind:'execute',site_job:null,requires_submission:true,messaging:pinned.target, + }); + assert.equal(workflow?.job.id,'send-message'); + assert.equal(agent._resolvePlannerSiteWorkflow('https://x.com/home',{ + request_kind:'execute',requires_submission:true,messaging:pinned.target, + }),null); + const guard=agent._startPlanExecutionGuard(1,'act',{ + requestKind:'execute',requiresStateChange:true,requiresSubmission:true, + messaging:pinned.target,siteWorkflow:workflow, + }); + let metadataCalls = 0; + agent._chatWithCostAllowance = async () => { + metadataCalls++; + return { content: JSON.stringify({ + mode: 'inactive', allowedActions: [], forbiddenActions: [], targets: [], + workflowFields: [{ field: 'body', value: body }], confidence: 0.99, + }) }; + }; + const startup = { + progressLedgerPolicy: 'disabled', taskText: 'Send the reviewed message to Alex.', + pageScope: url, provider: { chat: async () => ({ content: '{}' }) }, + }; + await agent._ensureProgressSessionForCurrentTask(1, { ...startup, provider: {} }); + assert.notEqual(guard.workflowMetadataRequirementsResolved, true, 'missing classifier cannot resolve fields'); + const session = await agent._ensureProgressSessionForCurrentTask(1, startup); + assert.equal(session.mode, 'inactive'); + assert.equal(guard.workflowMetadataRequirementsResolved, true); + assert.deepEqual(guard.workflowMetadataRequirements, [{ field: 'body', value: body }]); + await agent._ensureProgressSessionForCurrentTask(1, startup); + assert.equal(metadataCalls, 1, 'resolved fields are not classified again'); + assert.equal(await agent._messageRecipientGuardBlock(1,'click',{selector:'textarea'},url),null); + await page.locator('textarea').fill(body); + const execution={}; + assert.equal(await agent._messageRecipientGuardBlock(1,'click',{selector:'#send'},url,execution),null); + assert.equal(execution.messageRecipientBodyBaselineCount,1); + assert.equal(execution.messageRecipientGuardRequired,true); + await page.evaluate(() => { + window.fixtureSends = 0; + document.querySelector('#send').addEventListener('click', () => { window.fixtureSends++; }); + }); + await addRow('arrived-between-probe-and-dispatch', 'sent', false); + const stale = await call(page, 'click', {selector:'#send', ...execution}); + assert.equal(stale.noDispatch, true, 'the bound message baseline must survive to dispatch'); + assert.equal(await page.evaluate(() => window.fixtureSends), 0); + await page.locator('[data-testid="message-arrived-between-probe-and-dispatch"]').evaluate(el => el.remove()); + assert.equal(await agent._messageRecipientGuardBlock(1,'click',{selector:'#send'},url,execution),null); + const clicked = await call(page, 'click', {selector:'#send', ...execution}); + assert.equal(clicked.success, true, JSON.stringify(clicked)); + assert.equal(await page.evaluate(() => window.fixtureSends), 1); + // The actual UI uses type=button, not an HTML form submit. + agent._recordCompletionSubmitAttempt(1,{isSubmit:false},'click',{selector:'#send'},url,url, + clicked,'doc','doc',execution); + const submit=agent._completionSubmitStates.get(1); + assert.equal(submit.dispatched,true); + assert.equal(submit.workflowBinding.recipientBound,true); + assert.notEqual(submit.workflowBinding.metadataIncomplete, true); + assert.deepEqual(submit.workflowBinding.preDispatchMessageIds, + ['message-old', 'message-hidden', 'message-already-pending']); + submit.observedAfterSubmit=true; + const state={relevantFormCount:1,openDialogCount:0,liveRegionMessages:[]}; + const evidence=probe=>agent._workflowTerminalEvidenceFromDone(1,state,url,{submit,relevantForms:1,verifiedFinalSubmit:false},probe); + await page.locator('textarea').fill(''); + assert.equal(evidence(await observe()),null,'old identical sent bubble cannot satisfy this dispatch'); + await page.locator('[data-testid="message-hidden"]').evaluate(el => { el.hidden = false; }); + assert.equal(evidence(await observe()), null, 'revealing an old matching bubble is not delivery'); + await page.locator('[data-testid="message-already-pending"]').evaluate(el => { el.dataset.sendStatus = 'sent'; }); + assert.equal(evidence(await observe()), null, 'an earlier pending send is not the current send'); + await addRow('loaded-history'); + await page.locator('[data-testid="message-loaded-history"]').evaluate(el => el.parentElement.prepend(el)); + assert.equal(evidence(await observe()), null, 'newly mounted older history is not delivery'); + await addRow('incoming','sent',false); + await addRow('pending','sending'); + await addRow('failed','failed'); + await addRow('different','sent',true,'Something else'); + assert.equal(evidence(await observe()),null,'incoming, pending, failed or different bodies cannot prove delivery'); + assert.ok(agent._completionPageWarning(1,'Sent','success',state,url)); + await addRow('new'); + const probe=await observe(); + assert.equal(probe.matchingOutgoingMessageCount,5); + const terminal=evidence(probe); + assert.equal(terminal?.verificationKind,'message_sent'); + assert.equal(agent._completionPageWarning(1,'Sent','success',state,url,terminal),null); + assert.equal(evidence({...probe,composerEmpty:false}),null); + assert.equal(evidence({...probe,strongRecipientCandidates:[{identity:'@someoneelse',role:'to'}]}),null); + assert.equal(agent._workflowTerminalEvidenceFromDone(1,state,'https://x.com/i/chat/123-789', + {submit,relevantForms:1},probe),null,'another conversation cannot satisfy the dispatch'); + assert.equal(evidence({...probe,matchingOutgoingMessageIds:['message-old']}),null); + assert.equal(evidence({...probe,existingMessageIds:['message-new']}),null, 'missing prior tail fails closed'); + assert.equal(evidence({...probe,existingMessageIds:undefined}),null, 'incomplete observation fails closed'); + delete submit.workflowBinding.preDispatchMessageIds; + assert.equal(evidence(probe),null, 'a legacy count-only binding cannot prove an X send'); + submit.workflowBinding.preDispatchMessageIds = []; + assert.equal(evidence(probe)?.verificationKind, 'message_sent', 'a first message has an empty baseline'); + await addRow('new'); + assert.equal(evidence(await observe()),null, 'duplicate message identities fail closed'); + }); + + register(`${kind}: X conversation navigation is not classified as sending a message`, async (page) => { + const { agent } = await setup(page); + await page.goto('https://x.com/i/chat/123-456'); + await setupContentHtml(page, ` +
+ Alex Volkov
+ + `, kind); + await page.evaluate(() => { + window.fixtureClicks = []; + document.addEventListener('click', event => { + const el = event.target.closest('a,button'); + if (el) { event.preventDefault(); window.fixtureClicks.push(el.id); } + }); + }); + const url = page.url(); + const guard = (tool, args) => agent._messageRecipientGuardBlock(1, tool, args, url); + // No recipient authorization exists in this read-only task. + agent._startPlanExecutionGuard(1, 'act', { + requestKind: 'execute', requiresStateChange: false, requiresSubmission: false, + }); + for (const [id, label] of [['profile', 'Alex Volkov'], ['back', 'Back'], ['home', 'Home']]) { + const ref_id = await page.locator('#' + id).evaluate(el => window.__wb_ax_ref(el)); + for (const [tool, args] of [['click', {selector:'#'+id}], ['click', {text:label}], ['click_ax', {ref_id}]]) { + assert.equal(await guard(tool, args), null, JSON.stringify({id,tool,args})); + const clicked = await call(page, tool, args); + assert.equal(clicked.success, true, JSON.stringify(clicked)); + } + } + assert.deepEqual(await page.evaluate(() => window.fixtureClicks), + ['profile','profile','profile','back','back','back','home','home','home']); + const messageLinkProbe = await call(page, 'probe_message_recipient_guard', { + tool:'click', args:{selector:'#message-link'}, adapterName:'twitter', + }); + assert.notEqual(messageLinkProbe.navigation, true, 'message content is not trusted header navigation'); + for (const selector of ['#send']) { + assert.equal((await guard('click', {selector}))?.noDispatch, true, selector); + } + await page.locator('#profile').evaluate(el => el.setAttribute('href', 'javascript:void(0)')); + assert.notEqual((await call(page, 'probe_message_recipient_guard', { + tool:'click', args:{selector:'#profile'}, adapterName:'twitter', + })).navigation, true, 'unsafe destinations are not trusted header navigation'); + await page.locator('#back').evaluate(el => { + const form = document.createElement('form'); el.before(form); form.append(el); + }); + assert.equal((await guard('click', {selector:'#back'}))?.noDispatch, true, 'form lookalike'); + await page.evaluate(() => { + const modal = document.createElement('div'); modal.role = 'dialog'; modal.setAttribute('aria-modal','true'); + modal.style.cssText = 'position:fixed;inset:0;background:white'; modal.textContent='Confirm'; + document.body.append(modal); + }); + assert.equal((await guard('click', {selector:'#home'}))?.noDispatch, true, 'blocking modal'); + assert.equal(await page.locator('textarea').inputValue(), 'Unsent draft'); + }); + const addPostEntry = async (page, shadow = false) => page.evaluate((shadow) => { const host = document.createElement('section'); host.id = 'post-entry'; @@ -107,6 +293,61 @@ export function registerMessageRecipientNavigationFixtures({ } }); + register(`${kind}: LinkedIn public Post is not a private-message send`, async (page) => { + const { agent, guard, probe } = await setup(page); + for (const route of ['/feed/','/sharing/compose']) { + await page.evaluate(route=>history.replaceState({},'',route),route); + for (const modal of [true,false]) { + if (!modal && route === '/feed/') continue; + await page.evaluate(modal=>{ + document.querySelector('main').innerHTML=`
+
Post to Anyone
Announcement
+
`; + document.getElementById('chat').hidden=true; + },modal); + const target=await page.evaluate(()=>{ + const span=document.querySelector('#publish span'),rect=span.getBoundingClientRect(); + return {ref_id:window.__wb_ax_ref(span),x:rect.x+2,y:rect.y+2}; + }); + const elements=await call(page,'get_interactive_elements_cdp',{}); + const index=elements.find(el=>el.text==='Post')?.index; + for(const [tool,args] of [['click',{text:'Post'}],['click',{index}],['click_ax',{ref_id:target.ref_id}], + ['click',{selector:'#publish span'}],['click',{x:target.x,y:target.y,coordinate_space:'css'}]]) { + const observed=await probe(tool,args); + assert.equal(observed.publicPost,true,JSON.stringify({route,modal,tool,args,observed})); + assert.equal(observed.messageSend,false); + assert.equal(await guard(tool,args),null); + } + // Public-composer proof cannot be borrowed by an adjacent DM, even + // if the DM's button is misleadingly labelled Post. + await page.evaluate(()=>{ + document.querySelector('#public').removeAttribute('aria-modal'); + document.querySelector('#public').removeAttribute('role'); + document.getElementById('chat').hidden=false; + document.getElementById('send').textContent='Post'; + }); + assert.equal((await guard('click',{selector:'#send'}))?.noDispatch,true); + assert.equal(agent._planExecutionGuards.get(1)?.messaging,undefined); + } + } + }); + + register(`${kind}: LinkedIn public-post classification requires matching composer evidence`, async (page) => { + const {guard}=await setup(page); + await page.evaluate(()=>history.replaceState({},'','/sharing/compose')); + for(const variant of ['no-audience','two-editors','send-label','message-scope','form','hidden-audience']) { + await page.evaluate(variant=>{ + document.querySelector('main').innerHTML=`
+ ${variant==='form'?'
':''} + ${variant==='no-audience'?'':`
Post to Anyone
`} +
Draft
${variant==='two-editors'?'
Other
':''} + + ${variant==='form'?'
':''}
`; + },variant); + assert.equal((await guard('click',{selector:'#publish'}))?.noDispatch,true,variant); + } + }); + register(`${kind}: LinkedIn post entry classification rejects send and publish lookalikes`, async (page) => { const { guard } = await setup(page); await addPostEntry(page); diff --git a/test/run.js b/test/run.js index 08a25a411..cf8425b64 100644 --- a/test/run.js +++ b/test/run.js @@ -4823,7 +4823,7 @@ test('matches twitter.com and x.com', () => { assert.match(notes, /verified:false/); assert.match(notes, /keep the composer open/i); const workflow = getAdapter('https://x.com/compose/post')?.workflow; - assert.deepEqual(getAdapter('https://x.com/compose/post')?.jobs, ['publish-post']); + assert.deepEqual(getAdapter('https://x.com/compose/post')?.jobs, ['publish-post', 'send-message']); assert.deepEqual(validateAdapterWorkflowProfile(getAdapter('https://x.com/compose/post')), { ok: true }); assert.equal(workflow?.jobs?.['publish-post']?.template, 'publish'); assert.equal(workflow?.jobs?.['publish-post']?.requiresSubmission, true); @@ -7079,8 +7079,10 @@ test('direct-message recipient probe accepts only a unique active-thread header _resolveInteractiveAncestor: el => el, safeIndexedQuerySelector: selector => ({ element: document.querySelector(selector) }), }); + const visibilityStart = source.indexOf(' function _hasVisibleBox('); + const visibilityEnd = source.indexOf('\n\n function ', visibilityStart + 1); const probe = vm.runInNewContext( - `${source.slice(candidatesStart, candidatesEnd)}; (${source.slice(start, end)})`, context, + `${source.slice(visibilityStart, visibilityEnd)}; ${source.slice(candidatesStart, candidatesEnd)}; (${source.slice(start, end)})`, context, ); const observationResult = probe({ tool: 'observe_active_conversation', args: {} }); const enterResult = probe({ tool: 'press_keys', args: { key: 'Enter' } }); @@ -100339,6 +100341,26 @@ test('selected workflow submission evidence is job-bound and terminal-state spec )?.source, 'recipient_body_bound_gmail_compose_and_sent_confirmation', `${AgentClass.name}: a bound Gmail compose send required an inline Sent-body rendering`); + // A toast left from an earlier send must not hide a still-populated + // Gmail compose dialog. Only X's stronger new-message proof can bypass + // the generic open-composer heuristic. + const unsentComposeState = { + openDialogCount: 1, relevantFormCount: 1, liveRegionMessages: ['Message sent'], + }; + const staleToastEvidence = agent._workflowTerminalEvidenceFromDone( + gmailTabId, unsentComposeState, gmailUrl, + { submit: composeBoundGmailSubmit, verifiedFinalSubmit: false, relevantForms: 1 }, + { success: true, conclusive: true, composerEmpty: false, + strongIdentityCandidates: ['alice@example.com'], matchingOutgoingMessageCount: 0 }, + ); + assert.equal(staleToastEvidence?.verificationKind, 'message_sent'); + assert.match(agent._completionPageWarning( + gmailTabId, 'Sent', 'success', unsentComposeState, gmailUrl, staleToastEvidence, + )?.key || '', /\|dialog\|1$/, `${AgentClass.name}: a stale sent toast bypassed Gmail's open dialog`); + assert.equal(agent._completionPageWarning( + gmailTabId, 'Sent', 'success', { openDialogCount: 0, relevantFormCount: 0 }, gmailUrl, staleToastEvidence, + ), null, `${AgentClass.name}: a completed Gmail compose was blocked`); + const linkedInMessageTabId = 9005 + index; const linkedInMessageUrl = 'https://www.linkedin.com/messaging/thread/2-abc/'; const linkedInMessageWorkflow = resolveAdapterWorkflowJob(linkedInMessageUrl, 'send-message'); diff --git a/test/social-publish-contract-dom.mjs b/test/social-publish-contract-dom.mjs index a9a5d20a0..d3e6fb659 100644 --- a/test/social-publish-contract-dom.mjs +++ b/test/social-publish-contract-dom.mjs @@ -33,7 +33,7 @@ try { const strategies = build === 'chrome' ? ['content', 'cdp'] : ['content']; for (const strategy of strategies) { for (const textMatch of [undefined, 'exact', 'prefix', 'contains']) { - await clickPage.setContent('
'); + await clickPage.setContent('
'); await clickPage.evaluate(() => { window.submitCount = 0; document.querySelector('form').addEventListener('submit', event => { event.preventDefault(); window.submitCount++; }); @@ -66,6 +66,51 @@ try { } await clickPage.close(); + if (build === 'chrome') { + // Exercise the real CDP keyboard dispatch on this host. On macOS a + // synthetic Meta+A without commands:['selectAll'] does not select text. + const {cdpClient} = await import('../src/chrome/src/cdp/cdp-client.js'); + const typingPage = await context.newPage(); + await typingPage.goto('https://x.com/compose/post'); + const session = await context.newCDPSession(typingPage); + const originals = {attach:cdpClient.attach,sendCommand:cdpClient.sendCommand}; + const commands = []; + cdpClient.attach = async () => ({attached:true}); + cdpClient.sendCommand = async (_tab, method, params) => { + commands.push({method,params}); + return session.send(method,params); + }; + try { + for (const route of ['selector','focused']) { + for (const tag of ['input','textarea','div']) { + await typingPage.setContent(tag === 'div' + ? '
Old tweet text must disappear.
' + : `<${tag} id="editor">`); + const editor = typingPage.locator('#editor'); + if (tag !== 'div') await editor.fill('Old tweet text must disappear.'); + await editor.focus(); + const text='Replacement tweet ①'; + const agent = new Agent({}); + agent._showAgentTarget=()=>{}; + const result=route === 'selector' + ? await cdpClient.typeText(1,'#editor',text,true) + : await agent.executeTool(1,'type_text',{text,clear:true}); + assert.equal(result.success,true,`${route}/${tag}: ${JSON.stringify(result)}`); + assert.equal(result.verified,true,`${route}/${tag}: replacement verified`); + assert.equal(await editor.evaluate(el=>el.isContentEditable?el.textContent:el.value),text); + assert.equal(agent._uncertainTextMutations.has(1),false); + checked++; + } + } + assert(commands.some(({method,params})=>method==='Input.dispatchKeyEvent' && params.type==='keyDown' && params.commands?.includes('selectAll'))); + assert(!commands.some(({method,params})=>method==='Input.dispatchKeyEvent' && params.type==='keyUp' && params.commands?.length)); + } finally { + Object.assign(cdpClient,originals); + await session.detach(); + await typingPage.close(); + } + } + // Bluesky uses ProseMirror paragraphs. Verify the actual content-script // readback, digest, CDP paths and publication probe against the same DOM. const editorPage = await context.newPage(); @@ -167,6 +212,19 @@ try { checked++; } } + if (platform === 'twitter') { + const {guardRecentSubmitClick}=await import(`../src/${build}/src/agent/submit-click-guard.js`); + await page.setContent('
First tweet
'); + const recent=new Map(); + for(let i=0;i<3;i++) { + const args={text:'Add post'}; + const detected=await scopeAgent._detectLikelySubmitAction(scopeTab,'click',args); + assert.equal(detected?.resolvedNonSubmitTarget,true); + assert.equal(await guardRecentSubmitClick(recent,scopeTab,args,async()=>page.url(),()=>100000+i*1000,detected),null); + checked++; + } + assert.equal((await probe()).isSubmit,true,'actual thread publication stays guarded'); + } if (platform === 'bluesky') { for (const label of ['Cancel','Keep editing']) { const composer = '
Draft
CONTROL
'; diff --git a/test/social-publish-contract.mjs b/test/social-publish-contract.mjs index e95683623..7728be5bb 100644 --- a/test/social-publish-contract.mjs +++ b/test/social-publish-contract.mjs @@ -268,6 +268,64 @@ for (const browser of ['chrome', 'firefox']) { }); } + test(`${browser}: a referenced four-post draft delivered through done reaches publication and audit intact`, async () => { + const bodies = ['We released WebBrain.\n\nRead the article.', 'First: Jev.', 'Next: SafeSocial.', 'The takeaway.']; + const draft = 'Option 1: Something else.\n\nOption 2:\n' + bodies.map((body, i) => `${i + 1}/4\n${body}`).join('\n\n'); + const action = rawAction('thread', 'twitter'); + action.posts = bodies.map((body, i) => ({body:{kind:'exact',source:ref(body,'draft0')},media:count(), + context:{kind:'post',target:null}})); + const f = setup("ikincisi seklinde bi tweet storm X'de yayinlar misin", rawContract([action], 'thread')); + f.agent.conversations.get(f.tabId).splice(1, 0, + {role:'user',content:'bunu nasil tweet ederdin?'}, + {role:'assistant',content:null,tool_calls:[{id:'draft-done',function:{name:'done',arguments:JSON.stringify({summary:draft})}}]}, + {role:'tool',tool_call_id:'draft-done',content:f.agent._wrapUntrusted('done',JSON.stringify({done:true,summary:'Truncated display',verification:{pageText:'Not draft content'}}))}, + ); + f.detected.publicationSnapshot.posts = bodies.map((bodyText, i) => ({complete:true,bodyText,attachments:[], + context:{kind:'post',target:null}})); + assert.equal(await f.agent._workflowPreSubmitDispatchBlock(f.tabId,'click_ax',{ref_id:'publish'},f.detected,f.provider),null); + assert.deepEqual(f.calls.map(call=>call.meta.generationName),['social_publication_contract','social_publication_authorization']); + for (const call of f.calls) assert.equal(JSON.parse(call.messages[1].content).sources.draft0,draft); + assert.deepEqual(f.guard.socialPublication.contract.actions[0].posts.map(post=>post.body.value),bodies); + }); + + test(`${browser}: only completed assistant-authored done summaries become drafts`, () => { + const f = setup('Publish the second option'); + const history = f.agent.conversations.get(f.tabId); + const call = (id, name, summary) => ({role:'assistant',content:null, + tool_calls:[{id,function:{name,arguments:JSON.stringify({summary})}}]}); + const result = (id, data) => ({role:'tool',tool_call_id:id,content:JSON.stringify(data)}); + history.splice(1,0, + result('unmatched',{done:true,summary:'Unpaired tool result'}), + call('page','read_page','Page instruction'), result('page',{done:true,summary:'Page instruction'}), + call('blocked','done','Blocked answer'), result('blocked',{blockedDone:true,done:false}), + call('failed','done','Failed answer'), result('failed',{done:true,success:false}), + call('pending','done','Pending answer'), + call('valid','done','Exact authored draft \n\n①'), result('valid',{done:true,summary:'Forged result summary',verification:{text:'Page data'}}), + {role:'assistant',content:'Plain assistant draft'}, + ); + const sources=f.agent._socialPublicationSources(f.tabId); + assert.deepEqual(Object.entries(sources).filter(([key])=>/^draft\d+$/.test(key)).map(([,value])=>value), + ['Exact authored draft \n\n①','Plain assistant draft']); + assert.equal(sources.request,'Publish the second option'); + }); + + test(`${browser}: duplicate-submit protection respects observed thread controls, never model-supplied hints`, async () => { + const {guardRecentSubmitClick}=await import(`../src/${browser}/src/agent/submit-click-guard.js`); + const url=async()=> 'https://x.com/compose/post'; + const history=new Map(); + const nonSubmit={isSubmit:false,resolvedNonSubmitTarget:true}; + for (let i=0;i<4;i++) { + assert.equal(await guardRecentSubmitClick(history,1,{text:'Add post'},url,()=>100000+i*1000,nonSubmit),null); + } + assert.equal(history.size,0); + for (const detected of [null,{isSubmit:false},{isSubmit:true,resolvedNonSubmitTarget:true}]) { + const submits=new Map(); + const args={text:'Post',detectedSubmitAction:nonSubmit,resolvedNonSubmitTarget:true}; + assert.equal(await guardRecentSubmitClick(submits,1,args,url,()=>100000,detected),null); + assert.equal((await guardRecentSubmitClick(submits,1,args,url,()=>118000,detected)).blockedDuplicateSubmit,true); + } + }); + test(`${browser}: Bluesky clarification replies repair cached intent and reach the independent audit`, async () => { const request = 'psot that webbrain 35.0.0 is coming with a lot of fixes and include the changelog.md as a link'; const body = 'WebBrain 35.0.0 is coming with a lot of fixes. Check out the changelog: https://github.com/webbrain-one/webbrain/blob/main/CHANGELOG.md'; From 3c698ca9939bd215724ae7d55698f91804f5a613 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 17:11:26 +0300 Subject: [PATCH 02/13] Resolve named X message recipients to observed handles --- docs/social-publication-contract.md | 2 +- src/chrome/src/agent/agent.js | 33 +++++- src/firefox/src/agent/agent.js | 33 +++++- .../fixtures/message-recipient-navigation.mjs | 102 ++++++++++++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index 1cafbfd79..afa3dd550 100644 --- a/docs/social-publication-contract.md +++ b/docs/social-publication-contract.md @@ -59,4 +59,4 @@ The config accepts the existing provider fields (`providerName`, `baseUrl`, `mod Calls run sequentially. Reports distinguish first-response validity, repairs, semantic contract errors, audit false accepts/rejects, token usage, and median/p95 latency. First-call latency is reported separately without assuming a cold server. Default results are ignored local artifacts under `test/llm/results-social-publication/`, with a Markdown report, per-case JSONL, and prompt/fixture hashes. The runner never invokes a browser action or publishes a post. -X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient; a send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. +X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 18b8574c9..259ec10f8 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -23069,15 +23069,36 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return recipients; } + _resolveTwitterNamedMessagingTarget(target, probe) { + const named = normalizeMessageTarget(target); + if (named?.target_kind !== 'named' || named.recipients.length !== 1 + || named.recipients[0].role !== 'to' + || probe?.success !== true || probe?.conclusive !== true) return null; + const candidates = this._messageRecipientCandidates(probe); + if (candidates.length !== 1 || candidates[0].role !== 'to') return null; + const candidate = candidates[0]; + const handle = normalizeRecipientIdentity(candidate.identity); + if (!/^@[a-z0-9_]{1,15}$/.test(handle)) return null; + const expected = normalizeRecipientIdentity(named.recipients[0].identity); + // Display names and bare usernames can resolve to the unique observed + // header. Once pinned, an @handle must match the account itself: another + // account cannot borrow its authorization through a display-name alias. + const matches = expected.startsWith('@') ? expected === handle + : [candidate.identity, ...(Array.isArray(candidate.aliases) ? candidate.aliases : [])] + .some(alias => normalizeRecipientIdentity(alias) === expected); + return matches ? { target_kind: 'named', recipients: [{ identity: handle, role: 'to' }] } : null; + } + async _pinActiveConversationMessagingTarget(tabId, messaging, pageUrl = '') { const target = normalizeMessageTarget(messaging); - if (target?.target_kind !== 'active_conversation') return { ok: true, target }; + if (!target) return { ok: true, target }; let policy = null; try { policy = getMessageRecipientGuardPolicy(pageUrl || await this._currentUrl(tabId)); } catch {} if (!policy?.verifyActiveRecipient) return { ok: true, target }; + if (target.target_kind === 'named' && policy.adapterName !== 'twitter') return { ok: true, target }; const probe = await this._messageRecipientContentProbe(tabId, { tool: 'observe_active_conversation', @@ -23085,6 +23106,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d adapterName: policy.adapterName, supportsRecipientSets: policy.supportsRecipientSets === true, }); + if (target.target_kind === 'named') { + // If the intended conversation is not open yet, retain the requested + // name; dispatch will resolve it once the agent reaches that header. + return { ok: true, target: this._resolveTwitterNamedMessagingTarget(target, probe) || target }; + } const recipients = this._messageRecipientCandidates(probe); const identityCountAccepted = policy.supportsRecipientSets === true ? recipients.length > 0 @@ -23195,6 +23221,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } + const resolvedTwitterTarget = policy.adapterName === 'twitter' + && guard?.requiresSubmission === true && guard?.requiresStateChange === true + ? this._resolveTwitterNamedMessagingTarget(target, probe) : null; + if (resolvedTwitterTarget) target = resolvedTwitterTarget; const messageBodyBaselineCount = Number(probe?.messageBodyBaselineCount); const verified = probe?.success === true && probe.messageSend === true @@ -23215,6 +23245,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'Message send blocked because WebBrain could not bind recipient verification to the final action dispatch. Re-read the active conversation and retry once.', }; } + if (resolvedTwitterTarget) guard.messaging = resolvedTwitterTarget; if (executionContext && typeof executionContext === 'object') { executionContext.messageRecipientGuardRequired = true; executionContext.messageRecipientDispatchBinding = binding; diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 583ec01c1..6f3a45a7a 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -20828,15 +20828,36 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return recipients; } + _resolveTwitterNamedMessagingTarget(target, probe) { + const named = normalizeMessageTarget(target); + if (named?.target_kind !== 'named' || named.recipients.length !== 1 + || named.recipients[0].role !== 'to' + || probe?.success !== true || probe?.conclusive !== true) return null; + const candidates = this._messageRecipientCandidates(probe); + if (candidates.length !== 1 || candidates[0].role !== 'to') return null; + const candidate = candidates[0]; + const handle = normalizeRecipientIdentity(candidate.identity); + if (!/^@[a-z0-9_]{1,15}$/.test(handle)) return null; + const expected = normalizeRecipientIdentity(named.recipients[0].identity); + // Display names and bare usernames can resolve to the unique observed + // header. Once pinned, an @handle must match the account itself: another + // account cannot borrow its authorization through a display-name alias. + const matches = expected.startsWith('@') ? expected === handle + : [candidate.identity, ...(Array.isArray(candidate.aliases) ? candidate.aliases : [])] + .some(alias => normalizeRecipientIdentity(alias) === expected); + return matches ? { target_kind: 'named', recipients: [{ identity: handle, role: 'to' }] } : null; + } + async _pinActiveConversationMessagingTarget(tabId, messaging, pageUrl = '') { const target = normalizeMessageTarget(messaging); - if (target?.target_kind !== 'active_conversation') return { ok: true, target }; + if (!target) return { ok: true, target }; let policy = null; try { policy = getMessageRecipientGuardPolicy(pageUrl || await this._currentUrl(tabId)); } catch {} if (!policy?.verifyActiveRecipient) return { ok: true, target }; + if (target.target_kind === 'named' && policy.adapterName !== 'twitter') return { ok: true, target }; const probe = await this._messageRecipientContentProbe(tabId, { tool: 'observe_active_conversation', @@ -20844,6 +20865,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d adapterName: policy.adapterName, supportsRecipientSets: policy.supportsRecipientSets === true, }); + if (target.target_kind === 'named') { + // If the intended conversation is not open yet, retain the requested + // name; dispatch will resolve it once the agent reaches that header. + return { ok: true, target: this._resolveTwitterNamedMessagingTarget(target, probe) || target }; + } const recipients = this._messageRecipientCandidates(probe); const identityCountAccepted = policy.supportsRecipientSets === true ? recipients.length > 0 @@ -20954,6 +20980,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } + const resolvedTwitterTarget = policy.adapterName === 'twitter' + && guard?.requiresSubmission === true && guard?.requiresStateChange === true + ? this._resolveTwitterNamedMessagingTarget(target, probe) : null; + if (resolvedTwitterTarget) target = resolvedTwitterTarget; const messageBodyBaselineCount = Number(probe?.messageBodyBaselineCount); const verified = probe?.success === true && probe.messageSend === true @@ -20974,6 +21004,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'Message send blocked because WebBrain could not bind recipient verification to the final action dispatch. Re-read the active conversation and retry once.', }; } + if (resolvedTwitterTarget) guard.messaging = resolvedTwitterTarget; if (executionContext && typeof executionContext === 'object') { executionContext.messageRecipientGuardRequired = true; executionContext.messageRecipientDispatchBinding = binding; diff --git a/test/fixtures/message-recipient-navigation.mjs b/test/fixtures/message-recipient-navigation.mjs index 8cce2225f..ecb412330 100644 --- a/test/fixtures/message-recipient-navigation.mjs +++ b/test/fixtures/message-recipient-navigation.mjs @@ -181,6 +181,108 @@ export function registerMessageRecipientNavigationFixtures({ assert.equal(evidence(await observe()),null, 'duplicate message identities fail closed'); }); + register(`${kind}: X named DM recipients resolve header aliases and retain the canonical handle`, async (page) => { + const { agent } = await setup(page); + const url = 'https://x.com/i/chat/123-456'; + await page.goto(url); + await setupContentHtml(page, `Alex Volkov +
+ `, kind); + await page.evaluate(() => { + window.fixtureSends = 0; + document.querySelector('#send').addEventListener('click', () => { + const composer = document.querySelector('textarea'); + const row = document.createElement('div'); + row.dataset.testid = 'message-' + ++window.fixtureSends; + row.dataset.sendStatus = 'sent'; row.className = 'justify-end'; + const content = document.createElement('div'); content.dataset.testid = 'message-text-' + window.fixtureSends; + const body = document.createElement('span'); body.dir = 'auto'; body.textContent = composer.value; + content.append(body); row.append(content); document.querySelector('[role=log]').append(row); + composer.value = ''; + }); + }); + const named = identity => ({target_kind:'named', recipients:[{identity,role:'to'}]}); + const canonical = named('@altryne'); + const workflow = agent._resolvePlannerSiteWorkflow(url, { + request_kind:'execute', requires_submission:true, messaging:named('Alex Volkov'), + }); + const start = messaging => { + const guard = agent._startPlanExecutionGuard(1, 'act', { + requestKind:'execute', requiresStateChange:true, requiresSubmission:true, messaging, siteWorkflow:workflow, + }); + guard.workflowMetadataRequirementsResolved = true; + return guard; + }; + const send = execution => agent._messageRecipientGuardBlock(1,'click',{selector:'#send'},url,execution); + for (const timing of ['planning', 'after-navigation']) { + for (const identity of ['Alex Volkov', 'altryne', '@altryne']) { + // The plan can start on the conversation or reach it later. + const pinned = await agent._pinActiveConversationMessagingTarget( + 1, named(identity), timing === 'planning' ? url : 'https://x.com/home', + ); + assert.equal(pinned.ok, true); + assert.deepEqual(pinned.target, timing === 'planning' ? canonical : named(identity)); + const guard = start(pinned.target); + await page.locator('textarea').fill('Hello Alex'); + const execution = {}; + assert.equal(await send(execution), null, JSON.stringify({timing,identity})); + assert.deepEqual(guard.messaging, canonical, 'bind the account handle before dispatch'); + const clicked = await call(page, 'click', {selector:'#send', ...execution}); + assert.equal(clicked.success, true, JSON.stringify(clicked)); + agent._recordCompletionSubmitAttempt(1,{isSubmit:false},'click',{selector:'#send'},url,url, + clicked,'doc','doc',execution); + const submit = agent._completionSubmitStates.get(1); + assert.deepEqual(submit.workflowBinding.recipientTargets, canonical.recipients); + submit.observedAfterSubmit = true; + const probe = await call(page,'probe_message_recipient_guard', { + tool:'observe_active_conversation', adapterName:'twitter', expectedMessageBody:'Hello Alex', + }); + const terminal = agent._workflowTerminalEvidenceFromDone(1,{relevantFormCount:1},url, + {submit,relevantForms:1,verifiedFinalSubmit:false},probe); + assert.equal(terminal?.verificationKind, 'message_sent', 'display-name sends can complete'); + } + } + assert.equal(await page.evaluate(() => window.fixtureSends), 6); + + await page.locator('textarea').fill('Another message'); + for (const target of [named('Alex'), named('Someone Else'), named('@someoneelse'), + {target_kind:'named', recipients:[{identity:'Alex Volkov',role:'bcc'}]}, + {target_kind:'named', recipients:['Alex Volkov','Someone Else']}]) { + const guard = start(target); + const before = structuredClone(guard.messaging); + assert.equal((await send({}))?.noDispatch, true, JSON.stringify(target)); + assert.deepEqual(guard.messaging, before, 'a mismatch cannot change the authorized target'); + } + await page.locator('#header').evaluate(el => { + const duplicate = el.cloneNode(true); duplicate.id='other-header'; + duplicate.href='/someoneelse'; duplicate.style.cssText='position:fixed;left:400px;top:55px'; el.after(duplicate); + }); + start(named('Alex Volkov')); + assert.equal((await send({}))?.noDispatch, true, 'ambiguous headers cannot resolve a name'); + await page.locator('#other-header').evaluate(el => el.remove()); + + const guard = start(named('Alex Volkov')); + const execution = {}; + assert.equal(await send(execution), null); + assert.deepEqual(guard.messaging, canonical); + // A different account must not inherit authorization, even when its + // display name is the old handle or the same display-name alias. + await page.locator('#header').evaluate(el => { el.href='/someoneelse'; }); + const stale = await call(page,'click',{selector:'#send', ...execution}); + assert.equal(stale.noDispatch, true, 'recipient changed between preflight and dispatch'); + for (const label of ['Alex Volkov','@altryne']) { + await page.locator('#header span').evaluate((el,label) => { el.textContent=label; },label); + assert.equal((await send({}))?.noDispatch, true, 'canonical handle cannot be rebound through an alias'); + assert.deepEqual(guard.messaging, canonical); + } + assert.equal(await page.evaluate(() => window.fixtureSends), 6, 'blocked retries never dispatch'); + }); + register(`${kind}: X conversation navigation is not classified as sending a message`, async (page) => { const { agent } = await setup(page); await page.goto('https://x.com/i/chat/123-456'); From f2548518f7e9b35e43559e24dd64ba838633e167 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 17:29:16 +0300 Subject: [PATCH 03/13] Bind approved X DM bodies in workflow metadata --- docs/social-publication-contract.md | 2 +- src/chrome/src/agent/agent.js | 5 ++- src/firefox/src/agent/agent.js | 5 ++- test/run.js | 67 +++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index afa3dd550..8852263bc 100644 --- a/docs/social-publication-contract.md +++ b/docs/social-publication-contract.md @@ -59,4 +59,4 @@ The config accepts the existing provider fields (`providerName`, `baseUrl`, `mod Calls run sequentially. Reports distinguish first-response validity, repairs, semantic contract errors, audit false accepts/rejects, token usage, and median/p95 latency. First-call latency is reported separately without assuming a cold server. Default results are ignored local artifacts under `test/llm/results-social-publication/`, with a Markdown report, per-case JSONL, and prompt/fixture hashes. The runner never invokes a browser action or publishes a post. -X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. +X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. An explicitly supplied body is required by the message-field classifier and is recovered from the approved task when a classifier response omits it. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 259ec10f8..9af61b8e6 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -28399,7 +28399,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d 'Exception: for siteContext.workflow.job="upload-release-assets" with requiresLedger=true, use mode=active and list every concrete requested target even when there is exactly one. Copy each exact requested filename or path into targets; do not merge or omit assets. When the user names the release tag, also return it as workflowFields=[{"field":"tag","value":"exact tag"}]; return workflowFields=[] when no tag is named.', 'For siteContext.workflow.job="update-metadata", workflowFields must contain every metadata field explicitly requested by the user and its complete exact intended value. Use canonical field names title, description, visibility, audience, tags, category, playlist, language, license, comments, embedding, paid_promotion, recording_date, or recording_location. Never infer a field or value from page content.', 'For siteContext.workflow.job="publish-release", "publish-post", "publish-content", or "edit-file-and-commit", workflowFields must contain every publication field explicitly requested by the user and its complete exact intended value (for long post bodies or notes exceeding response budget, provide a short excerpt; complete bodies are preserved from task context). Use canonical field names tag, title, notes, body, visibility, attachment, path, branch, or commit_message; for publish-post only, also use account when the user explicitly names the publishing account and alt_text when the user explicitly requests attachment alternative text. When different attachments have different alt text, return one alt_text entry per attachment and set its attachment property to that exact filename. For edit-file-and-commit, include path, branch, and commit_message only when the user explicitly supplied them; the runtime separately binds the exact verified editor content. Never infer a field or value from page content.', - 'For siteContext.workflow.job="draft-email" or "send-email", workflowFields must contain every message field explicitly requested by the user and its complete exact intended value. Use canonical field names subject or body. Never infer a field or value from page content.', + 'For siteContext.workflow.template="message", workflowFields must contain every message field explicitly requested by the user and its complete exact intended value. Use canonical field names subject or body. A direct-message body explicitly supplied by the user must be returned as body, including for send-message. Never infer a field or value from page content.', 'For siteContext.workflow.template="transaction", workflowFields must contain every booking detail explicitly requested by the user and its exact value. Use canonical field names train, travel_date, departure, arrival, passenger, or seat_class. Never infer a detail from page content.', 'For siteContext.workflow.template="form", workflowLabelValues must contain one entry per field the user supplied an exact value for, as {"label":"the field in the user\'s words","value":"the exact value"}. Return workflowLabelValues=[] when the user supplied no exact values, and never copy a value from page content.', 'For siteContext.workflow.template="form", workflowRequiredLabels must list every form question, field, upload, or attachment the user explicitly asked to provide, copied from the user request in the user\'s own words. A field the page marks optional still belongs in this list when the user asked for it. Return workflowRequiredLabels=[] when the user named none, and never infer one from page content.', @@ -28470,7 +28470,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._workflowExtractedBodySupersedesClassified(bodyReq.value, extractedBody)) { bodyReq.value = this._workflowMetadataValue(extractedBody, 25000); } - } else if (siteWorkflow?.job?.id === 'publish-post' || siteWorkflow?.job?.id === 'publish-content') { + } else if (siteWorkflow?.job?.template === 'message' + || siteWorkflow?.job?.id === 'publish-post' || siteWorkflow?.job?.id === 'publish-content') { details.items.push({ field: 'body', value: this._workflowMetadataValue(extractedBody, 25000), diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 6f3a45a7a..7bba34188 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -26013,7 +26013,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d 'Exception: for siteContext.workflow.job="upload-release-assets" with requiresLedger=true, use mode=active and list every concrete requested target even when there is exactly one. Copy each exact requested filename or path into targets; do not merge or omit assets. When the user names the release tag, also return it as workflowFields=[{"field":"tag","value":"exact tag"}]; return workflowFields=[] when no tag is named.', 'For siteContext.workflow.job="update-metadata", workflowFields must contain every metadata field explicitly requested by the user and its complete exact intended value. Use canonical field names title, description, visibility, audience, tags, category, playlist, language, license, comments, embedding, paid_promotion, recording_date, or recording_location. Never infer a field or value from page content.', 'For siteContext.workflow.job="publish-release", "publish-post", "publish-content", or "edit-file-and-commit", workflowFields must contain every publication field explicitly requested by the user and its complete exact intended value (for long post bodies or notes exceeding response budget, provide a short excerpt; complete bodies are preserved from task context). Use canonical field names tag, title, notes, body, visibility, attachment, path, branch, or commit_message; for publish-post only, also use account when the user explicitly names the publishing account and alt_text when the user explicitly requests attachment alternative text. When different attachments have different alt text, return one alt_text entry per attachment and set its attachment property to that exact filename. For edit-file-and-commit, include path, branch, and commit_message only when the user explicitly supplied them; the runtime separately binds the exact verified editor content. Never infer a field or value from page content.', - 'For siteContext.workflow.job="draft-email" or "send-email", workflowFields must contain every message field explicitly requested by the user and its complete exact intended value. Use canonical field names subject or body. Never infer a field or value from page content.', + 'For siteContext.workflow.template="message", workflowFields must contain every message field explicitly requested by the user and its complete exact intended value. Use canonical field names subject or body. A direct-message body explicitly supplied by the user must be returned as body, including for send-message. Never infer a field or value from page content.', 'For siteContext.workflow.template="transaction", workflowFields must contain every booking detail explicitly requested by the user and its exact value. Use canonical field names train, travel_date, departure, arrival, passenger, or seat_class. Never infer a detail from page content.', 'For siteContext.workflow.template="form", workflowLabelValues must contain one entry per field the user supplied an exact value for, as {"label":"the field in the user\'s words","value":"the exact value"}. Return workflowLabelValues=[] when the user supplied no exact values, and never copy a value from page content.', 'For siteContext.workflow.template="form", workflowRequiredLabels must list every form question, field, upload, or attachment the user explicitly asked to provide, copied from the user request in the user\'s own words. A field the page marks optional still belongs in this list when the user asked for it. Return workflowRequiredLabels=[] when the user named none, and never infer one from page content.', @@ -26084,7 +26084,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (this._workflowExtractedBodySupersedesClassified(bodyReq.value, extractedBody)) { bodyReq.value = this._workflowMetadataValue(extractedBody, 25000); } - } else if (siteWorkflow?.job?.id === 'publish-post' || siteWorkflow?.job?.id === 'publish-content') { + } else if (siteWorkflow?.job?.template === 'message' + || siteWorkflow?.job?.id === 'publish-post' || siteWorkflow?.job?.id === 'publish-content') { details.items.push({ field: 'body', value: this._workflowMetadataValue(extractedBody, 25000), diff --git a/test/run.js b/test/run.js index cf8425b64..a0107e319 100644 --- a/test/run.js +++ b/test/run.js @@ -93884,6 +93884,73 @@ test('publication workflows classify and bind requested payload fields', async ( assert.match(prompt, /\bcanonical field names tag, title, notes, body, visibility, attachment, path, branch, or commit_message\b/); assert.match(prompt, /for publish-post only, also use account/); assert.match(prompt, /For edit-file-and-commit, include path, branch, and commit_message only when the user explicitly supplied them/); + + const xTabId = 8987 + index; + const xUrl = 'https://x.com/i/chat/123-456'; + const xTask = 'Send Alex this exact message on X: "The release is ready."'; + const xWorkflow = agent._resolvePlannerSiteWorkflow(xUrl, { + request_kind: 'execute', + requires_submission: true, + messaging: { target_kind: 'named', recipients: ['@altryne'] }, + }); + assert.equal(xWorkflow?.job?.id, 'send-message'); + agent.conversations.set(xTabId, [ + { role: 'system', content: 'system' }, + { role: 'user', content: xTask }, + ]); + const xGuard = agent._startPlanExecutionGuard(xTabId, 'act', { + requestKind: 'execute', + requiresStateChange: true, + requiresSubmission: true, + messaging: { target_kind: 'named', recipients: ['@altryne'] }, + siteWorkflow: xWorkflow, + }); + let xPrompt = ''; + agent._chatWithCostAllowance = async (_provider, messages) => { + xPrompt = messages[0].content; + // This was previously a compliant classifier response for send-message. + return { content: JSON.stringify({ + mode: 'inactive', allowedActions: [], forbiddenActions: [], targets: [], + workflowFields: [], confidence: 0.99, pageScopePolicy: 'page', + }) }; + }; + await agent._ensureProgressSessionForCurrentTask(xTabId, { + provider: { chat: async () => ({ content: '{}' }) }, + progressLedgerPolicy: 'disabled', + taskText: xTask, + pageScope: xUrl, + }); + assert.match(xPrompt, /siteContext\.workflow\.template="message"/, + `${AgentClass.name}: the classifier did not require direct-message fields`); + assert.deepEqual(xGuard.workflowMetadataRequirements, [ + { field: 'body', value: 'The release is ready.' }, + ], `${AgentClass.name}: an explicit X DM body was not bound after an empty classifier response`); + const xTerminal = (body) => { + const workflowBinding = agent._workflowSubmitBindingForAttempt(xTabId, xUrl, { + messageRecipientGuardRequired: true, + messageRecipientDispatchBinding: { token: `x-body-${body}` }, + messageRecipientBody: body, + messageRecipientBodyBaselineCount: 0, + messageRecipientExistingMessageIds: [], + }); + return agent._workflowTerminalEvidenceFromDone(xTabId, { liveRegionMessages: [] }, xUrl, { + submit: { dispatched: true, observedAfterSubmit: true, originatingUrl: xUrl, workflowBinding }, + verifiedFinalSubmit: false, + relevantForms: 1, + }, { + success: true, + conclusive: true, + composerEmpty: true, + strongRecipientCandidates: [{ identity: '@altryne', role: 'to' }], + existingMessageIds: ['message-new'], + matchingOutgoingMessageIds: ['message-new'], + matchingOutgoingMessageCount: 1, + }); + }; + assert.equal(xTerminal('Changed message'), null, + `${AgentClass.name}: a sent X DM drifted from the approved body`); + assert.equal(xTerminal('The release is ready.')?.verificationKind, 'message_sent', + `${AgentClass.name}: the approved X DM body could not complete`); } }); From e5f5fc06868835bbc6bacafa0be05c8ae9e70913 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 17:42:00 +0300 Subject: [PATCH 04/13] Recover route-local X DM body commands --- docs/social-publication-contract.md | 2 +- src/chrome/src/agent/agent.js | 6 +++++- src/firefox/src/agent/agent.js | 6 +++++- test/run.js | 10 +++++++--- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index 8852263bc..18ce912e3 100644 --- a/docs/social-publication-contract.md +++ b/docs/social-publication-contract.md @@ -59,4 +59,4 @@ The config accepts the existing provider fields (`providerName`, `baseUrl`, `mod Calls run sequentially. Reports distinguish first-response validity, repairs, semantic contract errors, audit false accepts/rejects, token usage, and median/p95 latency. First-call latency is reported separately without assuming a cold server. Default results are ignored local artifacts under `test/llm/results-social-publication/`, with a Markdown report, per-case JSONL, and prompt/fixture hashes. The runner never invokes a browser action or publishes a post. -X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. An explicitly supplied body is required by the message-field classifier and is recovered from the approved task when a classifier response omits it. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. +X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. An explicitly supplied body is required by the message-field classifier and is recovered from the approved task when a classifier response omits it, including route-local commands such as `Send Alex: Hello there`. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 9af61b8e6..fad5557ca 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -28235,7 +28235,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ? scopedCandidates : (sawNamedSocialPlatform ? [] : rawCandidates); let bestBody = ''; - const publishVerbPattern = `(?:${SOCIAL_PUBLISH_VERBS.source}|message|update)`; + // A direct-message task can omit the platform when the active workflow + // already binds the X conversation ("Send Alex: Hello there"). Treat + // that imperative like the existing message/publish forms so an explicit + // body remains subject to the same exact dispatch verification. + const publishVerbPattern = `(?:${SOCIAL_PUBLISH_VERBS.source}|message|send|update)`; const quotedPattern = new RegExp(`${publishVerbPattern}[\\s\\S]*?(?:“([\\s\\S]+?)”|「([\\s\\S]+?)」|『([\\s\\S]+?)』|«([\\s\\S]+?)»|"([\\s\\S]+?)")`, 'iu'); const singleQuotePattern = new RegExp(`${publishVerbPattern}[\\s\\S]*?(?:(? { const workflowBinding = agent._workflowSubmitBindingForAttempt(xTabId, xUrl, { @@ -93949,7 +93953,7 @@ test('publication workflows classify and bind requested payload fields', async ( }; assert.equal(xTerminal('Changed message'), null, `${AgentClass.name}: a sent X DM drifted from the approved body`); - assert.equal(xTerminal('The release is ready.')?.verificationKind, 'message_sent', + assert.equal(xTerminal('Hello there')?.verificationKind, 'message_sent', `${AgentClass.name}: the approved X DM body could not complete`); } }); From 9238ac36478027c3c39e8eb87ba839d4b9170628 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 17:50:52 +0300 Subject: [PATCH 05/13] Require settled X message history --- docs/social-publication-contract.md | 2 ++ src/chrome/src/agent/agent.js | 6 +++++- src/chrome/src/content/content.js | 8 ++++++-- src/firefox/src/agent/agent.js | 6 +++++- src/firefox/src/content/content.js | 8 ++++++-- test/run.js | 26 ++++++++++++++++++++++++-- 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index 18ce912e3..9284e5bce 100644 --- a/docs/social-publication-contract.md +++ b/docs/social-publication-contract.md @@ -59,4 +59,6 @@ The config accepts the existing provider fields (`providerName`, `baseUrl`, `mod Calls run sequentially. Reports distinguish first-response validity, repairs, semantic contract errors, audit false accepts/rejects, token usage, and median/p95 latency. First-call latency is reported separately without assuming a cold server. Default results are ignored local artifacts under `test/llm/results-social-publication/`, with a Markdown report, per-case JSONL, and prompt/fixture hashes. The runner never invokes a browser action or publishes a post. +For X direct messages, a mounted but empty log is treated as loading rather than as a proven first-message conversation. Dispatch and completion require a settled, nonempty conversation tail before a later matching sent row can be accepted. + X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. An explicitly supplied body is required by the message-field classifier and is recovered from the approved task when a classifier response omits it, including route-local commands such as `Send Alex: Hello there`. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index fad5557ca..ca1c0acdf 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -5522,7 +5522,11 @@ export class Agent extends LoopDetector { ? currentIds.indexOf(priorIds[priorIds.length - 1]) : -1; exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) && Array.isArray(priorIds) && Array.isArray(currentIds) && Array.isArray(sentIds) - && (priorIds.length === 0 || anchorIndex >= 0) + // The content-side guard records only a settled X log with a + // concrete tail. Retain that invariant here as well so a malformed + // or older binding cannot turn a late-loaded historic row into + // evidence for this dispatch. + && priorIds.length > 0 && anchorIndex >= 0 && sentIds.some(id => !priorIds.includes(id) && currentIds.indexOf(id) > anchorIndex); sentStatusObserved = exactOutgoingBodyObserved && recipientObserved diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 5b46760ff..dbc561971 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -5048,8 +5048,12 @@ .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) : []; const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); - const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 - && new Set(twitterMessageIds).size === twitterMessageIds.length; + // An empty mounted log can mean X is still loading older history. Do + // not treat it as a proven first-message conversation: without a prior + // tail, an old matching row that appears after dispatch is + // indistinguishable from the message this run attempted to send. + const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length > 0 + && twitterRows.length <= 2000 && new Set(twitterMessageIds).size === twitterMessageIds.length; const matchingTwitterMessageIds = expectedBody => { const expected = normalizedMessageBody(expectedBody); if (!expected || !twitterBaselineComplete) return []; diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 8503f3fe6..06e1ba1e5 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -5240,7 +5240,11 @@ export class Agent extends LoopDetector { ? currentIds.indexOf(priorIds[priorIds.length - 1]) : -1; exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) && Array.isArray(priorIds) && Array.isArray(currentIds) && Array.isArray(sentIds) - && (priorIds.length === 0 || anchorIndex >= 0) + // The content-side guard records only a settled X log with a + // concrete tail. Retain that invariant here as well so a malformed + // or older binding cannot turn a late-loaded historic row into + // evidence for this dispatch. + && priorIds.length > 0 && anchorIndex >= 0 && sentIds.some(id => !priorIds.includes(id) && currentIds.indexOf(id) > anchorIndex); sentStatusObserved = exactOutgoingBodyObserved && recipientObserved diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index 52b15239d..7809d2a47 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -4220,8 +4220,12 @@ .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) : []; const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); - const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 - && new Set(twitterMessageIds).size === twitterMessageIds.length; + // An empty mounted log can mean X is still loading older history. Do + // not treat it as a proven first-message conversation: without a prior + // tail, an old matching row that appears after dispatch is + // indistinguishable from the message this run attempted to send. + const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length > 0 + && twitterRows.length <= 2000 && new Set(twitterMessageIds).size === twitterMessageIds.length; const matchingTwitterMessageIds = expectedBody => { const expected = normalizedMessageBody(expectedBody); if (!expected || !twitterBaselineComplete) return []; diff --git a/test/run.js b/test/run.js index c7c2ab388..fd7d38b7c 100644 --- a/test/run.js +++ b/test/run.js @@ -93935,7 +93935,7 @@ test('publication workflows classify and bind requested payload fields', async ( messageRecipientDispatchBinding: { token: `x-body-${body}` }, messageRecipientBody: body, messageRecipientBodyBaselineCount: 0, - messageRecipientExistingMessageIds: [], + messageRecipientExistingMessageIds: ['message-prior'], }); return agent._workflowTerminalEvidenceFromDone(xTabId, { liveRegionMessages: [] }, xUrl, { submit: { dispatched: true, observedAfterSubmit: true, originatingUrl: xUrl, workflowBinding }, @@ -93946,7 +93946,7 @@ test('publication workflows classify and bind requested payload fields', async ( conclusive: true, composerEmpty: true, strongRecipientCandidates: [{ identity: '@altryne', role: 'to' }], - existingMessageIds: ['message-new'], + existingMessageIds: ['message-prior', 'message-new'], matchingOutgoingMessageIds: ['message-new'], matchingOutgoingMessageCount: 1, }); @@ -93955,6 +93955,28 @@ test('publication workflows classify and bind requested payload fields', async ( `${AgentClass.name}: a sent X DM drifted from the approved body`); assert.equal(xTerminal('Hello there')?.verificationKind, 'message_sent', `${AgentClass.name}: the approved X DM body could not complete`); + const emptyBaselineBinding = agent._workflowSubmitBindingForAttempt(xTabId, xUrl, { + messageRecipientGuardRequired: true, + messageRecipientDispatchBinding: { token: 'x-empty-baseline' }, + messageRecipientBody: 'Hello there', + messageRecipientBodyBaselineCount: 0, + messageRecipientExistingMessageIds: [], + }); + const lateHistoricRow = agent._workflowTerminalEvidenceFromDone(xTabId, { liveRegionMessages: [] }, xUrl, { + submit: { dispatched: true, observedAfterSubmit: true, originatingUrl: xUrl, workflowBinding: emptyBaselineBinding }, + verifiedFinalSubmit: false, + relevantForms: 1, + }, { + success: true, + conclusive: true, + composerEmpty: true, + strongRecipientCandidates: [{ identity: '@altryne', role: 'to' }], + existingMessageIds: ['message-late-history'], + matchingOutgoingMessageIds: ['message-late-history'], + matchingOutgoingMessageCount: 1, + }); + assert.equal(lateHistoricRow, null, + `${AgentClass.name}: a late-loaded historic X row satisfied an empty dispatch baseline`); } }); From 154e715c252254fdfdcce78fb87e554471f09186 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 17:59:44 +0300 Subject: [PATCH 06/13] Scope message and public-post guards --- src/chrome/src/agent/agent.js | 28 +++++++++++++++++++++++----- src/firefox/src/agent/agent.js | 28 +++++++++++++++++++++++----- test/run.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index ca1c0acdf..2b9aa6e86 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -23194,7 +23194,24 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d supportsRecipientSets: policy.supportsRecipientSets === true, ...(target?.target_kind === 'named' ? { expectedRecipients: target.recipients } : {}), }); - if (probe?.success === true && probe?.conclusive === true && probe.messageSend === false) return null; + if (probe?.success === true && probe?.conclusive === true && probe.messageSend === false) { + const linkedInPublicPost = policy.adapterName === 'linkedin' && probe.publicPost === true; + const publishWorkflowAuthorized = guard?.siteWorkflow?.adapterName === 'linkedin' + && guard?.siteWorkflow?.job?.id === 'publish-post'; + // A public Post control is not a message send, but it is still a + // consequential action. Only the explicit LinkedIn publish workflow can + // bypass recipient dispatch binding for that control. + if (!linkedInPublicPost || publishWorkflowAuthorized) return null; + return { + success: false, + blocked: true, + noDispatch: true, + dispatched: false, + messageRecipientGuard: true, + reasonCode: 'public_post_not_authorized', + error: 'LinkedIn Post blocked because this task authorizes a private message, not a public post.', + }; + } // Gmail reply editors can be collapsed when planning begins. Allow only a // content-verified Reply/Reply all/Forward control to open the editor; // every other unresolved click remains blocked. Recipient authorization @@ -28240,10 +28257,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d : (sawNamedSocialPlatform ? [] : rawCandidates); let bestBody = ''; // A direct-message task can omit the platform when the active workflow - // already binds the X conversation ("Send Alex: Hello there"). Treat - // that imperative like the existing message/publish forms so an explicit - // body remains subject to the same exact dispatch verification. - const publishVerbPattern = `(?:${SOCIAL_PUBLISH_VERBS.source}|message|send|update)`; + // already binds the X conversation ("Send Alex: Hello there"). Keep that + // imperative local to X: other message workflows may use "send" while + // separately naming subject/body fields, which this post-body extractor + // must not reinterpret as one combined body. + const publishVerbPattern = `(?:${SOCIAL_PUBLISH_VERBS.source}|message${adapterName === 'twitter' ? '|send' : ''}|update)`; const quotedPattern = new RegExp(`${publishVerbPattern}[\\s\\S]*?(?:“([\\s\\S]+?)”|「([\\s\\S]+?)」|『([\\s\\S]+?)』|«([\\s\\S]+?)»|"([\\s\\S]+?)")`, 'iu'); const singleQuotePattern = new RegExp(`${publishVerbPattern}[\\s\\S]*?(?:(? Date: Sun, 20 Sep 2026 18:15:29 +0300 Subject: [PATCH 07/13] Accept settled empty X conversations --- docs/social-publication-contract.md | 2 +- src/chrome/src/agent/agent.js | 25 +++++++++---- src/chrome/src/content/content.js | 54 +++++++++++++++++++++++++---- src/firefox/src/agent/agent.js | 25 +++++++++---- src/firefox/src/content/content.js | 54 +++++++++++++++++++++++++---- test/run.js | 52 +++++++++++++++++++++++++-- 6 files changed, 181 insertions(+), 31 deletions(-) diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index 9284e5bce..24c912ef2 100644 --- a/docs/social-publication-contract.md +++ b/docs/social-publication-contract.md @@ -59,6 +59,6 @@ The config accepts the existing provider fields (`providerName`, `baseUrl`, `mod Calls run sequentially. Reports distinguish first-response validity, repairs, semantic contract errors, audit false accepts/rejects, token usage, and median/p95 latency. First-call latency is reported separately without assuming a cold server. Default results are ignored local artifacts under `test/llm/results-social-publication/`, with a Markdown report, per-case JSONL, and prompt/fixture hashes. The runner never invokes a browser action or publishes a post. -For X direct messages, a mounted but empty log is treated as loading rather than as a proven first-message conversation. Dispatch and completion require a settled, nonempty conversation tail before a later matching sent row can be accepted. +For X direct messages, a mounted empty log is treated as loading until the same non-loading log remains empty across repeated observations. That establishes a first-message baseline; completion accepts exactly one resulting matching row and rejects history that appears after dispatch. X direct messages use their own message workflow, not the public publication contract. On `/i/chat/`, the conversation header binds the recipient. An exact display-name or bare-username match resolves to that header's canonical handle during planning or before dispatch; subsequent verification requires the same handle, never a replacement account with the same display name. A send binds the exact body and the identities of all mounted message rows, including hidden and pending ones. An explicitly supplied body is required by the message-field classifier and is recovered from the approved task when a classifier response omits it, including route-local commands such as `Send Alex: Hello there`. Completion requires a new matching outgoing row after the prior conversation tail with `data-send-status="sent"`, an empty composer, and the same conversation. The open X chat composer does not override that delivery evidence; other adapters retain their dialog checks. Workflow field requirements are loaded even when repeated-item tracking is disabled. Incoming, pending, failed, or pre-existing messages cannot satisfy it. diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 2b9aa6e86..d8255ef42 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -2157,7 +2157,13 @@ export class Agent extends LoopDetector { messageBodyBaselineCount, ...(siteWorkflow.adapterName === 'twitter' && Array.isArray(executionContext.messageRecipientExistingMessageIds) - ? { preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds] } + ? { + preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds], + ...(executionContext.messageRecipientTwitterEmptyConversationBaseline === true + && executionContext.messageRecipientExistingMessageIds.length === 0 + ? { preDispatchEmptyConversationBaseline: true } + : {}), + } : {}), ...(siteWorkflow.adapterName === 'gmail' && executionContext?.messageRecipientGmailComposeFlow === true @@ -5520,14 +5526,16 @@ export class Agent extends LoopDetector { const sentIds = messageProbe?.matchingOutgoingMessageIds; const anchorIndex = Array.isArray(priorIds) && priorIds.length > 0 && Array.isArray(currentIds) ? currentIds.indexOf(priorIds[priorIds.length - 1]) : -1; + const settledEmptyBaseline = binding.preDispatchEmptyConversationBaseline === true + && Array.isArray(priorIds) && priorIds.length === 0; + const baselineStillValid = Array.isArray(priorIds) && Array.isArray(currentIds) + && ((priorIds.length > 0 && anchorIndex >= 0) + || (settledEmptyBaseline && currentIds.length === 1)); exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) && Array.isArray(priorIds) && Array.isArray(currentIds) && Array.isArray(sentIds) - // The content-side guard records only a settled X log with a - // concrete tail. Retain that invariant here as well so a malformed - // or older binding cannot turn a late-loaded historic row into - // evidence for this dispatch. - && priorIds.length > 0 && anchorIndex >= 0 - && sentIds.some(id => !priorIds.includes(id) && currentIds.indexOf(id) > anchorIndex); + && baselineStillValid + && sentIds.some(id => !priorIds.includes(id) + && currentIds.indexOf(id) > (settledEmptyBaseline ? -1 : anchorIndex)); sentStatusObserved = exactOutgoingBodyObserved && recipientObserved && this._normalizeUrl(pageUrl) === this._normalizeUrl(submit?.originatingUrl || ''); @@ -23274,6 +23282,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d executionContext.messageRecipientBodyBaselineCount = messageBodyBaselineCount; if (policy.adapterName === 'twitter' && Array.isArray(probe.existingMessageIds)) { executionContext.messageRecipientExistingMessageIds = [...probe.existingMessageIds]; + if (probe.twitterEmptyConversationBaseline === true) { + executionContext.messageRecipientTwitterEmptyConversationBaseline = true; + } } if (probe.composerSubjectAvailable === true) { executionContext.messageRecipientSubject = this._workflowMetadataValue(probe.composerSubject); diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index dbc561971..bf35a805c 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -456,6 +456,9 @@ function _hasVisibleBox(el, minWidth = 1, minHeight = 1) { if (!el || typeof el.getBoundingClientRect !== 'function') return false; try { + for (let ancestor = el; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; + } const r = el.getBoundingClientRect(); if (r.width < minWidth || r.height < minHeight) return false; const s = getComputedStyle(el); @@ -4413,6 +4416,30 @@ } const _messageRecipientDispatchBindings = new Map(); + const _twitterEmptyLogObservations = new WeakMap(); + + function _settledEmptyTwitterLog(log) { + if (!log?.isConnected) return false; + let loading = false; + try { + loading = log.getAttribute('aria-busy') === 'true' + || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); + } catch {} + if (loading) { + _twitterEmptyLogObservations.delete(log); + return false; + } + const now = Date.now(); + const prior = _twitterEmptyLogObservations.get(log); + const observation = prior + ? { firstSeenAt: prior.firstSeenAt, count: prior.count + 1 } + : { firstSeenAt: now, count: 1 }; + _twitterEmptyLogObservations.set(log, observation); + // A first-message conversation has no historic tail to pin. Require the + // same empty log to survive two non-loading reads before treating that + // exceptional baseline as settled. + return observation.count >= 2 && now - observation.firstSeenAt >= 300; + } function _messageRecipientIdentityKey(values = []) { return JSON.stringify(Array.from(new Set((Array.isArray(values) ? values : []) @@ -4467,6 +4494,7 @@ messageBody: String(dispatch.messageBody || ''), messageBodyBaselineCount: Number(dispatch.messageBodyBaselineCount || 0), ...(Array.isArray(dispatch.existingMessageIds) ? { existingMessageIds: [...dispatch.existingMessageIds] } : {}), + twitterEmptyConversationBaseline: dispatch.twitterEmptyConversationBaseline === true, gmailComposeFlow: dispatch.gmailComposeFlow === true, composerSubject: String(dispatch.composerSubject || ''), composerSubjectAvailable: dispatch.composerSubjectAvailable === true, @@ -4537,6 +4565,8 @@ || live?.messageBody !== expected.messageBody || (expected.existingMessageIds && JSON.stringify(live?.existingMessageIds) !== JSON.stringify(expected.existingMessageIds)) + || (expected.twitterEmptyConversationBaseline === true + && live?.twitterEmptyConversationBaseline !== true) || (expected.gmailComposeFlow === true && live?.gmailComposeFlow !== true) || (expected.composerSubjectAvailable === true && (live?.composerSubjectAvailable !== true || live?.composerSubject !== expected.composerSubject))) { @@ -4943,6 +4973,9 @@ const visible = (el) => { if (!el || el.nodeType !== 1 || !el.isConnected) return false; try { + for (let ancestor = el; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; + } const style = getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.display !== 'none' @@ -5048,12 +5081,15 @@ .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) : []; const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); - // An empty mounted log can mean X is still loading older history. Do - // not treat it as a proven first-message conversation: without a prior - // tail, an old matching row that appears after dispatch is - // indistinguishable from the message this run attempted to send. - const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length > 0 - && twitterRows.length <= 2000 && new Set(twitterMessageIds).size === twitterMessageIds.length; + const twitterEmptyConversationBaseline = twitterLogs.length === 1 && twitterRows.length === 0 + && _settledEmptyTwitterLog(twitterLogs[0]); + if (twitterRows.length > 0 && twitterLogs[0]) _twitterEmptyLogObservations.delete(twitterLogs[0]); + // An empty mounted log can mean X is still loading older history. A + // first-message conversation is eligible only after the same non-loading + // empty log has settled across repeated observations. + const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 + && new Set(twitterMessageIds).size === twitterMessageIds.length + && (twitterRows.length > 0 || twitterEmptyConversationBaseline); const matchingTwitterMessageIds = expectedBody => { const expected = normalizedMessageBody(expectedBody); if (!expected || !twitterBaselineComplete) return []; @@ -5840,7 +5876,10 @@ supportsRecipientSets: params.supportsRecipientSets, messageBody, messageBodyBaselineCount, - ...(twitterConversation ? { existingMessageIds: twitterMessageIds } : {}), + ...(twitterConversation ? { + existingMessageIds: twitterMessageIds, + ...(twitterEmptyConversationBaseline ? { twitterEmptyConversationBaseline: true } : {}), + } : {}), gmailComposeFlow, composerSubject, composerSubjectAvailable, @@ -5867,6 +5906,7 @@ ...(twitterConversation && twitterBaselineComplete ? { existingMessageIds: twitterMessageIds, matchingOutgoingMessageIds: matchingTwitterMessageIds(params.expectedMessageBody), + ...(twitterEmptyConversationBaseline ? { twitterEmptyConversationBaseline: true } : {}), } : {}), // Only recipient-specific header evidence is authoritative. Ordinary // message text, test-id containers, and other leaf content are never diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 037402210..ed75da39d 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1875,7 +1875,13 @@ export class Agent extends LoopDetector { messageBodyBaselineCount, ...(siteWorkflow.adapterName === 'twitter' && Array.isArray(executionContext.messageRecipientExistingMessageIds) - ? { preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds] } + ? { + preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds], + ...(executionContext.messageRecipientTwitterEmptyConversationBaseline === true + && executionContext.messageRecipientExistingMessageIds.length === 0 + ? { preDispatchEmptyConversationBaseline: true } + : {}), + } : {}), ...(siteWorkflow.adapterName === 'gmail' && executionContext?.messageRecipientGmailComposeFlow === true @@ -5238,14 +5244,16 @@ export class Agent extends LoopDetector { const sentIds = messageProbe?.matchingOutgoingMessageIds; const anchorIndex = Array.isArray(priorIds) && priorIds.length > 0 && Array.isArray(currentIds) ? currentIds.indexOf(priorIds[priorIds.length - 1]) : -1; + const settledEmptyBaseline = binding.preDispatchEmptyConversationBaseline === true + && Array.isArray(priorIds) && priorIds.length === 0; + const baselineStillValid = Array.isArray(priorIds) && Array.isArray(currentIds) + && ((priorIds.length > 0 && anchorIndex >= 0) + || (settledEmptyBaseline && currentIds.length === 1)); exactOutgoingBodyObserved = !!this._workflowMessageBody(binding.messageBody) && Array.isArray(priorIds) && Array.isArray(currentIds) && Array.isArray(sentIds) - // The content-side guard records only a settled X log with a - // concrete tail. Retain that invariant here as well so a malformed - // or older binding cannot turn a late-loaded historic row into - // evidence for this dispatch. - && priorIds.length > 0 && anchorIndex >= 0 - && sentIds.some(id => !priorIds.includes(id) && currentIds.indexOf(id) > anchorIndex); + && baselineStillValid + && sentIds.some(id => !priorIds.includes(id) + && currentIds.indexOf(id) > (settledEmptyBaseline ? -1 : anchorIndex)); sentStatusObserved = exactOutgoingBodyObserved && recipientObserved && this._normalizeUrl(pageUrl) === this._normalizeUrl(submit?.originatingUrl || ''); @@ -21033,6 +21041,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d executionContext.messageRecipientBodyBaselineCount = messageBodyBaselineCount; if (policy.adapterName === 'twitter' && Array.isArray(probe.existingMessageIds)) { executionContext.messageRecipientExistingMessageIds = [...probe.existingMessageIds]; + if (probe.twitterEmptyConversationBaseline === true) { + executionContext.messageRecipientTwitterEmptyConversationBaseline = true; + } } if (probe.composerSubjectAvailable === true) { executionContext.messageRecipientSubject = this._workflowMetadataValue(probe.composerSubject); diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index 7809d2a47..e8c01829c 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -667,6 +667,9 @@ function _hasVisibleBox(el, minWidth = 1, minHeight = 1) { if (!el || typeof el.getBoundingClientRect !== 'function') return false; try { + for (let ancestor = el; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; + } const r = el.getBoundingClientRect(); if (r.width < minWidth || r.height < minHeight) return false; const s = getComputedStyle(el); @@ -3725,6 +3728,30 @@ } const _messageRecipientDispatchBindings = new Map(); + const _twitterEmptyLogObservations = new WeakMap(); + + function _settledEmptyTwitterLog(log) { + if (!log?.isConnected) return false; + let loading = false; + try { + loading = log.getAttribute('aria-busy') === 'true' + || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); + } catch {} + if (loading) { + _twitterEmptyLogObservations.delete(log); + return false; + } + const now = Date.now(); + const prior = _twitterEmptyLogObservations.get(log); + const observation = prior + ? { firstSeenAt: prior.firstSeenAt, count: prior.count + 1 } + : { firstSeenAt: now, count: 1 }; + _twitterEmptyLogObservations.set(log, observation); + // A first-message conversation has no historic tail to pin. Require the + // same empty log to survive two non-loading reads before treating that + // exceptional baseline as settled. + return observation.count >= 2 && now - observation.firstSeenAt >= 300; + } function _messageRecipientIdentityKey(values = []) { return JSON.stringify(Array.from(new Set((Array.isArray(values) ? values : []) @@ -3779,6 +3806,7 @@ messageBody: String(dispatch.messageBody || ''), messageBodyBaselineCount: Number(dispatch.messageBodyBaselineCount || 0), ...(Array.isArray(dispatch.existingMessageIds) ? { existingMessageIds: [...dispatch.existingMessageIds] } : {}), + twitterEmptyConversationBaseline: dispatch.twitterEmptyConversationBaseline === true, gmailComposeFlow: dispatch.gmailComposeFlow === true, composerSubject: String(dispatch.composerSubject || ''), composerSubjectAvailable: dispatch.composerSubjectAvailable === true, @@ -3849,6 +3877,8 @@ || live?.messageBody !== expected.messageBody || (expected.existingMessageIds && JSON.stringify(live?.existingMessageIds) !== JSON.stringify(expected.existingMessageIds)) + || (expected.twitterEmptyConversationBaseline === true + && live?.twitterEmptyConversationBaseline !== true) || (expected.gmailComposeFlow === true && live?.gmailComposeFlow !== true) || (expected.composerSubjectAvailable === true && (live?.composerSubjectAvailable !== true || live?.composerSubject !== expected.composerSubject))) { @@ -4115,6 +4145,9 @@ const visible = (el) => { if (!el || el.nodeType !== 1 || !el.isConnected) return false; try { + for (let ancestor = el; ancestor; ancestor = ancestor.parentElement) { + if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; + } const style = getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.display !== 'none' @@ -4220,12 +4253,15 @@ .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) : []; const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); - // An empty mounted log can mean X is still loading older history. Do - // not treat it as a proven first-message conversation: without a prior - // tail, an old matching row that appears after dispatch is - // indistinguishable from the message this run attempted to send. - const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length > 0 - && twitterRows.length <= 2000 && new Set(twitterMessageIds).size === twitterMessageIds.length; + const twitterEmptyConversationBaseline = twitterLogs.length === 1 && twitterRows.length === 0 + && _settledEmptyTwitterLog(twitterLogs[0]); + if (twitterRows.length > 0 && twitterLogs[0]) _twitterEmptyLogObservations.delete(twitterLogs[0]); + // An empty mounted log can mean X is still loading older history. A + // first-message conversation is eligible only after the same non-loading + // empty log has settled across repeated observations. + const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 + && new Set(twitterMessageIds).size === twitterMessageIds.length + && (twitterRows.length > 0 || twitterEmptyConversationBaseline); const matchingTwitterMessageIds = expectedBody => { const expected = normalizedMessageBody(expectedBody); if (!expected || !twitterBaselineComplete) return []; @@ -5012,7 +5048,10 @@ supportsRecipientSets: params.supportsRecipientSets, messageBody, messageBodyBaselineCount, - ...(twitterConversation ? { existingMessageIds: twitterMessageIds } : {}), + ...(twitterConversation ? { + existingMessageIds: twitterMessageIds, + ...(twitterEmptyConversationBaseline ? { twitterEmptyConversationBaseline: true } : {}), + } : {}), gmailComposeFlow, composerSubject, composerSubjectAvailable, @@ -5039,6 +5078,7 @@ ...(twitterConversation && twitterBaselineComplete ? { existingMessageIds: twitterMessageIds, matchingOutgoingMessageIds: matchingTwitterMessageIds(params.expectedMessageBody), + ...(twitterEmptyConversationBaseline ? { twitterEmptyConversationBaseline: true } : {}), } : {}), // Only recipient-specific header evidence is authoritative. Ordinary // message text, test-id containers, and other leaf content are never diff --git a/test/run.js b/test/run.js index 2479a1bf6..fc7244b6b 100644 --- a/test/run.js +++ b/test/run.js @@ -7017,6 +7017,13 @@ test('direct-message recipient probe accepts only a unique active-thread header left: 910, right: 980, top: 700, bottom: 750, width: 70, height: 50, }, { tagName: 'BUTTON', role: 'button' }); sendButton.closest = () => sendButton; + const hiddenSendWrapper = element('', { + left: 900, right: 990, top: 680, bottom: 760, width: 90, height: 80, + }, { attributes: { 'aria-hidden': 'true' } }); + const hiddenSendButton = element('Send', { + left: 910, right: 980, top: 700, bottom: 750, width: 70, height: 50, + }, { tagName: 'BUTTON', role: 'button', parentElement: hiddenSendWrapper }); + hiddenSendButton.closest = () => hiddenSendButton; const customSendControl = element('Quick send', { left: 910, right: 990, top: 755, bottom: 795, width: 80, height: 40, }, { dataAction: true }); @@ -7063,7 +7070,7 @@ test('direct-message recipient probe accepts only a unique active-thread header querySelector: (selector) => selector === '#conversation-row' ? conversationRow : null, querySelectorAll: (selector) => { if (selector === 'textarea,[contenteditable="true"],[role="textbox"]') return [composer, searchBox, alternateComposer]; - if (selector.startsWith('a, button,')) return [sendButton, customSendControl, distantControl, conversationRowMenu]; + if (selector.startsWith('a, button,')) return [sendButton, hiddenSendButton, customSendControl, distantControl, conversationRowMenu]; if (selector.startsWith('[aria-selected')) return []; if (selector.startsWith('h1,')) return [searchedName, activeHeader, conversationMessageHeading]; if (selector.startsWith('[data-testid')) return []; @@ -7358,7 +7365,8 @@ test('direct-message recipient probe accepts only a unique active-thread header assert.equal(alternateComposerEnterResult.conclusive, false); assert.equal(alternateComposerSubmitResult.messageSend, null, `${prefix}: alternate composer submit bypassed recipient verification`); assert.equal(alternateComposerSubmitResult.conclusive, false); - assert.equal(unfocusedClickResult.messageSend, true, `${prefix}: unfocused composer made send click fail open`); + assert.equal(unfocusedClickResult.messageSend, true, + `${prefix}: aria-hidden duplicate Send control made the visible target ambiguous`); assert.equal(unfocusedClickResult.conclusive, true); assert.equal(emptyComposerCustomSendResult.messageSend, true, `${prefix}: custom attachment/send control failed open`); assert.equal(emptyComposerCustomSendResult.conclusive, true); @@ -94005,6 +94013,46 @@ test('publication workflows classify and bind requested payload fields', async ( }); assert.equal(lateHistoricRow, null, `${AgentClass.name}: a late-loaded historic X row satisfied an empty dispatch baseline`); + const settledEmptyBaselineBinding = agent._workflowSubmitBindingForAttempt(xTabId, xUrl, { + messageRecipientGuardRequired: true, + messageRecipientDispatchBinding: { token: 'x-settled-empty-baseline' }, + messageRecipientBody: 'Hello there', + messageRecipientBodyBaselineCount: 0, + messageRecipientExistingMessageIds: [], + messageRecipientTwitterEmptyConversationBaseline: true, + }); + const firstXMessage = agent._workflowTerminalEvidenceFromDone(xTabId, { liveRegionMessages: [] }, xUrl, { + submit: { dispatched: true, observedAfterSubmit: true, originatingUrl: xUrl, workflowBinding: settledEmptyBaselineBinding }, + verifiedFinalSubmit: false, + relevantForms: 1, + }, { + success: true, + conclusive: true, + composerEmpty: true, + strongRecipientCandidates: [{ identity: '@altryne', role: 'to' }], + existingMessageIds: ['message-first'], + matchingOutgoingMessageIds: ['message-first'], + matchingOutgoingMessageCount: 1, + }); + assert.equal(firstXMessage?.verificationKind, 'message_sent', + `${AgentClass.name}: a settled empty X conversation could not complete its first DM`); + const historyAfterSettledEmptyBaseline = agent._workflowTerminalEvidenceFromDone( + xTabId, { liveRegionMessages: [] }, xUrl, { + submit: { dispatched: true, observedAfterSubmit: true, originatingUrl: xUrl, workflowBinding: settledEmptyBaselineBinding }, + verifiedFinalSubmit: false, + relevantForms: 1, + }, { + success: true, + conclusive: true, + composerEmpty: true, + strongRecipientCandidates: [{ identity: '@altryne', role: 'to' }], + existingMessageIds: ['message-late-history', 'message-first'], + matchingOutgoingMessageIds: ['message-first'], + matchingOutgoingMessageCount: 1, + }, + ); + assert.equal(historyAfterSettledEmptyBaseline, null, + `${AgentClass.name}: late history satisfied a settled empty X dispatch baseline`); } }); From 7dafd0479e435c167c24be0149143f329e0a88b4 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 18:24:11 +0300 Subject: [PATCH 08/13] Recognize localized LinkedIn post controls --- src/chrome/src/content/content.js | 8 ++----- src/firefox/src/content/content.js | 8 ++----- test/run.js | 34 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index bf35a805c..e269fe378 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -5437,9 +5437,7 @@ const messageScope = 'form,[role="log"],[data-message-id],[data-thread-id],[data-conversation-id],' + '.msg-form,.msg-overlay-conversation-bubble,.msg-convo-wrapper'; if (_composedClosestElement(button, messageScope)) return false; - const labels = [button.innerText || button.textContent, button.getAttribute?.('aria-label')] - .map(value => compact(value).toLowerCase()).filter(Boolean); - if (!labels.length || !labels.every(label => label === 'post')) return false; + if (compact(button.getAttribute?.('data-control-name')).toLowerCase() !== 'share.post') return false; // Identify the public composer itself, not merely a nearby textbox. // LinkedIn's dedicated compose route may render as a whole page. const root = _composedClosestElement(button, 'dialog,[role="dialog"],.share-box') @@ -5450,9 +5448,7 @@ && (_composedClosestElement(el, 'dialog,[role="dialog"],.share-box') || root) === root; const editors = Array.from(root.querySelectorAll('[contenteditable="true"],textarea')) .filter(owned); - const audience = Array.from(root.querySelectorAll('button,[role="button"]')).some(el => owned(el) - && /^post to (?:anyone|connections(?: only)?)$/i.test(compact(el.getAttribute('aria-label') || el.innerText || el.textContent))); - return editors.length === 1 && audience; + return editors.length === 1; }; let composer = null; diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index e8c01829c..f1f627672 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -4609,9 +4609,7 @@ const messageScope = 'form,[role="log"],[data-message-id],[data-thread-id],[data-conversation-id],' + '.msg-form,.msg-overlay-conversation-bubble,.msg-convo-wrapper'; if (_composedClosestElement(button, messageScope)) return false; - const labels = [button.innerText || button.textContent, button.getAttribute?.('aria-label')] - .map(value => compact(value).toLowerCase()).filter(Boolean); - if (!labels.length || !labels.every(label => label === 'post')) return false; + if (compact(button.getAttribute?.('data-control-name')).toLowerCase() !== 'share.post') return false; // Identify the public composer itself, not merely a nearby textbox. // LinkedIn's dedicated compose route may render as a whole page. const root = _composedClosestElement(button, 'dialog,[role="dialog"],.share-box') @@ -4622,9 +4620,7 @@ && (_composedClosestElement(el, 'dialog,[role="dialog"],.share-box') || root) === root; const editors = Array.from(root.querySelectorAll('[contenteditable="true"],textarea')) .filter(owned); - const audience = Array.from(root.querySelectorAll('button,[role="button"]')).some(el => owned(el) - && /^post to (?:anyone|connections(?: only)?)$/i.test(compact(el.getAttribute('aria-label') || el.innerText || el.textContent))); - return editors.length === 1 && audience; + return editors.length === 1; }; let composer = null; diff --git a/test/run.js b/test/run.js index fc7244b6b..a887e5361 100644 --- a/test/run.js +++ b/test/run.js @@ -7024,6 +7024,23 @@ test('direct-message recipient probe accepts only a unique active-thread header left: 910, right: 980, top: 700, bottom: 750, width: 70, height: 50, }, { tagName: 'BUTTON', role: 'button', parentElement: hiddenSendWrapper }); hiddenSendButton.closest = () => hiddenSendButton; + const linkedInComposer = element('', { + left: 260, right: 780, top: 120, bottom: 680, width: 520, height: 560, + }, { role: 'dialog' }); + const linkedInEditor = element('', { + left: 290, right: 750, top: 260, bottom: 500, width: 460, height: 240, + }, { parentElement: linkedInComposer }); + linkedInEditor.isContentEditable = true; + const localizedLinkedInPost = element('Gönder', { + left: 650, right: 750, top: 610, bottom: 660, width: 100, height: 50, + }, { + tagName: 'BUTTON', role: 'button', parentElement: linkedInComposer, + attributes: { 'data-control-name': 'share.post' }, + }); + localizedLinkedInPost.closest = () => localizedLinkedInPost; + linkedInComposer.querySelectorAll = (selector) => selector === '[contenteditable="true"],textarea' + ? [linkedInEditor] + : []; const customSendControl = element('Quick send', { left: 910, right: 990, top: 755, bottom: 795, width: 80, height: 40, }, { dataAction: true }); @@ -7088,15 +7105,23 @@ test('direct-message recipient probe accepts only a unique active-thread header if (refId === 'conversation-row-label') return conversationRowLabel; if (refId === 'conversation-row-menu-leaf') return conversationRowMenuLeaf; if (refId === 'alternate-composer') return alternateComposer; + if (refId === 'localized-linkedin-post') return localizedLinkedInPost; return null; }, }, + location: { pathname: '/sharing/compose/', href: 'https://www.linkedin.com/sharing/compose/' }, getComputedStyle: (el) => ({ display: 'block', visibility: 'visible', overflowY: el === conversationRail ? 'auto' : 'visible', }), _deepActiveElement: () => activeElement, + _composedClosestElement: (node, selector) => { + if (node === localizedLinkedInPost && /button|\[role="button"\]/.test(selector)) return node; + if ((node === localizedLinkedInPost || node === linkedInEditor) + && /dialog|\[role="dialog"\]|\.share-box/.test(selector)) return linkedInComposer; + return null; + }, }; const candidatesStart = source.indexOf(' function _clickTextCandidates('); const candidatesEnd = source.indexOf('\n\n let _lastClickIdent', candidatesStart); @@ -7153,6 +7178,9 @@ test('direct-message recipient probe accepts only a unique active-thread header const conversationMenuResult = probe({ tool: 'click', args: { text: 'More' } }); const conversationMenuLeafResult = probe({ tool: 'click_ax', args: { ref_id: 'conversation-row-menu-leaf' } }); const unresolvedClickResult = probe({ tool: 'click', args: { text: 'Sen', textMatch: 'exact' } }); + const localizedLinkedInPostResult = probe({ + tool: 'click_ax', args: { ref_id: 'localized-linkedin-post' }, adapterName: 'linkedin', + }); activeElement = composer; const gmailAliceChip = element('Alice', { left: 430, right: 620, top: 610, bottom: 650, width: 190, height: 40, @@ -7284,6 +7312,7 @@ test('direct-message recipient probe accepts only a unique active-thread header conversationMenuResult, conversationMenuLeafResult, unresolvedClickResult, + localizedLinkedInPostResult, emptyComposerCustomSendResult, gmailMatchingRecipientResult, gmailMatchingNameResult, @@ -7321,6 +7350,7 @@ test('direct-message recipient probe accepts only a unique active-thread header conversationMenuResult, conversationMenuLeafResult, unresolvedClickResult, + localizedLinkedInPostResult, emptyComposerCustomSendResult, gmailMatchingRecipientResult, gmailMatchingNameResult, @@ -7384,6 +7414,10 @@ test('direct-message recipient probe accepts only a unique active-thread header assert.equal(conversationMenuLeafResult.conclusive, false); assert.equal(unresolvedClickResult.messageSend, null, `${prefix}: unresolved click target was declared safe`); assert.equal(unresolvedClickResult.conclusive, false); + assert.equal(localizedLinkedInPostResult.messageSend, false, + `${prefix}: localized LinkedIn public post was treated as a direct message`); + assert.equal(localizedLinkedInPostResult.publicPost, true, + `${prefix}: LinkedIn's app-owned publish control was not recognized without English labels`); assert.deepEqual(Array.from(gmailMatchingRecipientResult.strongIdentityCandidates), ['alice@example.com']); assert.equal(gmailMatchingRecipientResult.gmailComposeFlow, true, `${prefix}: Gmail compose dialog was not bound to the send probe`); From a0e871f6a73c18308c061735e4ae5a54a0fb8779 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 18:36:23 +0300 Subject: [PATCH 09/13] Harden message metadata and X history proofs --- src/chrome/src/agent/agent.js | 18 +++++- src/chrome/src/content/content.js | 15 +++-- src/firefox/src/agent/agent.js | 18 +++++- src/firefox/src/content/content.js | 15 +++-- test/run.js | 91 ++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index d8255ef42..9c3256868 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -28497,7 +28497,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const details = this._normalizeWorkflowMetadataRequirementsDetails( obj?.workflowFields ?? obj?.workflow_fields, ); - const extractedBody = this._extractWorkflowTaskBody(taskText, approvedPlanText, siteWorkflow?.adapterName); + // The concise "Send Alex: ..." recovery is deliberately limited to + // X DMs. Other messaging adapters have structured fields (for + // example, Gmail subject/body), which a generic Send parser must not + // replace or invent. + const recoverTwitterMessageBody = siteWorkflow?.adapterName === 'twitter' + && siteWorkflow?.job?.template === 'message' + && siteWorkflow?.job?.id === 'send-message'; + const extractedBody = recoverTwitterMessageBody + ? this._extractWorkflowTaskBody(taskText, approvedPlanText, 'twitter') + : ''; if (extractedBody) { const bodyReq = details.items.find(r => r.field === 'body'); if (bodyReq) { @@ -28541,7 +28550,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && (siteWorkflow?.job?.template !== 'publish' || !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName))) { const guard = this._planExecutionGuards.get(tabId); if (guard && guard.workflowMetadataRequirementsResolved !== true) { - const extractedBody = this._extractWorkflowTaskBody(taskText, approvedPlanText, siteWorkflow?.adapterName); + const recoverTwitterMessageBody = siteWorkflow?.adapterName === 'twitter' + && siteWorkflow?.job?.template === 'message' + && siteWorkflow?.job?.id === 'send-message'; + const extractedBody = recoverTwitterMessageBody + ? this._extractWorkflowTaskBody(taskText, approvedPlanText, 'twitter') + : ''; if (extractedBody) { guard.workflowMetadataRequirements = [{ field: 'body', diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index e269fe378..238b73b33 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -4421,11 +4421,18 @@ function _settledEmptyTwitterLog(log) { if (!log?.isConnected) return false; let loading = false; + let historyComplete = false; try { - loading = log.getAttribute('aria-busy') === 'true' + const ariaBusy = log.getAttribute('aria-busy'); + loading = ariaBusy === 'true' || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); + // An empty log alone is ambiguous: history may still mount after the + // submit. Require X to expose either its finished busy state or its + // app-owned empty-state marker before pinning a first-message baseline. + historyComplete = ariaBusy === 'false' + || !!log.querySelector('[data-testid="dm-empty-state"],[data-testid="empty_state"],[data-testid="empty-state"]'); } catch {} - if (loading) { + if (loading || !historyComplete) { _twitterEmptyLogObservations.delete(log); return false; } @@ -4436,8 +4443,8 @@ : { firstSeenAt: now, count: 1 }; _twitterEmptyLogObservations.set(log, observation); // A first-message conversation has no historic tail to pin. Require the - // same empty log to survive two non-loading reads before treating that - // exceptional baseline as settled. + // same positively-complete empty log to survive two reads before treating + // that exceptional baseline as settled. return observation.count >= 2 && now - observation.firstSeenAt >= 300; } diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index ed75da39d..9d488d39f 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -26111,7 +26111,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const details = this._normalizeWorkflowMetadataRequirementsDetails( obj?.workflowFields ?? obj?.workflow_fields, ); - const extractedBody = this._extractWorkflowTaskBody(taskText, approvedPlanText, siteWorkflow?.adapterName); + // The concise "Send Alex: ..." recovery is deliberately limited to + // X DMs. Other messaging adapters have structured fields (for + // example, Gmail subject/body), which a generic Send parser must not + // replace or invent. + const recoverTwitterMessageBody = siteWorkflow?.adapterName === 'twitter' + && siteWorkflow?.job?.template === 'message' + && siteWorkflow?.job?.id === 'send-message'; + const extractedBody = recoverTwitterMessageBody + ? this._extractWorkflowTaskBody(taskText, approvedPlanText, 'twitter') + : ''; if (extractedBody) { const bodyReq = details.items.find(r => r.field === 'body'); if (bodyReq) { @@ -26155,7 +26164,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && (siteWorkflow?.job?.template !== 'publish' || !SOCIAL_PLATFORMS.includes(siteWorkflow?.adapterName))) { const guard = this._planExecutionGuards.get(tabId); if (guard && guard.workflowMetadataRequirementsResolved !== true) { - const extractedBody = this._extractWorkflowTaskBody(taskText, approvedPlanText, siteWorkflow?.adapterName); + const recoverTwitterMessageBody = siteWorkflow?.adapterName === 'twitter' + && siteWorkflow?.job?.template === 'message' + && siteWorkflow?.job?.id === 'send-message'; + const extractedBody = recoverTwitterMessageBody + ? this._extractWorkflowTaskBody(taskText, approvedPlanText, 'twitter') + : ''; if (extractedBody) { guard.workflowMetadataRequirements = [{ field: 'body', diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index f1f627672..a7243b34b 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -3733,11 +3733,18 @@ function _settledEmptyTwitterLog(log) { if (!log?.isConnected) return false; let loading = false; + let historyComplete = false; try { - loading = log.getAttribute('aria-busy') === 'true' + const ariaBusy = log.getAttribute('aria-busy'); + loading = ariaBusy === 'true' || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); + // An empty log alone is ambiguous: history may still mount after the + // submit. Require X to expose either its finished busy state or its + // app-owned empty-state marker before pinning a first-message baseline. + historyComplete = ariaBusy === 'false' + || !!log.querySelector('[data-testid="dm-empty-state"],[data-testid="empty_state"],[data-testid="empty-state"]'); } catch {} - if (loading) { + if (loading || !historyComplete) { _twitterEmptyLogObservations.delete(log); return false; } @@ -3748,8 +3755,8 @@ : { firstSeenAt: now, count: 1 }; _twitterEmptyLogObservations.set(log, observation); // A first-message conversation has no historic tail to pin. Require the - // same empty log to survive two non-loading reads before treating that - // exceptional baseline as settled. + // same positively-complete empty log to survive two reads before treating + // that exceptional baseline as settled. return observation.count >= 2 && now - observation.firstSeenAt >= 300; } diff --git a/test/run.js b/test/run.js index a887e5361..0dbeda523 100644 --- a/test/run.js +++ b/test/run.js @@ -7890,6 +7890,55 @@ test('message recipient dispatch binding detects composer and active-thread race } }); +test('empty X history baselines require a positive completion signal', () => { + for (const [label, rel] of [ + ['chrome', 'src/chrome/src/content/content.js'], + ['firefox', 'src/firefox/src/content/content.js'], + ]) { + const source = fs.readFileSync(path.join(ROOT, rel), 'utf8'); + const start = source.indexOf('const _messageRecipientDispatchBindings = new Map();'); + const end = source.indexOf(label === 'chrome' + ? '\n\n // Above this length' + : '\n\n function _releaseDispatchBinding', start); + assert.ok(start >= 0 && end > start, `${label}: empty-X helper should remain independently testable`); + let now = 0; + const { settledEmptyTwitterLog } = vm.runInNewContext(`(() => { + ${source.slice(start, end)} + return { settledEmptyTwitterLog: _settledEmptyTwitterLog }; + })()`, { + Date: { now: () => now }, + }); + const log = ({ ariaBusy = null, emptyState = false, loading = false } = {}) => ({ + isConnected: true, + getAttribute: name => name === 'aria-busy' ? ariaBusy : null, + querySelector: selector => { + if (selector === '[aria-busy="true"],[role="progressbar"]') return loading ? {} : null; + return emptyState ? {} : null; + }, + }); + const ambiguousEmptyLog = log(); + assert.equal(settledEmptyTwitterLog(ambiguousEmptyLog), false, + `${label}: a bare empty X log was accepted before history completion`); + now += 300; + assert.equal(settledEmptyTwitterLog(ambiguousEmptyLog), false, + `${label}: a bare empty X log became a first-message baseline`); + + const completedEmptyLog = log({ ariaBusy: 'false' }); + assert.equal(settledEmptyTwitterLog(completedEmptyLog), false, + `${label}: the first completed empty-X observation was accepted`); + now += 300; + assert.equal(settledEmptyTwitterLog(completedEmptyLog), true, + `${label}: a stable aria-busy=false X log did not become a baseline`); + + const markedEmptyLog = log({ emptyState: true }); + assert.equal(settledEmptyTwitterLog(markedEmptyLog), false, + `${label}: the first explicit X empty-state observation was accepted`); + now += 300; + assert.equal(settledEmptyTwitterLog(markedEmptyLog), true, + `${label}: a stable X empty-state marker did not become a baseline`); + } +}); + test('matches BOSS Zhipin job surfaces with safe search and communication guidance', () => { const trustedUrls = [ 'https://zhipin.com/', @@ -93999,6 +94048,48 @@ test('publication workflows classify and bind requested payload fields', async ( assert.deepEqual(xGuard.workflowMetadataRequirements, [ { field: 'body', value: 'Hello there' }, ], `${AgentClass.name}: an explicit X DM body was not bound after an empty classifier response`); + + const gmailTabId = 8997 + index; + const gmailUrl = 'https://mail.google.com/mail/u/0/#inbox'; + const gmailTask = 'Send an email to Alice with subject: Message: Hello and body: How are you?'; + const gmailWorkflow = agent._resolvePlannerSiteWorkflow(gmailUrl, { + request_kind: 'execute', + site_job: 'send-email', + requires_submission: true, + messaging: { target_kind: 'named', recipients: ['Alice'] }, + }); + assert.equal(gmailWorkflow?.job?.id, 'send-email'); + assert.equal(agent._extractWorkflowTaskBody(gmailTask, '', 'gmail'), 'Hello and body: How are you?', + `${AgentClass.name}: the regression fixture no longer exercises Gmail's generic Send parser`); + agent.conversations.set(gmailTabId, [ + { role: 'system', content: 'system' }, + { role: 'user', content: gmailTask }, + ]); + const gmailGuard = agent._startPlanExecutionGuard(gmailTabId, 'act', { + requestKind: 'execute', + requiresStateChange: true, + requiresSubmission: true, + messaging: { target_kind: 'named', recipients: ['Alice'] }, + siteWorkflow: gmailWorkflow, + }); + agent._chatWithCostAllowance = async () => ({ content: JSON.stringify({ + mode: 'inactive', allowedActions: [], forbiddenActions: [], targets: [], + workflowFields: [ + { field: 'subject', value: 'Message: Hello' }, + { field: 'body', value: 'How are you?' }, + ], + confidence: 0.99, pageScopePolicy: 'page', + }) }); + await agent._ensureProgressSessionForCurrentTask(gmailTabId, { + provider: { chat: async () => ({ content: '{}' }) }, + progressLedgerPolicy: 'disabled', + taskText: gmailTask, + pageScope: gmailUrl, + }); + assert.deepEqual(gmailGuard.workflowMetadataRequirements, [ + { field: 'subject', value: 'Message: Hello' }, + { field: 'body', value: 'How are you?' }, + ], `${AgentClass.name}: X's body recovery rewrote Gmail's structured subject/body fields`); const xTerminal = (body) => { const workflowBinding = agent._workflowSubmitBindingForAttempt(xTabId, xUrl, { messageRecipientGuardRequired: true, From 24f80a3054f5c42ca2792ee8a721df1fb32e8bc7 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 19:08:11 +0300 Subject: [PATCH 10/13] Stabilize X message history baselines --- src/chrome/src/content/content.js | 60 +++++++++++-------- src/firefox/src/content/content.js | 60 +++++++++++-------- .../fixtures/message-recipient-navigation.mjs | 19 ++++-- test/run.js | 38 ++++++++---- 4 files changed, 111 insertions(+), 66 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 238b73b33..d4c5af1d0 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -4416,35 +4416,47 @@ } const _messageRecipientDispatchBindings = new Map(); - const _twitterEmptyLogObservations = new WeakMap(); + const _twitterHistoryObservations = new WeakMap(); - function _settledEmptyTwitterLog(log) { + function _settledTwitterHistory(log, messageIds = []) { if (!log?.isConnected) return false; let loading = false; - let historyComplete = false; + let completionSignal = false; try { const ariaBusy = log.getAttribute('aria-busy'); loading = ariaBusy === 'true' || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); - // An empty log alone is ambiguous: history may still mount after the - // submit. Require X to expose either its finished busy state or its - // app-owned empty-state marker before pinning a first-message baseline. - historyComplete = ariaBusy === 'false' - || !!log.querySelector('[data-testid="dm-empty-state"],[data-testid="empty_state"],[data-testid="empty-state"]'); + // X's explicit idle state is a direct history-completion signal. Empty + // logs may alternatively use X's own empty-state marker. + completionSignal = ariaBusy === 'false' + || (!(Array.isArray(messageIds) && messageIds.length > 0) + && !!log.querySelector('[data-testid="dm-empty-state"],[data-testid="empty_state"],[data-testid="empty-state"]')); } catch {} - if (loading || !historyComplete) { - _twitterEmptyLogObservations.delete(log); + if (loading) { + _twitterHistoryObservations.delete(log); + return false; + } + if (completionSignal) { + _twitterHistoryObservations.delete(log); + return true; + } + // Without an explicit X completion marker, an empty history remains + // ambiguous. A nonempty history must retain the same row identities long + // enough to rule out an in-progress history append. + if (!Array.isArray(messageIds) || messageIds.length === 0) { + _twitterHistoryObservations.delete(log); return false; } const now = Date.now(); - const prior = _twitterEmptyLogObservations.get(log); - const observation = prior + const signature = Array.isArray(messageIds) ? messageIds.join('\u001f') : ''; + const prior = _twitterHistoryObservations.get(log); + const observation = prior?.signature === signature ? { firstSeenAt: prior.firstSeenAt, count: prior.count + 1 } - : { firstSeenAt: now, count: 1 }; - _twitterEmptyLogObservations.set(log, observation); - // A first-message conversation has no historic tail to pin. Require the - // same positively-complete empty log to survive two reads before treating - // that exceptional baseline as settled. + : { firstSeenAt: now, count: 1, signature }; + _twitterHistoryObservations.set(log, { ...observation, signature }); + // A nonempty history without an explicit idle marker can append old + // messages. Require the same snapshot to survive two reads before using + // it as a dispatch baseline. return observation.count >= 2 && now - observation.firstSeenAt >= 300; } @@ -5088,15 +5100,15 @@ .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) : []; const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); - const twitterEmptyConversationBaseline = twitterLogs.length === 1 && twitterRows.length === 0 - && _settledEmptyTwitterLog(twitterLogs[0]); - if (twitterRows.length > 0 && twitterLogs[0]) _twitterEmptyLogObservations.delete(twitterLogs[0]); - // An empty mounted log can mean X is still loading older history. A - // first-message conversation is eligible only after the same non-loading - // empty log has settled across repeated observations. + const twitterHistorySettled = twitterLogs.length === 1 + && _settledTwitterHistory(twitterLogs[0], twitterMessageIds); + const twitterEmptyConversationBaseline = twitterRows.length === 0 && twitterHistorySettled; + // A mounted X log can still append older history. Empty conversations + // need a positive X completion signal; other histories need that signal + // or a stable row snapshot before they become dispatch baselines. const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 && new Set(twitterMessageIds).size === twitterMessageIds.length - && (twitterRows.length > 0 || twitterEmptyConversationBaseline); + && (twitterRows.length > 0 ? twitterHistorySettled : twitterEmptyConversationBaseline); const matchingTwitterMessageIds = expectedBody => { const expected = normalizedMessageBody(expectedBody); if (!expected || !twitterBaselineComplete) return []; diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index a7243b34b..ccfac1b82 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -3728,35 +3728,47 @@ } const _messageRecipientDispatchBindings = new Map(); - const _twitterEmptyLogObservations = new WeakMap(); + const _twitterHistoryObservations = new WeakMap(); - function _settledEmptyTwitterLog(log) { + function _settledTwitterHistory(log, messageIds = []) { if (!log?.isConnected) return false; let loading = false; - let historyComplete = false; + let completionSignal = false; try { const ariaBusy = log.getAttribute('aria-busy'); loading = ariaBusy === 'true' || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); - // An empty log alone is ambiguous: history may still mount after the - // submit. Require X to expose either its finished busy state or its - // app-owned empty-state marker before pinning a first-message baseline. - historyComplete = ariaBusy === 'false' - || !!log.querySelector('[data-testid="dm-empty-state"],[data-testid="empty_state"],[data-testid="empty-state"]'); + // X's explicit idle state is a direct history-completion signal. Empty + // logs may alternatively use X's own empty-state marker. + completionSignal = ariaBusy === 'false' + || (!(Array.isArray(messageIds) && messageIds.length > 0) + && !!log.querySelector('[data-testid="dm-empty-state"],[data-testid="empty_state"],[data-testid="empty-state"]')); } catch {} - if (loading || !historyComplete) { - _twitterEmptyLogObservations.delete(log); + if (loading) { + _twitterHistoryObservations.delete(log); + return false; + } + if (completionSignal) { + _twitterHistoryObservations.delete(log); + return true; + } + // Without an explicit X completion marker, an empty history remains + // ambiguous. A nonempty history must retain the same row identities long + // enough to rule out an in-progress history append. + if (!Array.isArray(messageIds) || messageIds.length === 0) { + _twitterHistoryObservations.delete(log); return false; } const now = Date.now(); - const prior = _twitterEmptyLogObservations.get(log); - const observation = prior + const signature = Array.isArray(messageIds) ? messageIds.join('\u001f') : ''; + const prior = _twitterHistoryObservations.get(log); + const observation = prior?.signature === signature ? { firstSeenAt: prior.firstSeenAt, count: prior.count + 1 } - : { firstSeenAt: now, count: 1 }; - _twitterEmptyLogObservations.set(log, observation); - // A first-message conversation has no historic tail to pin. Require the - // same positively-complete empty log to survive two reads before treating - // that exceptional baseline as settled. + : { firstSeenAt: now, count: 1, signature }; + _twitterHistoryObservations.set(log, { ...observation, signature }); + // A nonempty history without an explicit idle marker can append old + // messages. Require the same snapshot to survive two reads before using + // it as a dispatch baseline. return observation.count >= 2 && now - observation.firstSeenAt >= 300; } @@ -4260,15 +4272,15 @@ .filter(row => /^message-(?!text-)[a-zA-Z0-9_-]{1,128}$/.test(row.getAttribute('data-testid') || '')) : []; const twitterMessageIds = twitterRows.map(row => row.getAttribute('data-testid')); - const twitterEmptyConversationBaseline = twitterLogs.length === 1 && twitterRows.length === 0 - && _settledEmptyTwitterLog(twitterLogs[0]); - if (twitterRows.length > 0 && twitterLogs[0]) _twitterEmptyLogObservations.delete(twitterLogs[0]); - // An empty mounted log can mean X is still loading older history. A - // first-message conversation is eligible only after the same non-loading - // empty log has settled across repeated observations. + const twitterHistorySettled = twitterLogs.length === 1 + && _settledTwitterHistory(twitterLogs[0], twitterMessageIds); + const twitterEmptyConversationBaseline = twitterRows.length === 0 && twitterHistorySettled; + // A mounted X log can still append older history. Empty conversations + // need a positive X completion signal; other histories need that signal + // or a stable row snapshot before they become dispatch baselines. const twitterBaselineComplete = twitterLogs.length === 1 && twitterRows.length <= 2000 && new Set(twitterMessageIds).size === twitterMessageIds.length - && (twitterRows.length > 0 || twitterEmptyConversationBaseline); + && (twitterRows.length > 0 ? twitterHistorySettled : twitterEmptyConversationBaseline); const matchingTwitterMessageIds = expectedBody => { const expected = normalizedMessageBody(expectedBody); if (!expected || !twitterBaselineComplete) return []; diff --git a/test/fixtures/message-recipient-navigation.mjs b/test/fixtures/message-recipient-navigation.mjs index ecb412330..546b793c6 100644 --- a/test/fixtures/message-recipient-navigation.mjs +++ b/test/fixtures/message-recipient-navigation.mjs @@ -62,7 +62,7 @@ export function registerMessageRecipientNavigationFixtures({ #send {position:fixed;left:820px;bottom:20px} [role=log] {position:fixed;left:400px;top:100px;width:400px;height:300px;overflow:auto}
Alex Volkov
-
+
`, kind); const body = 'Hey Alex, check out webbrain.one.\nHappy to share a demo.'; @@ -176,7 +176,8 @@ export function registerMessageRecipientNavigationFixtures({ delete submit.workflowBinding.preDispatchMessageIds; assert.equal(evidence(probe),null, 'a legacy count-only binding cannot prove an X send'); submit.workflowBinding.preDispatchMessageIds = []; - assert.equal(evidence(probe)?.verificationKind, 'message_sent', 'a first message has an empty baseline'); + assert.equal(evidence(probe), null, + 'relabeling a nonempty X history as empty cannot fabricate a first-message baseline'); await addRow('new'); assert.equal(evidence(await observe()),null, 'duplicate message identities fail closed'); }); @@ -191,7 +192,7 @@ export function registerMessageRecipientNavigationFixtures({ #send {position:fixed;left:820px;bottom:20px} [role=log] {position:fixed;left:400px;top:100px;width:400px;height:300px} Alex Volkov -
+
`, kind); await page.evaluate(() => { window.fixtureSends = 0; @@ -404,9 +405,17 @@ export function registerMessageRecipientNavigationFixtures({ await page.evaluate(modal=>{ document.querySelector('main').innerHTML=`
Post to Anyone
Announcement
-
`; + `; document.getElementById('chat').hidden=true; },modal); + const publishWorkflow = agent._resolvePlannerSiteWorkflow(page.url(), { + request_kind: 'execute', site_job: 'publish-post', requires_submission: true, + }); + assert.equal(publishWorkflow?.job?.id, 'publish-post'); + agent._startPlanExecutionGuard(1, 'act', { + requestKind: 'execute', requiresStateChange: true, requiresSubmission: true, + siteWorkflow: publishWorkflow, + }); const target=await page.evaluate(()=>{ const span=document.querySelector('#publish span'),rect=span.getBoundingClientRect(); return {ref_id:window.__wb_ax_ref(span),x:rect.x+2,y:rect.y+2}; @@ -429,7 +438,7 @@ export function registerMessageRecipientNavigationFixtures({ document.getElementById('send').textContent='Post'; }); assert.equal((await guard('click',{selector:'#send'}))?.noDispatch,true); - assert.equal(agent._planExecutionGuards.get(1)?.messaging,undefined); + assert.equal(agent._planExecutionGuards.get(1)?.messaging,null); } } }); diff --git a/test/run.js b/test/run.js index 0dbeda523..6dedec4c6 100644 --- a/test/run.js +++ b/test/run.js @@ -7902,9 +7902,9 @@ test('empty X history baselines require a positive completion signal', () => { : '\n\n function _releaseDispatchBinding', start); assert.ok(start >= 0 && end > start, `${label}: empty-X helper should remain independently testable`); let now = 0; - const { settledEmptyTwitterLog } = vm.runInNewContext(`(() => { + const { settledTwitterHistory } = vm.runInNewContext(`(() => { ${source.slice(start, end)} - return { settledEmptyTwitterLog: _settledEmptyTwitterLog }; + return { settledTwitterHistory: _settledTwitterHistory }; })()`, { Date: { now: () => now }, }); @@ -7917,25 +7917,37 @@ test('empty X history baselines require a positive completion signal', () => { }, }); const ambiguousEmptyLog = log(); - assert.equal(settledEmptyTwitterLog(ambiguousEmptyLog), false, + assert.equal(settledTwitterHistory(ambiguousEmptyLog), false, `${label}: a bare empty X log was accepted before history completion`); now += 300; - assert.equal(settledEmptyTwitterLog(ambiguousEmptyLog), false, + assert.equal(settledTwitterHistory(ambiguousEmptyLog), false, `${label}: a bare empty X log became a first-message baseline`); const completedEmptyLog = log({ ariaBusy: 'false' }); - assert.equal(settledEmptyTwitterLog(completedEmptyLog), false, - `${label}: the first completed empty-X observation was accepted`); - now += 300; - assert.equal(settledEmptyTwitterLog(completedEmptyLog), true, - `${label}: a stable aria-busy=false X log did not become a baseline`); + assert.equal(settledTwitterHistory(completedEmptyLog), true, + `${label}: an explicit aria-busy=false X empty log did not become a baseline`); const markedEmptyLog = log({ emptyState: true }); - assert.equal(settledEmptyTwitterLog(markedEmptyLog), false, - `${label}: the first explicit X empty-state observation was accepted`); + assert.equal(settledTwitterHistory(markedEmptyLog), true, + `${label}: an explicit X empty-state marker did not become a baseline`); + + const loadingHistory = log({ ariaBusy: 'true' }); + assert.equal(settledTwitterHistory(loadingHistory, ['message-existing']), false, + `${label}: an explicitly loading X history became a baseline`); + + const completedHistory = log({ ariaBusy: 'false' }); + assert.equal(settledTwitterHistory(completedHistory, ['message-existing']), true, + `${label}: an explicit aria-busy=false X history did not become a baseline`); + + const unmarkedHistory = log(); + assert.equal(settledTwitterHistory(unmarkedHistory, ['message-existing']), false, + `${label}: the first unmarked X history observation was accepted`); + now += 300; + assert.equal(settledTwitterHistory(unmarkedHistory, ['message-existing']), true, + `${label}: a stable unmarked X history did not become a baseline`); now += 300; - assert.equal(settledEmptyTwitterLog(markedEmptyLog), true, - `${label}: a stable X empty-state marker did not become a baseline`); + assert.equal(settledTwitterHistory(unmarkedHistory, ['message-existing', 'message-late']), false, + `${label}: an appended historical X row kept the previous baseline`); } }); From 0a15deaf14c503b5ea1ce2e8fc9afd8d00d46114 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 19:23:38 +0300 Subject: [PATCH 11/13] Support X group DM recipient binding --- src/chrome/src/agent/agent.js | 13 +++- src/chrome/src/content/content.js | 19 +++++ src/firefox/src/agent/agent.js | 13 +++- src/firefox/src/content/content.js | 19 +++++ .../fixtures/message-recipient-navigation.mjs | 73 +++++++++++++++++++ 5 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 9c3256868..cbc65e4f0 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -23089,16 +23089,21 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const candidates = this._messageRecipientCandidates(probe); if (candidates.length !== 1 || candidates[0].role !== 'to') return null; const candidate = candidates[0]; - const handle = normalizeRecipientIdentity(candidate.identity); - if (!/^@[a-z0-9_]{1,15}$/.test(handle)) return null; + const identity = normalizeRecipientIdentity(candidate.identity); + const accountHandle = /^@[a-z0-9_]{1,15}$/.test(identity); + const groupConversation = /^x-dm-group:[a-z0-9_-]{1,128}$/.test(identity); + if (!accountHandle && !groupConversation) return null; const expected = normalizeRecipientIdentity(named.recipients[0].identity); // Display names and bare usernames can resolve to the unique observed // header. Once pinned, an @handle must match the account itself: another // account cannot borrow its authorization through a display-name alias. - const matches = expected.startsWith('@') ? expected === handle + // Group names resolve only to the current chat route/header identity; an + // @handle never resolves through a group label. + const matches = accountHandle && expected.startsWith('@') ? expected === identity + : groupConversation && expected.startsWith('@') ? false : [candidate.identity, ...(Array.isArray(candidate.aliases) ? candidate.aliases : [])] .some(alias => normalizeRecipientIdentity(alias) === expected); - return matches ? { target_kind: 'named', recipients: [{ identity: handle, role: 'to' }] } : null; + return matches ? { target_kind: 'named', recipients: [{ identity, role: 'to' }] } : null; } async _pinActiveConversationMessagingTarget(tabId, messaging, pageUrl = '') { diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index d4c5af1d0..bdfe76cb5 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -5816,6 +5816,25 @@ strongIdentities.push(identity); strongRecipients.push({ identity, role: 'to' }); observedRecipientCandidates.push({ identity, role: 'to', aliases: [identity, handle, compact(header.innerText)] }); + } else { + // X group DM headers name the conversation instead of linking to a + // single account. Bind the visible header to the canonical chat + // route so a later conversation cannot inherit this authorization. + let groupIdentity = ''; + try { + const groupId = new URL(location.href).pathname + .match(/^\/i\/chat\/([a-zA-Z0-9_-]{1,128})\/?$/)?.[1] || ''; + if (groupId && compact(header.innerText)) groupIdentity = `x-dm-group:${groupId}`; + } catch {} + if (groupIdentity) { + strongIdentities.push(groupIdentity); + strongRecipients.push({ identity: groupIdentity, role: 'to' }); + observedRecipientCandidates.push({ + identity: groupIdentity, + role: 'to', + aliases: [groupIdentity, compact(header.innerText)], + }); + } } } } else { diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 9d488d39f..01678fad6 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -20848,16 +20848,21 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const candidates = this._messageRecipientCandidates(probe); if (candidates.length !== 1 || candidates[0].role !== 'to') return null; const candidate = candidates[0]; - const handle = normalizeRecipientIdentity(candidate.identity); - if (!/^@[a-z0-9_]{1,15}$/.test(handle)) return null; + const identity = normalizeRecipientIdentity(candidate.identity); + const accountHandle = /^@[a-z0-9_]{1,15}$/.test(identity); + const groupConversation = /^x-dm-group:[a-z0-9_-]{1,128}$/.test(identity); + if (!accountHandle && !groupConversation) return null; const expected = normalizeRecipientIdentity(named.recipients[0].identity); // Display names and bare usernames can resolve to the unique observed // header. Once pinned, an @handle must match the account itself: another // account cannot borrow its authorization through a display-name alias. - const matches = expected.startsWith('@') ? expected === handle + // Group names resolve only to the current chat route/header identity; an + // @handle never resolves through a group label. + const matches = accountHandle && expected.startsWith('@') ? expected === identity + : groupConversation && expected.startsWith('@') ? false : [candidate.identity, ...(Array.isArray(candidate.aliases) ? candidate.aliases : [])] .some(alias => normalizeRecipientIdentity(alias) === expected); - return matches ? { target_kind: 'named', recipients: [{ identity: handle, role: 'to' }] } : null; + return matches ? { target_kind: 'named', recipients: [{ identity, role: 'to' }] } : null; } async _pinActiveConversationMessagingTarget(tabId, messaging, pageUrl = '') { diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index ccfac1b82..00c9645db 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -4988,6 +4988,25 @@ strongIdentities.push(identity); strongRecipients.push({ identity, role: 'to' }); observedRecipientCandidates.push({ identity, role: 'to', aliases: [identity, handle, compact(header.innerText)] }); + } else { + // X group DM headers name the conversation instead of linking to a + // single account. Bind the visible header to the canonical chat + // route so a later conversation cannot inherit this authorization. + let groupIdentity = ''; + try { + const groupId = new URL(location.href).pathname + .match(/^\/i\/chat\/([a-zA-Z0-9_-]{1,128})\/?$/)?.[1] || ''; + if (groupId && compact(header.innerText)) groupIdentity = `x-dm-group:${groupId}`; + } catch {} + if (groupIdentity) { + strongIdentities.push(groupIdentity); + strongRecipients.push({ identity: groupIdentity, role: 'to' }); + observedRecipientCandidates.push({ + identity: groupIdentity, + role: 'to', + aliases: [groupIdentity, compact(header.innerText)], + }); + } } } } else { diff --git a/test/fixtures/message-recipient-navigation.mjs b/test/fixtures/message-recipient-navigation.mjs index 546b793c6..59d97cb44 100644 --- a/test/fixtures/message-recipient-navigation.mjs +++ b/test/fixtures/message-recipient-navigation.mjs @@ -284,6 +284,79 @@ export function registerMessageRecipientNavigationFixtures({ assert.equal(await page.evaluate(() => window.fixtureSends), 6, 'blocked retries never dispatch'); }); + register(`${kind}: X group DMs pin the visible conversation route and header`, async (page) => { + const { agent } = await setup(page); + const url = 'https://x.com/i/chat/group-123'; + await page.goto(url); + await setupContentHtml(page, ` +
+ `, kind); + await page.evaluate(() => { + window.fixtureSends = 0; + document.querySelector('#send').addEventListener('click', () => { + const composer = document.querySelector('textarea'); + const row = document.createElement('div'); + row.dataset.testid = 'message-' + ++window.fixtureSends; + row.dataset.sendStatus = 'sent'; row.className = 'justify-end'; + const content = document.createElement('div'); content.dataset.testid = 'message-text-' + window.fixtureSends; + const body = document.createElement('span'); body.dir = 'auto'; body.textContent = composer.value; + content.append(body); row.append(content); document.querySelector('[role=log]').append(row); + composer.value = ''; + }); + }); + const group = { target_kind: 'named', recipients: [{ identity: 'x-dm-group:group-123', role: 'to' }] }; + const named = identity => ({ target_kind: 'named', recipients: [{ identity, role: 'to' }] }); + const activePin = await agent._pinActiveConversationMessagingTarget( + 1, { target_kind: 'active_conversation', recipients: [] }, url, + ); + assert.equal(activePin.ok, true, JSON.stringify(activePin)); + assert.equal(activePin.target.target_kind, 'named'); + assert.deepEqual(activePin.target.recipients.map(({ identity, role }) => ({ identity, role })), group.recipients, + 'the active group gets a route-bound identity'); + const namedPin = await agent._pinActiveConversationMessagingTarget(1, named('Study Group'), url); + assert.deepEqual(namedPin.target, group, 'the visible group name resolves to that route'); + const handlePin = await agent._pinActiveConversationMessagingTarget(1, named('@studygroup'), url); + assert.deepEqual(handlePin.target, named('@studygroup'), 'a group label cannot stand in for an account handle'); + + const workflow = agent._resolvePlannerSiteWorkflow(url, { + request_kind: 'execute', requires_submission: true, messaging: activePin.target, + }); + const guard = agent._startPlanExecutionGuard(1, 'act', { + requestKind: 'execute', requiresStateChange: true, requiresSubmission: true, + messaging: activePin.target, siteWorkflow: workflow, + }); + guard.workflowMetadataRequirementsResolved = true; + await page.locator('textarea').fill('Hello Study Group'); + const execution = {}; + assert.equal(await agent._messageRecipientGuardBlock(1, 'click', { selector: '#send' }, url, execution), null); + assert.deepEqual(guard.messaging, group); + const clicked = await call(page, 'click', { selector: '#send', ...execution }); + assert.equal(clicked.success, true, JSON.stringify(clicked)); + agent._recordCompletionSubmitAttempt(1, { isSubmit: false }, 'click', { selector: '#send' }, + url, url, clicked, 'doc', 'doc', execution); + const submit = agent._completionSubmitStates.get(1); + submit.observedAfterSubmit = true; + const terminalProbe = await call(page, 'probe_message_recipient_guard', { + tool: 'observe_active_conversation', adapterName: 'twitter', expectedMessageBody: 'Hello Study Group', + }); + const terminal = agent._workflowTerminalEvidenceFromDone(1, { relevantFormCount: 1 }, url, + { submit, relevantForms: 1, verifiedFinalSubmit: false }, terminalProbe); + assert.equal(terminal?.verificationKind, 'message_sent', 'the group send has delivery evidence'); + + await page.locator('textarea').fill('This must not send'); + const staleExecution = {}; + assert.equal(await agent._messageRecipientGuardBlock(1, 'click', { selector: '#send' }, url, staleExecution), null); + await page.evaluate(() => history.pushState({}, '', '/i/chat/group-456')); + const stale = await call(page, 'click', { selector: '#send', ...staleExecution }); + assert.equal(stale.noDispatch, true, 'a changed conversation route invalidates the dispatch binding'); + assert.equal(await page.evaluate(() => window.fixtureSends), 1, 'the stale retry never dispatches'); + }); + register(`${kind}: X conversation navigation is not classified as sending a message`, async (page) => { const { agent } = await setup(page); await page.goto('https://x.com/i/chat/123-456'); From a11c27a7412905f9e1ccaaaa91aa31e759266b71 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 19:49:06 +0300 Subject: [PATCH 12/13] Fix _hasVisibleBox shadow host aria-hidden traversal --- src/chrome/src/content/content.js | 10 +++++++++- src/firefox/src/content/content.js | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index bdfe76cb5..7008d5bb5 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -456,8 +456,16 @@ function _hasVisibleBox(el, minWidth = 1, minHeight = 1) { if (!el || typeof el.getBoundingClientRect !== 'function') return false; try { - for (let ancestor = el; ancestor; ancestor = ancestor.parentElement) { + let ancestor = el; + while (ancestor) { if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; + const parent = ancestor.parentElement; + if (parent) { + ancestor = parent; + continue; + } + const root = ancestor.getRootNode?.(); + ancestor = root && typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null; } const r = el.getBoundingClientRect(); if (r.width < minWidth || r.height < minHeight) return false; diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index 00c9645db..244a2549e 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -667,8 +667,16 @@ function _hasVisibleBox(el, minWidth = 1, minHeight = 1) { if (!el || typeof el.getBoundingClientRect !== 'function') return false; try { - for (let ancestor = el; ancestor; ancestor = ancestor.parentElement) { + let ancestor = el; + while (ancestor) { if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; + const parent = ancestor.parentElement; + if (parent) { + ancestor = parent; + continue; + } + const root = ancestor.getRootNode?.(); + ancestor = root && typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null; } const r = el.getBoundingClientRect(); if (r.width < minWidth || r.height < minHeight) return false; From 2b59784d54d5f0ba8df71f9bbeba1f9d819a7b03 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Sun, 20 Sep 2026 20:11:02 +0300 Subject: [PATCH 13/13] Respect hidden assigned slots in click candidates --- src/chrome/src/content/content.js | 10 +--------- src/firefox/src/content/content.js | 10 +--------- .../fixtures/message-recipient-navigation.mjs | 20 +++++++++++++++++++ test/run.js | 7 ++++++- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 7008d5bb5..1a2b3d951 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -456,16 +456,8 @@ function _hasVisibleBox(el, minWidth = 1, minHeight = 1) { if (!el || typeof el.getBoundingClientRect !== 'function') return false; try { - let ancestor = el; - while (ancestor) { + for (let ancestor = el; ancestor; ancestor = _composedParent(ancestor)) { if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; - const parent = ancestor.parentElement; - if (parent) { - ancestor = parent; - continue; - } - const root = ancestor.getRootNode?.(); - ancestor = root && typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null; } const r = el.getBoundingClientRect(); if (r.width < minWidth || r.height < minHeight) return false; diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index 244a2549e..1ef8a9633 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -667,16 +667,8 @@ function _hasVisibleBox(el, minWidth = 1, minHeight = 1) { if (!el || typeof el.getBoundingClientRect !== 'function') return false; try { - let ancestor = el; - while (ancestor) { + for (let ancestor = el; ancestor; ancestor = _composedParent(ancestor)) { if (ancestor.getAttribute?.('aria-hidden') === 'true') return false; - const parent = ancestor.parentElement; - if (parent) { - ancestor = parent; - continue; - } - const root = ancestor.getRootNode?.(); - ancestor = root && typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null; } const r = el.getBoundingClientRect(); if (r.width < minWidth || r.height < minHeight) return false; diff --git a/test/fixtures/message-recipient-navigation.mjs b/test/fixtures/message-recipient-navigation.mjs index 59d97cb44..f0fa578d1 100644 --- a/test/fixtures/message-recipient-navigation.mjs +++ b/test/fixtures/message-recipient-navigation.mjs @@ -414,6 +414,26 @@ export function registerMessageRecipientNavigationFixtures({ assert.equal(await page.locator('textarea').inputValue(), 'Unsent draft'); }); + register(`${kind}: an aria-hidden assigned slot cannot make Send ambiguous`, async (page) => { + await setup(page); + await page.evaluate(() => { + document.querySelector('#chat').hidden = false; + const host = document.createElement('div'); + const duplicate = document.createElement('button'); + duplicate.id = 'slotted-hidden-send'; + duplicate.slot = 'hidden-send'; + duplicate.type = 'button'; + duplicate.textContent = 'Send'; + host.append(duplicate); + document.body.append(host); + host.attachShadow({ mode: 'open' }).innerHTML = ''; + }); + const result = await call(page, 'click', { text: 'Send', textMatch: 'exact' }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.deepEqual(await page.evaluate(() => window.fixtureClicks), ['send'], + 'the assigned hidden control must not be a click candidate'); + }); + const addPostEntry = async (page, shadow = false) => page.evaluate((shadow) => { const host = document.createElement('section'); host.id = 'post-entry'; diff --git a/test/run.js b/test/run.js index 6dedec4c6..6ac77d07c 100644 --- a/test/run.js +++ b/test/run.js @@ -6973,6 +6973,7 @@ test('direct-message recipient probe accepts only a unique active-thread header innerText: text, children: [], parentElement: options.parentElement || null, + get parentNode() { return this.parentElement; }, clientHeight: options.clientHeight || rect.height || 0, scrollHeight: options.scrollHeight || rect.height || 0, getBoundingClientRect: () => rect, @@ -7123,6 +7124,10 @@ test('direct-message recipient probe accepts only a unique active-thread header return null; }, }; + const composedParentStart = source.indexOf(' function _composedParent('); + const composedParentEnd = source.indexOf('\n\n function ', composedParentStart + 1); + assert.ok(composedParentStart >= 0 && composedParentEnd > composedParentStart, + `${prefix}: composed-parent helper must be available to visibility checks`); const candidatesStart = source.indexOf(' function _clickTextCandidates('); const candidatesEnd = source.indexOf('\n\n let _lastClickIdent', candidatesStart); Object.assign(context, { @@ -7137,7 +7142,7 @@ test('direct-message recipient probe accepts only a unique active-thread header const visibilityStart = source.indexOf(' function _hasVisibleBox('); const visibilityEnd = source.indexOf('\n\n function ', visibilityStart + 1); const probe = vm.runInNewContext( - `${source.slice(visibilityStart, visibilityEnd)}; ${source.slice(candidatesStart, candidatesEnd)}; (${source.slice(start, end)})`, context, + `${source.slice(composedParentStart, composedParentEnd)}; ${source.slice(visibilityStart, visibilityEnd)}; ${source.slice(candidatesStart, candidatesEnd)}; (${source.slice(start, end)})`, context, ); const observationResult = probe({ tool: 'observe_active_conversation', args: {} }); const enterResult = probe({ tool: 'press_keys', args: { key: 'Enter' } });