Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

641 lines
27 KiB
JavaScript

import { htmlToMarkdown } from '@jackwener/opencli/utils';
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
export const GROK_DOMAIN = 'grok.com';
export const GROK_URL = 'https://grok.com/';
export const IS_VISIBLE_JS = `
const isVisible = (node) => {
if (!(node instanceof Element)) return false;
const style = window.getComputedStyle(node);
if (style.visibility === 'hidden' || style.display === 'none') return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
`;
export function authRequired(detail) {
return new AuthRequiredError(
GROK_DOMAIN,
detail || 'Sign in to grok.com in your browser, then retry.',
);
}
export function normalizeBooleanFlag(value, fallback = false) {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
// UUID v4-shape: 8-4-4-4-12 hex with dashes (the format Grok uses for /c/<id>)
const GROK_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parseGrokSessionId(input) {
const raw = String(input ?? '').trim();
if (!raw) {
throw new ArgumentError('id', 'must be a non-empty session ID or grok.com chat URL');
}
let candidate = raw;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new ArgumentError('id', `not a valid Grok URL (got "${input}")`);
}
const host = parsed.hostname.toLowerCase();
const pathMatch = parsed.pathname.match(
/^\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i,
);
if (parsed.protocol !== 'https:' || (host !== 'grok.com' && !host.endsWith('.grok.com')) || !pathMatch) {
throw new ArgumentError(
'id',
`not a valid Grok conversation URL (got "${input}"); expected https://grok.com/c/<id>`,
);
}
candidate = pathMatch[1];
}
if (!GROK_SESSION_ID_RE.test(candidate)) {
throw new ArgumentError(
'id',
`not a valid Grok session ID (got "${input}"); expected a UUID like "7c4197f2-10a1-4ebb-a84a-fea89f4f1d06" or a full https://grok.com/c/<id> URL`,
);
}
return candidate.toLowerCase();
}
export async function isOnGrok(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string' || !url) return false;
try {
const hostname = new URL(url).hostname;
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
} catch {
return false;
}
}
export async function ensureOnGrok(page) {
if (await isOnGrok(page)) return;
await page.goto(GROK_URL);
await page.wait(2);
}
export async function isLoggedIn(page) {
// Composer presence is the most reliable signed-in marker — when the user
// is signed out, grok.com renders a sign-in CTA in place of the chat
// composer rather than the TipTap editor.
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const composer = document.querySelector('.ProseMirror[contenteditable="true"]');
if (composer && isVisible(composer)) {
const signInCta = Array.from(document.querySelectorAll('button, a'))
.some((node) => isVisible(node) && /^(sign in|log in)$/i.test((node.textContent || '').trim()));
return !signInCta;
}
return false;
})()`);
return Boolean(result);
}
export async function getCurrentSessionId(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string') return '';
const match = url.match(/\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return match ? match[1].toLowerCase() : '';
}
// Model picker trigger has stable id="model-select-trigger". Use that as the
// primary selector — aria-label localizes (e.g. "Model select" in English,
// "模型选择" in Chinese, etc.) and is unreliable across browser locales.
const MODEL_TRIGGER_SELECTORS = [
'#model-select-trigger',
'button[aria-label="Model select"]',
'button[aria-label="模型选择"]',
'button[aria-label="モデル選択"]',
];
export async function getModelLabel(page) {
const selectorJson = JSON.stringify(MODEL_TRIGGER_SELECTORS);
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const selectors = ${selectorJson};
let trigger = null;
for (const sel of selectors) {
trigger = Array.from(document.querySelectorAll(sel)).find((node) => isVisible(node));
if (trigger) break;
}
if (!trigger) return '';
return (trigger.innerText || trigger.textContent || '').trim().split('\\n')[0].trim();
})()`);
return typeof result === 'string' ? result : '';
}
export async function getMessageBubbles(page) {
// Grok marks each turn with `[data-testid="user-message"]` or
// `[data-testid="assistant-message"]`. Their nearest ancestor with an
// `id="response-<uuid>"` is a stable per-turn ID we can use for polling
// dedupe. The older `div.message-bubble` selector still matches but does
// not distinguish role on its own.
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const findResponseId = (node) => {
let parent = node.parentElement;
while (parent && parent !== document.body) {
const id = parent.getAttribute('id') || '';
if (id.startsWith('response-')) return id.slice('response-'.length);
parent = parent.parentElement;
}
return '';
};
const bubbles = Array.from(document.querySelectorAll('[data-testid="user-message"], [data-testid="assistant-message"]'))
.filter((node) => node instanceof HTMLElement && isVisible(node));
const out = [];
let positional = 0;
for (const node of bubbles) {
const isAssistant = node.getAttribute('data-testid') === 'assistant-message';
const responseId = findResponseId(node);
const baseId = responseId || ('pos-' + positional);
const id = baseId + (isAssistant ? '-assistant' : '-user');
positional += 1;
const html = node.innerHTML || '';
const text = (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim();
out.push({ id, role: isAssistant ? 'Assistant' : 'User', text, html });
}
return out;
})()`);
if (!Array.isArray(result)) return [];
return result
.map((item) => ({
id: String(item?.id || ''),
role: item?.role === 'Assistant' ? 'Assistant' : 'User',
text: String(item?.text || '').trim(),
html: String(item?.html || ''),
}))
// User turns are always non-empty; an Assistant turn may legitimately
// hold only an image / non-text widget (text empty, html populated).
// Keep entries with either text OR html so image-only turns are not
// silently dropped from `read` / `detail`.
.filter((item) => item.id && (item.text || item.html));
}
export function bubbleHtmlToMarkdown(html) {
try {
return htmlToMarkdown(html).trim();
} catch {
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
}
export async function getHistoryFromSidebar(page, limit) {
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const re = /^\\/c\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
const seen = new Set();
const out = [];
const anchors = Array.from(document.querySelectorAll('a[href^="/c/"]'));
for (const a of anchors) {
if (!(a instanceof HTMLElement) || !isVisible(a)) continue;
const href = a.getAttribute('href') || '';
const m = href.match(re);
if (!m) continue;
const id = m[1].toLowerCase();
if (seen.has(id)) continue;
const title = (a.innerText || a.textContent || '').trim();
// Sidebar renders the conversation title in one anchor and a separate
// icon-only anchor for hover affordances. Keep the first occurrence with
// a non-empty title; if the icon anchor wins the DOM order, fall back to
// the second pass below.
seen.add(id);
out.push({ id, title });
}
// Second pass: backfill empty titles from later anchors with the same id.
const titleById = new Map(out.map((entry) => [entry.id, entry.title]));
for (const a of anchors) {
if (!(a instanceof HTMLElement) || !isVisible(a)) continue;
const href = a.getAttribute('href') || '';
const m = href.match(re);
if (!m) continue;
const id = m[1].toLowerCase();
const title = (a.innerText || a.textContent || '').trim();
if (title && !titleById.get(id)) {
titleById.set(id, title);
}
}
return out.map((entry) => ({ id: entry.id, title: titleById.get(entry.id) || entry.title }));
})()`);
if (!Array.isArray(result)) return [];
const sliced = result.slice(0, limit);
return sliced.map((item) => ({
id: String(item?.id || ''),
title: String(item?.title || ''),
}));
}
export async function startNewChat(page) {
// Grok's "new chat" path is just a navigation back to the homepage —
// there is no dedicated button in the current UI.
await page.goto(GROK_URL);
await page.wait(2);
}
// Open the sidebar conversation context menu (right-click on the /c/<id>
// link), wait for it to appear, click the menu item whose visible text
// matches one of the localized labels. Returns whether the click happened.
//
// Menu items observed on grok.com (2026-05-31):
// "打开新标签页" / "Open in new tab"
// "重命名" / "Rename"
// "置顶" or "取消置顶" / "Pin" or "Unpin"
// "删除" / "Delete"
//
// Grok's delete action takes effect IMMEDIATELY — no confirmation dialog —
// so callers must enforce their own --yes / dry-run gating.
export async function clickConversationMenuItem(page, conversationId, labelOptions) {
const id = String(conversationId).toLowerCase();
const idJson = JSON.stringify(id);
const labelsJson = JSON.stringify(labelOptions.map((l) => l.toLowerCase()));
return await page.evaluate(`(async () => {
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const id = ${idJson};
const labels = ${labelsJson};
// Find the sidebar anchor for this conversation.
let link = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
link = Array.from(document.querySelectorAll('a[href^="/c/"]'))
.find((a) => a instanceof HTMLElement && a.offsetParent && (a.getAttribute('href') || '').toLowerCase().includes(id));
if (link) break;
await waitFor(300);
}
if (!link) {
return { ok: false, reason: 'Conversation not found in sidebar.', detail: 'id=' + id };
}
// Trigger the radix context menu — radix listens to PointerEvent +
// contextmenu, so plain MouseEvent('contextmenu') alone is ignored.
// Dispatch the full sequence pointerdown/mousedown/contextmenu/pointerup/mouseup
// with right-button state to mirror a real right-click.
{
const rect = link.getBoundingClientRect();
const init = {
bubbles: true, cancelable: true, button: 2, buttons: 2,
clientX: Math.round(rect.left + Math.min(rect.width / 2, 16)),
clientY: Math.round(rect.top + Math.min(rect.height / 2, 16)),
};
link.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
link.dispatchEvent(new MouseEvent('mousedown', init));
link.dispatchEvent(new MouseEvent('contextmenu', init));
link.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
link.dispatchEvent(new MouseEvent('mouseup', init));
}
// Wait for menu items to appear.
let items = [];
for (let attempt = 0; attempt < 10; attempt += 1) {
items = Array.from(document.querySelectorAll('[role="menuitem"]'))
.filter((it) => it instanceof HTMLElement && it.offsetParent);
if (items.length) break;
await waitFor(150);
}
if (!items.length) {
return { ok: false, reason: 'Context menu did not open for the conversation.' };
}
const target = items.find((it) => {
const text = (it.textContent || '').trim().toLowerCase();
return labels.some((l) => text === l);
});
if (!target) {
return {
ok: false,
reason: 'No menu item matched the requested label.',
detail: 'available=' + JSON.stringify(items.map((it) => (it.textContent || '').trim())),
};
}
target.click();
return { ok: true, clicked: (target.textContent || '').trim() };
})()`);
}
export function getPinStateFromMenuLabels(labels) {
const normalized = (Array.isArray(labels) ? labels : [])
.map((label) => String(label || '').trim().toLowerCase())
.filter(Boolean);
if (normalized.some((label) => label === '取消置顶' || label === 'unpin')) {
return 'pinned';
}
if (normalized.some((label) => label === '置顶' || label === 'pin')) {
return 'unpinned';
}
return '';
}
export async function isConversationVisibleInSidebar(page, conversationId) {
const id = String(conversationId).toLowerCase();
const idJson = JSON.stringify(id);
const result = await page.evaluate(`(() => {
const id = ${idJson};
return Array.from(document.querySelectorAll('a[href^="/c/"]'))
.some((a) => a instanceof HTMLElement && a.offsetParent && (a.getAttribute('href') || '').toLowerCase().includes(id));
})()`);
return Boolean(result);
}
export async function waitForConversationToDisappear(page, conversationId, timeoutMs = 5_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (!(await isConversationVisibleInSidebar(page, conversationId))) return true;
await page.wait(0.25);
}
return !(await isConversationVisibleInSidebar(page, conversationId));
}
export async function readConversationMenuLabels(page, conversationId) {
const id = String(conversationId).toLowerCase();
const idJson = JSON.stringify(id);
const result = await page.evaluate(`(async () => {
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const id = ${idJson};
let link = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
link = Array.from(document.querySelectorAll('a[href^="/c/"]'))
.find((a) => a instanceof HTMLElement && a.offsetParent && (a.getAttribute('href') || '').toLowerCase().includes(id));
if (link) break;
await waitFor(300);
}
if (!link) {
return { ok: false, reason: 'Conversation not found in sidebar.', detail: 'id=' + id, labels: [] };
}
const rect = link.getBoundingClientRect();
const init = {
bubbles: true, cancelable: true, button: 2, buttons: 2,
clientX: Math.round(rect.left + Math.min(rect.width / 2, 16)),
clientY: Math.round(rect.top + Math.min(rect.height / 2, 16)),
};
link.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
link.dispatchEvent(new MouseEvent('mousedown', init));
link.dispatchEvent(new MouseEvent('contextmenu', init));
link.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
link.dispatchEvent(new MouseEvent('mouseup', init));
let items = [];
for (let attempt = 0; attempt < 10; attempt += 1) {
items = Array.from(document.querySelectorAll('[role="menuitem"]'))
.filter((it) => it instanceof HTMLElement && it.offsetParent);
if (items.length) break;
await waitFor(150);
}
const labels = items.map((it) => (it.textContent || '').trim()).filter(Boolean);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return items.length
? { ok: true, labels }
: { ok: false, reason: 'Context menu did not open for the conversation.', labels: [] };
})()`);
try {
await page.keys('Escape');
} catch {
// Best-effort cleanup; some fake pages and browser adapters do not expose keys().
}
if (!result || typeof result !== 'object') {
return { ok: false, reason: 'Malformed context-menu result.', labels: [] };
}
return {
ok: Boolean(result.ok),
reason: String(result.reason || ''),
detail: String(result.detail || ''),
labels: Array.isArray(result.labels) ? result.labels.map((label) => String(label || '')) : [],
};
}
export async function waitForConversationPinState(page, conversationId, expectedState, timeoutMs = 5_000) {
const started = Date.now();
let last = null;
while (Date.now() - started < timeoutMs) {
last = await readConversationMenuLabels(page, conversationId);
if (last.ok && getPinStateFromMenuLabels(last.labels) === expectedState) {
return { ok: true, state: expectedState, labels: last.labels };
}
await page.wait(0.25);
}
last = await readConversationMenuLabels(page, conversationId);
const state = last.ok ? getPinStateFromMenuLabels(last.labels) : '';
return {
ok: last.ok && state === expectedState,
state,
labels: last.labels || [],
reason: last.reason || `Conversation did not reach ${expectedState} state.`,
detail: last.detail || '',
};
}
// After clickConversationMenuItem opens an inline rename input, fill it and
// commit by pressing Enter. Returns the new title we set (best-effort).
export async function fillRenameInputAndSubmit(page, newTitle) {
const titleJson = JSON.stringify(newTitle);
return await page.evaluate(`(async () => {
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let input = null;
for (let attempt = 0; attempt < 15; attempt += 1) {
// The inline rename UI surfaces as a single visible <input> or a
// contenteditable inside the sidebar row.
input = Array.from(document.querySelectorAll('input[type="text"], [contenteditable="true"]:not(.ProseMirror)'))
.find((el) => el instanceof HTMLElement && el.offsetParent && el.getBoundingClientRect().left < 260);
if (input) break;
await waitFor(200);
}
if (!input) {
return { ok: false, reason: 'Inline rename input did not appear.' };
}
input.focus();
if (input instanceof HTMLInputElement) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, ${titleJson});
input.dispatchEvent(new Event('input', { bubbles: true }));
} else {
// contenteditable path
input.innerText = '';
document.execCommand('insertText', false, ${titleJson});
}
// Commit by Enter (Grok accepts Enter to confirm, Escape to cancel).
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
return { ok: true, value: ${titleJson} };
})()`);
}
export async function sendMessage(page, prompt) {
const promptJson = JSON.stringify(prompt);
return await page.evaluate(`(async () => {
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const composerSelector = '.ProseMirror[contenteditable="true"]';
const isVisible = (node) => {
if (!(node instanceof Element)) return false;
const style = window.getComputedStyle(node);
if (style.visibility === 'hidden' || style.display === 'none') return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const responseIdFor = (node) => {
let parent = node.parentElement;
while (parent && parent !== document.body) {
const id = parent.getAttribute('id') || '';
if (id.startsWith('response-')) return id.slice('response-'.length);
parent = parent.parentElement;
}
return '';
};
const userTurns = () => Array.from(document.querySelectorAll('[data-testid="user-message"]'))
.filter((node) => node instanceof HTMLElement && isVisible(node))
.map((node, index) => ({
id: responseIdFor(node) || ('pos-' + index),
text: normalize(node.innerText || node.textContent || ''),
}))
.filter((turn) => turn.text);
const promptText = normalize(${promptJson});
const beforeTurns = userTurns();
const beforeKeys = new Set(beforeTurns.map((turn) => turn.id + '\\n' + turn.text));
const waitForSubmittedUserTurn = async () => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const turns = userTurns();
const latest = turns[turns.length - 1];
const hasNewMatchingTurn = turns.some((turn) => turn.text === promptText && !beforeKeys.has(turn.id + '\\n' + turn.text));
const appendedMatchingTurn = turns.length > beforeTurns.length && latest?.text === promptText;
if (hasNewMatchingTurn || appendedMatchingTurn) return true;
await waitFor(250);
}
return false;
};
let composer = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
const candidate = document.querySelector(composerSelector);
if (candidate instanceof HTMLElement) { composer = candidate; break; }
await waitFor(500);
}
if (!(composer instanceof HTMLElement)) {
return { ok: false, reason: 'Grok composer (.ProseMirror) was not found on grok.com.' };
}
const editor = composer.editor;
if (!editor || !editor.commands || typeof editor.commands.focus !== 'function' || typeof editor.commands.insertContent !== 'function') {
return { ok: false, reason: 'Grok composer editor API was unavailable (page may still be loading).' };
}
try {
if (typeof editor.commands.clearContent === 'function') editor.commands.clearContent();
editor.commands.focus();
editor.commands.insertContent(${promptJson});
} catch (error) {
return {
ok: false,
reason: 'Failed to insert the prompt into the Grok composer.',
detail: error instanceof Error ? error.message : String(error),
};
}
const isClickableSubmit = (node) => {
if (!(node instanceof HTMLButtonElement)) return false;
if (node.disabled) return false;
return isVisible(node);
};
// Prefer data-testid (locale-independent); fall back to aria-label per
// language. As of 2026-05-31 Grok renders button[data-testid="chat-submit"]
// once the composer has content. The aria-label varies: "Submit" (en),
// "提交" (zh-CN), and presumably other locales.
const submitSelectors = [
'button[data-testid="chat-submit"]',
'button[aria-label="Submit"]',
'button[aria-label="提交"]',
'button[aria-label="送信"]',
];
let submit = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
for (const sel of submitSelectors) {
const candidate = Array.from(document.querySelectorAll(sel)).find(isClickableSubmit);
if (candidate instanceof HTMLButtonElement) { submit = candidate; break; }
}
if (submit) break;
await waitFor(500);
}
if (submit instanceof HTMLButtonElement) {
submit.click();
if (!(await waitForSubmittedUserTurn())) {
return { ok: false, reason: 'Grok submit button was clicked but no new user turn appeared.' };
}
return { ok: true, submittedVia: 'submit-button' };
}
// Fallback: some Grok deployments / locales never surface a
// submit-labelled button (see #1782); the composer commits on Enter
// when there is no Shift modifier, and Tiptap honours the synthetic
// keypress because the editor is focused. Dispatch a full keydown +
// keypress + keyup chain so any modifier / IME listener stays
// consistent with a real user pressing Enter.
const dispatchEnter = (target) => {
const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
target.dispatchEvent(new KeyboardEvent('keydown', opts));
target.dispatchEvent(new KeyboardEvent('keypress', opts));
target.dispatchEvent(new KeyboardEvent('keyup', opts));
};
try {
// Re-focus first; insertContent above may have moved focus elsewhere.
editor.commands.focus();
const focusTarget = document.activeElement instanceof HTMLElement ? document.activeElement : composer;
dispatchEnter(focusTarget);
if (!(await waitForSubmittedUserTurn())) {
return { ok: false, reason: 'Grok Enter-key fallback fired but no new user turn appeared.' };
}
return { ok: true, submittedVia: 'enter-key' };
} catch (error) {
return {
ok: false,
reason: 'Grok submit button never appeared and Enter-key fallback failed.',
detail: error instanceof Error ? error.message : String(error),
};
}
})()`);
}
const POLL_INTERVAL_SECONDS = 2;
const MIN_WAIT_MS = 6_000;
const STABLE_POLLS_REQUIRED = 2;
function stripNoise(text) {
return (text || '')
.replace(/\u00a0/g, ' ')
.trim();
}
export async function waitForAnswer(page, prompt, timeoutSeconds, baselineLastAssistantId) {
const startTime = Date.now();
let previousText = '';
let stableCount = 0;
let lastCandidate = '';
while (Date.now() - startTime < timeoutSeconds * 1000) {
await page.wait(POLL_INTERVAL_SECONDS);
const bubbles = await getMessageBubbles(page);
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
if (!lastAssistant) continue;
// Skip stale assistant turns from before our send: if the latest
// assistant ID matches the baseline, the new reply hasn't arrived yet.
if (baselineLastAssistantId && lastAssistant.id === baselineLastAssistantId) continue;
const text = stripNoise(lastAssistant.text);
if (!text || text === prompt.trim()) continue;
lastCandidate = text;
const waitedLongEnough = Date.now() - startTime >= MIN_WAIT_MS;
if (text === previousText) {
stableCount += 1;
if (waitedLongEnough && stableCount >= STABLE_POLLS_REQUIRED) {
return { status: 'ok', assistant: lastAssistant };
}
} else {
previousText = text;
stableCount = 0;
}
}
if (lastCandidate) {
const bubbles = await getMessageBubbles(page);
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
return { status: 'partial', assistant: lastAssistant };
}
return { status: 'timeout' };
}
export const __test__ = {
GROK_SESSION_ID_RE,
getPinStateFromMenuLabels,
};