diff --git a/docs/social-publication-contract.md b/docs/social-publication-contract.md index 5b5ad41a9..24c912ef2 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,7 @@ 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. + +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/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..cbc65e4f0 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,16 @@ export class Agent extends LoopDetector { recipientTargets: recipientTarget.recipients.map(recipient => ({ ...recipient })), messageBody, messageBodyBaselineCount, + ...(siteWorkflow.adapterName === 'twitter' + && Array.isArray(executionContext.messageRecipientExistingMessageIds) + ? { + preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds], + ...(executionContext.messageRecipientTwitterEmptyConversationBaseline === true + && executionContext.messageRecipientExistingMessageIds.length === 0 + ? { preDispatchEmptyConversationBaseline: true } + : {}), + } + : {}), ...(siteWorkflow.adapterName === 'gmail' && executionContext?.messageRecipientGmailComposeFlow === true ? { gmailComposeFlow: true } @@ -2182,7 +2194,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 +5506,40 @@ 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; + 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) + && baselineStillValid + && sentIds.some(id => !priorIds.includes(id) + && currentIds.indexOf(id) > (settledEmptyBaseline ? -1 : 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 +5552,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 +5679,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 +12694,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 +16929,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 +18757,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 +19055,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); @@ -23004,15 +23081,41 @@ 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 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. + // 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, 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', @@ -23020,6 +23123,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 @@ -23099,7 +23207,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 @@ -23130,6 +23255,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 @@ -23150,11 +23279,18 @@ 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; executionContext.messageRecipientBody = this._workflowMessageBody(probe.messageBody); 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); executionContext.messageRecipientSubjectAvailable = true; @@ -28136,7 +28272,12 @@ 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"). 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]*?(?:(? r.field === 'body'); if (bodyReq) { @@ -28371,7 +28521,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), @@ -28401,10 +28552,15 @@ 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); + 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', @@ -28603,7 +28759,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 +28798,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 +28841,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 +33402,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 +33418,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 +35369,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 +37372,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 +37392,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 +37503,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 +37753,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 +38706,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 +39005,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..1a2b3d951 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 = _composedParent(ancestor)) { + 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); @@ -1384,6 +1387,7 @@ }; visit(scope); return candidates + .filter(e => _hasVisibleBox(e)) .map(e => ({ e, txt: _siteInteractionText(e).toLowerCase() })) .filter(candidate => candidate.txt); } @@ -4412,6 +4416,49 @@ } const _messageRecipientDispatchBindings = new Map(); + const _twitterHistoryObservations = new WeakMap(); + + function _settledTwitterHistory(log, messageIds = []) { + if (!log?.isConnected) return false; + let loading = false; + let completionSignal = false; + try { + const ariaBusy = log.getAttribute('aria-busy'); + loading = ariaBusy === 'true' + || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); + // 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) { + _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 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, 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; + } function _messageRecipientIdentityKey(values = []) { return JSON.stringify(Array.from(new Set((Array.isArray(values) ? values : []) @@ -4465,6 +4512,8 @@ identityKey, 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, @@ -4533,6 +4582,10 @@ || liveIdentityKey !== expected.identityKey || !expected.messageBody || 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))) { @@ -4939,6 +4992,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' @@ -5032,9 +5088,41 @@ } 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 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 ? twitterHistorySettled : twitterEmptyConversationBaseline); + 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 +5217,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 +5423,53 @@ 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; + 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') + || (/^\/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); + return editors.length === 1; + }; + let composer = null; let messageSend = null; if (observationOnly) { @@ -5371,9 +5510,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 +5556,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 +5798,45 @@ } } } + } 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 { + // 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 { for (const el of document.querySelectorAll( '[aria-selected="true"],[aria-current]:not([aria-current="false"])' @@ -5711,6 +5897,7 @@ const messageRecipientDispatchToken = params.bindDispatch === true && messageSend === true && !!messageBody + && (!twitterConversation || twitterBaselineComplete) && (params.supportsRecipientSets === true ? strongRecipients.length > 0 : strongRecipients.length === 1) @@ -5723,6 +5910,10 @@ supportsRecipientSets: params.supportsRecipientSets, messageBody, messageBodyBaselineCount, + ...(twitterConversation ? { + existingMessageIds: twitterMessageIds, + ...(twitterEmptyConversationBaseline ? { twitterEmptyConversationBaseline: true } : {}), + } : {}), gmailComposeFlow, composerSubject, composerSubjectAvailable, @@ -5746,6 +5937,11 @@ composerSubjectAvailable, composerStatusMessages, matchingOutgoingMessageCount, + ...(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 // 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..01678fad6 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,16 @@ export class Agent extends LoopDetector { recipientTargets: recipientTarget.recipients.map(recipient => ({ ...recipient })), messageBody, messageBodyBaselineCount, + ...(siteWorkflow.adapterName === 'twitter' + && Array.isArray(executionContext.messageRecipientExistingMessageIds) + ? { + preDispatchMessageIds: [...executionContext.messageRecipientExistingMessageIds], + ...(executionContext.messageRecipientTwitterEmptyConversationBaseline === true + && executionContext.messageRecipientExistingMessageIds.length === 0 + ? { preDispatchEmptyConversationBaseline: true } + : {}), + } + : {}), ...(siteWorkflow.adapterName === 'gmail' && executionContext?.messageRecipientGmailComposeFlow === true ? { gmailComposeFlow: true } @@ -1900,7 +1912,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 +5224,40 @@ 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; + 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) + && baselineStillValid + && sentIds.some(id => !priorIds.includes(id) + && currentIds.indexOf(id) > (settledEmptyBaseline ? -1 : 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 +5270,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 +5397,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 +11233,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 +14735,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 +16563,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 +16861,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); @@ -20763,15 +20840,41 @@ 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 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. + // 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, 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', @@ -20779,6 +20882,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 @@ -20858,7 +20966,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 @@ -20889,6 +21014,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 @@ -20909,11 +21038,18 @@ 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; executionContext.messageRecipientBody = this._workflowMessageBody(probe.messageBody); 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); executionContext.messageRecipientSubjectAvailable = true; @@ -25750,7 +25886,12 @@ 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"). 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]*?(?:(? r.field === 'body'); if (bodyReq) { @@ -25985,7 +26135,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), @@ -26015,10 +26166,15 @@ 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); + 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', @@ -26217,7 +26373,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 +26412,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 +26455,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 +31849,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 +33898,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..1ef8a9633 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 = _composedParent(ancestor)) { + 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); @@ -1358,7 +1361,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); } @@ -3725,6 +3728,49 @@ } const _messageRecipientDispatchBindings = new Map(); + const _twitterHistoryObservations = new WeakMap(); + + function _settledTwitterHistory(log, messageIds = []) { + if (!log?.isConnected) return false; + let loading = false; + let completionSignal = false; + try { + const ariaBusy = log.getAttribute('aria-busy'); + loading = ariaBusy === 'true' + || !!log.querySelector('[aria-busy="true"],[role="progressbar"]'); + // 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) { + _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 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, 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; + } function _messageRecipientIdentityKey(values = []) { return JSON.stringify(Array.from(new Set((Array.isArray(values) ? values : []) @@ -3778,6 +3824,8 @@ identityKey, 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, @@ -3846,6 +3894,10 @@ || liveIdentityKey !== expected.identityKey || !expected.messageBody || 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))) { @@ -4112,6 +4164,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' @@ -4205,9 +4260,41 @@ } 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 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 ? twitterHistorySettled : twitterEmptyConversationBaseline); + 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 +4389,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 +4595,53 @@ 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; + 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') + || (/^\/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); + return editors.length === 1; + }; + let composer = null; let messageSend = null; if (observationOnly) { @@ -4544,9 +4682,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 +4728,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 +4970,45 @@ } } } + } 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 { + // 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 { for (const el of document.querySelectorAll( '[aria-selected="true"],[aria-current]:not([aria-current="false"])' @@ -4884,6 +5069,7 @@ const messageRecipientDispatchToken = params.bindDispatch === true && messageSend === true && !!messageBody + && (!twitterConversation || twitterBaselineComplete) && (params.supportsRecipientSets === true ? strongRecipients.length > 0 : strongRecipients.length === 1) @@ -4896,6 +5082,10 @@ supportsRecipientSets: params.supportsRecipientSets, messageBody, messageBodyBaselineCount, + ...(twitterConversation ? { + existingMessageIds: twitterMessageIds, + ...(twitterEmptyConversationBaseline ? { twitterEmptyConversationBaseline: true } : {}), + } : {}), gmailComposeFlow, composerSubject, composerSubjectAvailable, @@ -4919,6 +5109,11 @@ composerSubjectAvailable, composerStatusMessages, matchingOutgoingMessageCount, + ...(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 // returned as dispatch identities. diff --git a/test/fixtures/message-recipient-navigation.mjs b/test/fixtures/message-recipient-navigation.mjs index 314578e07..f0fa578d1 100644 --- a/test/fixtures/message-recipient-navigation.mjs +++ b/test/fixtures/message-recipient-navigation.mjs @@ -52,6 +52,388 @@ 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), 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'); + }); + + 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 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'); + 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'); + }); + + 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'; @@ -107,6 +489,69 @@ 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 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}; + }); + 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,null); + } + } + }); + + 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..6ac77d07c 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); @@ -5727,6 +5727,29 @@ test('direct-message recipient guard uses structured intent and exact active ide `${label}: conclusively non-message Enter was incorrectly blocked`, ); + probe = { success: true, conclusive: true, messageSend: false, publicPost: true, identityCandidates: [] }; + agent._planExecutionGuards.set(tabId, { + messaging: { target_kind: 'named', recipients: ['Ada'] }, + requiresSubmission: true, + requiresStateChange: true, + siteWorkflow: resolveAdapterWorkflowJob('https://www.linkedin.com/feed/', 'send-message'), + }); + const publicPostBlocked = await agent._messageRecipientGuardBlock( + tabId, 'click_ax', { ref_id: 'ref_linkedin_post' }, 'https://www.linkedin.com/feed/', {}, + ); + assert.equal(publicPostBlocked?.reasonCode, 'public_post_not_authorized', + `${label}: LinkedIn DM authorization allowed a public Post control`); + agent._planExecutionGuards.get(tabId).siteWorkflow = resolveAdapterWorkflowJob( + 'https://www.linkedin.com/feed/', 'publish-post', + ); + assert.equal( + await agent._messageRecipientGuardBlock( + tabId, 'click_ax', { ref_id: 'ref_linkedin_post' }, 'https://www.linkedin.com/feed/', {}, + ), + null, + `${label}: explicit LinkedIn publish workflow could not use its public Post control`, + ); + probe = { success: true, conclusive: false, messageSend: null, identityCandidates: [] }; const inconclusive = await agent._messageRecipientGuardBlock(tabId, 'press_keys', { key: 'Enter' }); assert.equal(inconclusive?.reasonCode, 'message_send_classification_inconclusive', `${label}: inconclusive probe failed open`); @@ -6950,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, @@ -6994,6 +7018,30 @@ 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 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 }); @@ -7040,7 +7088,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 []; @@ -7058,16 +7106,28 @@ 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 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, { @@ -7079,8 +7139,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(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' } }); @@ -7121,6 +7183,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, @@ -7252,6 +7317,7 @@ test('direct-message recipient probe accepts only a unique active-thread header conversationMenuResult, conversationMenuLeafResult, unresolvedClickResult, + localizedLinkedInPostResult, emptyComposerCustomSendResult, gmailMatchingRecipientResult, gmailMatchingNameResult, @@ -7289,6 +7355,7 @@ test('direct-message recipient probe accepts only a unique active-thread header conversationMenuResult, conversationMenuLeafResult, unresolvedClickResult, + localizedLinkedInPostResult, emptyComposerCustomSendResult, gmailMatchingRecipientResult, gmailMatchingNameResult, @@ -7333,7 +7400,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); @@ -7351,6 +7419,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`); @@ -7823,6 +7895,67 @@ 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 { settledTwitterHistory } = vm.runInNewContext(`(() => { + ${source.slice(start, end)} + return { settledTwitterHistory: _settledTwitterHistory }; + })()`, { + 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(settledTwitterHistory(ambiguousEmptyLog), false, + `${label}: a bare empty X log was accepted before history completion`); + now += 300; + assert.equal(settledTwitterHistory(ambiguousEmptyLog), false, + `${label}: a bare empty X log became a first-message baseline`); + + const completedEmptyLog = log({ ariaBusy: 'false' }); + 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(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(settledTwitterHistory(unmarkedHistory, ['message-existing', 'message-late']), false, + `${label}: an appended historical X row kept the previous baseline`); + } +}); + test('matches BOSS Zhipin job surfaces with safe search and communication guidance', () => { const trustedUrls = [ 'https://zhipin.com/', @@ -93882,6 +94015,186 @@ 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'; + // The active X route supplies the platform. The task itself can be the + // concise direct-message command users naturally write. + const xTask = 'Send Alex: Hello there'; + 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.equal(agent._extractWorkflowTaskBody(xTask, '', 'twitter'), 'Hello there', + `${AgentClass.name}: a route-local Send command did not recover its explicit X DM body`); + assert.equal( + agent._extractWorkflowTaskBody('Send an email to Alice with subject: Hello and body: How are you?', '', 'gmail'), + '', + `${AgentClass.name}: X route-local Send recovery rewrote Gmail subject/body metadata`, + ); + 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, + messageRecipientDispatchBinding: { token: `x-body-${body}` }, + messageRecipientBody: body, + messageRecipientBodyBaselineCount: 0, + messageRecipientExistingMessageIds: ['message-prior'], + }); + 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-prior', '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('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`); + 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`); } }); @@ -100339,6 +100652,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';