From 36e8ff0c3c5c0d3b2dc9b0897930cfd17d907313 Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 2 Jul 2026 11:03:10 +0800 Subject: [PATCH 1/2] Improve ChatGPT Deep Research progress reporting --- cli-manifest.json | 9 + clis/chatgpt/commands.test.js | 19 +- clis/chatgpt/deep-research-result.js | 28 +- clis/chatgpt/utils.js | 369 +++++++++++++++++++++++---- clis/chatgpt/utils.test.js | 141 +++++++++- 5 files changed, 513 insertions(+), 53 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index dd7729ad2..a62ac34f6 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -6289,6 +6289,15 @@ "status", "report", "sources", + "progress", + "asyncTaskConversationId", + "widgetSessionId", + "asyncStatus", + "venusMessageType", + "venusStatus", + "waitingForUserUntil", + "planTitle", + "planId", "url", "method", "diagnostics" diff --git a/clis/chatgpt/commands.test.js b/clis/chatgpt/commands.test.js index d8d295da8..cacb8026f 100644 --- a/clis/chatgpt/commands.test.js +++ b/clis/chatgpt/commands.test.js @@ -130,7 +130,24 @@ describe('chatgpt browser command registration', () => { expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), expect.objectContaining({ name: 'stable', type: 'int', default: 6 }), ])); - expect(command.columns).toEqual(['conversationId', 'status', 'report', 'sources', 'url', 'method', 'diagnostics']); + expect(command.columns).toEqual([ + 'conversationId', + 'status', + 'report', + 'sources', + 'progress', + 'asyncTaskConversationId', + 'widgetSessionId', + 'asyncStatus', + 'venusMessageType', + 'venusStatus', + 'waitingForUserUntil', + 'planTitle', + 'planId', + 'url', + 'method', + 'diagnostics', + ]); }); it('does not return a success row when no completed deep research report exists', async () => { diff --git a/clis/chatgpt/deep-research-result.js b/clis/chatgpt/deep-research-result.js index a92ba8d82..a4d794b8b 100644 --- a/clis/chatgpt/deep-research-result.js +++ b/clis/chatgpt/deep-research-result.js @@ -29,7 +29,24 @@ export const deepResearchResultCommand = cli({ { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' }, { name: 'stable', type: 'int', default: 6, help: 'Seconds the report text must remain unchanged when --wait is true' }, ], - columns: ['conversationId', 'status', 'report', 'sources', 'url', 'method', 'diagnostics'], + columns: [ + 'conversationId', + 'status', + 'report', + 'sources', + 'progress', + 'asyncTaskConversationId', + 'widgetSessionId', + 'asyncStatus', + 'venusMessageType', + 'venusStatus', + 'waitingForUserUntil', + 'planTitle', + 'planId', + 'url', + 'method', + 'diagnostics', + ], func: async (page, kwargs) => { const id = parseChatGPTConversationId(kwargs.id); const shouldWait = normalizeBooleanFlag(kwargs.wait, false); @@ -71,6 +88,15 @@ export const deepResearchResultCommand = cli({ status: result.status, report: result.report || '', sources: result.sources || [], + progress: result.progress || {}, + asyncTaskConversationId: result.asyncTaskConversationId || '', + widgetSessionId: result.widgetSessionId || '', + asyncStatus: result.asyncStatus ?? '', + venusMessageType: result.venusMessageType || '', + venusStatus: result.venusStatus || '', + waitingForUserUntil: result.waitingForUserUntil || '', + planTitle: result.planTitle || '', + planId: result.planId || '', url: result.url || targetUrl, method: result.method || '', diagnostics: result.diagnostics || {}, diff --git a/clis/chatgpt/utils.js b/clis/chatgpt/utils.js index 2a187db5b..df1198ae0 100644 --- a/clis/chatgpt/utils.js +++ b/clis/chatgpt/utils.js @@ -764,18 +764,46 @@ export async function getCurrentChatGPTTool(page) { const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const labels = ${JSON.stringify(CHATGPT_TOOL_OPTIONS)}; + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const compact = (value) => normalize(value).toLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, ''); + const matchesLabel = (value, labels) => { + const normalized = normalize(value).toLowerCase(); + const compacted = compact(value); + if (!normalized && !compacted) return false; + return labels.some((label) => { + const normalizedLabel = normalize(label).toLowerCase(); + const compactedLabel = compact(label); + return normalized === normalizedLabel + || normalized.includes(normalizedLabel) + || (compactedLabel && compacted.includes(compactedLabel)); + }); + }; + const labels = ${JSON.stringify(CHATGPT_TOOL_OPTIONS)}; const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); const root = form || document.body; - const nodes = Array.from(root.querySelectorAll('button, [role="button"], [role="menuitemradio"], span, div')); + const nodes = Array.from(root.querySelectorAll('button, [role="button"], [role="menuitemradio"], [role="menuitem"], [role="option"], span, div[tabindex="0"], div')); const node = nodes.find((candidate) => { if (!isVisible(candidate)) return false; - const text = normalize(candidate.textContent); - return Object.values(labels).some((entry) => entry.labels.includes(text)); + const composerSelector = '[contenteditable="true"][role="textbox"], #prompt-textarea[contenteditable="true"], [data-testid="prompt-textarea"][contenteditable="true"]'; + const toolPill = candidate.closest('[contenteditable="false"]'); + if (!toolPill && (candidate.closest(composerSelector) || candidate.querySelector(composerSelector))) return false; + const haystacks = [ + candidate.textContent, + candidate.getAttribute('aria-label'), + candidate.getAttribute('title'), + candidate.getAttribute('data-testid'), + ]; + return Object.values(labels).some((entry) => haystacks.some((value) => matchesLabel(value, entry.labels))); }); - const label = normalize(node?.textContent || ''); - const entry = Object.entries(labels).find(([, value]) => value.labels.includes(label)); + const haystacks = node instanceof HTMLElement + ? [ + node.textContent, + node.getAttribute('aria-label'), + node.getAttribute('title'), + node.getAttribute('data-testid'), + ] + : []; + const entry = Object.entries(labels).find(([, value]) => haystacks.some((candidate) => matchesLabel(candidate, value.labels))); return { tool: entry?.[0] ?? null, label: entry?.[1]?.label ?? null, @@ -831,11 +859,41 @@ export async function selectChatGPTTool(page, tool) { return rect.width > 0 && rect.height > 0; }; const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const compact = (value) => normalize(value).toLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, ''); const labels = ${JSON.stringify(target.labels)}; - const options = Array.from(document.querySelectorAll('[role="menuitemradio"]')); - const option = options.find((node) => node instanceof HTMLElement && isVisible(node) && labels.includes(normalize(node.textContent))); + const optionSelector = '[role="menuitemradio"], [role="menuitem"], [role="option"], button, div[tabindex="0"]'; + const matchesLabel = (value) => { + const normalized = normalize(value).toLowerCase(); + const compacted = compact(value); + if (!normalized && !compacted) return false; + return labels.some((label) => { + const normalizedLabel = normalize(label).toLowerCase(); + const compactedLabel = compact(label); + return normalized === normalizedLabel + || normalized.includes(normalizedLabel) + || (compactedLabel && compacted.includes(compactedLabel)); + }); + }; + const rootSelector = '[role="menu"], [role="listbox"], [data-radix-popper-content-wrapper], [data-radix-menu-content], [data-testid*="menu"], [data-testid*="popover"]'; + const visibleRoots = Array.from(document.querySelectorAll(rootSelector)) + .filter((node) => node instanceof HTMLElement && isVisible(node) && !node.closest('nav, aside')); + const searchRoots = visibleRoots.length ? visibleRoots : [document]; + const options = Array.from(new Set(searchRoots.flatMap((root) => { + const matchesRoot = root instanceof HTMLElement && root.matches(optionSelector) ? [root] : []; + return matchesRoot.concat(Array.from(root.querySelectorAll(optionSelector))); + }))); + const option = options.find((node) => { + if (!(node instanceof HTMLElement) || !isVisible(node) || node.closest('nav, aside')) return false; + const haystacks = [ + node.textContent, + node.getAttribute('aria-label'), + node.getAttribute('title'), + node.getAttribute('data-testid'), + ]; + return haystacks.some(matchesLabel); + }); if (!(option instanceof HTMLElement)) return { found: false }; - const checked = option.getAttribute('aria-checked') === 'true'; + const checked = option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true'; option.scrollIntoView({ block: 'center', inline: 'center' }); const rect = option.getBoundingClientRect(); return { @@ -917,11 +975,7 @@ export function parseChatGPTProjectId(value) { ); } -/** - * Send a message to the ChatGPT composer and submit it. - * Returns true if the message was sent successfully. - */ -export async function sendChatGPTMessage(page, text) { +async function closeChatGPTSidebar(page) { // Close sidebar if open (it can cover the chat composer) await page.evaluate(` (() => { @@ -930,6 +984,14 @@ export async function sendChatGPTMessage(page, text) { if (closeBtn) closeBtn.click(); })() `); +} + +/** + * Clear and fill the ChatGPT composer without submitting it. + * Returns true if the composer was ready and the text was inserted. + */ +async function fillChatGPTMessage(page, text) { + await closeChatGPTSidebar(page); // The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next // page.evaluate roundtrip flushes the close-sidebar React update and // findComposer() retries inside a single CDP call, so no fixed sleep is @@ -944,8 +1006,24 @@ export async function sendChatGPTMessage(page, text) { if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { composer.value = ''; } else if (composer.isContentEditable) { - composer.textContent = ''; - composer.innerHTML = '


'; + const preserved = Array.from(composer.querySelectorAll('[contenteditable="false"]')).map((node) => node.cloneNode(true)); + if (preserved.length) { + const p = document.createElement('p'); + for (const node of preserved) { + p.appendChild(node); + p.appendChild(document.createTextNode(' ')); + } + composer.replaceChildren(p); + } else { + composer.textContent = ''; + composer.innerHTML = '


'; + } + const range = document.createRange(); + range.selectNodeContents(composer); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); } else { composer.textContent = ''; } @@ -955,6 +1033,7 @@ export async function sendChatGPTMessage(page, text) { const rect = composer.getBoundingClientRect(); return { ready: true, + contentEditable: !!composer.isContentEditable, x: Math.round(rect.left + Math.max(8, Math.min(rect.width / 2, rect.width - 8))), y: Math.round(rect.top + Math.max(8, Math.min(rect.height / 2, rect.height - 8))), }; @@ -970,6 +1049,22 @@ export async function sendChatGPTMessage(page, text) { await page.nativeClick(Number(typeResult.x), Number(typeResult.y)); await page.wait(0.2); } + if (typeResult.contentEditable) { + await page.evaluate(` + (() => { + ${buildComposerLocatorScript()} + const composer = findComposer(); + if (!composer || !composer.isContentEditable) return; + composer.focus(); + const range = document.createRange(); + range.selectNodeContents(composer); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + })() + `); + } await page.nativeType(text); } else { throw new Error('nativeType unavailable'); @@ -988,6 +1083,14 @@ export async function sendChatGPTMessage(page, text) { `); } + return true; +} + +/** + * Submit the current ChatGPT composer contents. + * Returns true if the message was sent successfully. + */ +async function submitChatGPTMessage(page) { let sent = null; for (let attempt = 0; attempt < 20; attempt += 1) { await page.wait(0.5); @@ -1264,25 +1367,147 @@ function extractDeepResearchSourcesFromReportMessage(reportMessage) { return [...byUrl.values()].slice(0, 200); } -function extractDeepResearchFromWidgetState(widgetState, source = 'conversation-widget-state') { - const state = parseJsonMaybe(widgetState); - if (!state || typeof state !== 'object') return null; - const reportMessage = state.report_message || state.reportMessage || null; - const parts = Array.isArray(reportMessage?.content?.parts) ? reportMessage.content.parts : []; - const report = normalizeDeepResearchText(parts.filter((part) => typeof part === 'string').join('\n\n')); - if (!looksLikeDeepResearchReport(report)) return null; +function pickFirstObject(...values) { + for (const value of values) { + const parsed = parseJsonMaybe(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } + return {}; +} + +function stringOrEmpty(value) { + return value === undefined || value === null ? '' : String(value); +} + +function compactDeepResearchPlanSteps(plan, stepStatusesByPlan) { + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + return steps.slice(0, 50).map((step, index) => { + const id = stringOrEmpty(step?.id || step?.step_id || step?.plan_step_id || step?.key || index); + return { + id, + title: stringOrEmpty(step?.title || step?.name || step?.summary || step?.description), + status: stringOrEmpty(step?.status || step?.step_status || stepStatusesByPlan?.[id] || ''), + }; + }).filter((step) => step.id || step.title || step.status); +} + +function deepResearchProgressStatus(progress) { + const venusStatus = String(progress.venusStatus || '').toLowerCase(); + if (/waiting_for_user|user_response/.test(venusStatus)) return 'waiting_for_user'; + if (/needs_user|user_action|action_required|requires_action/.test(venusStatus)) return 'needs_user_action'; + if (/running|in_progress|loading|generating|researching|queued|started|processing/.test(venusStatus)) return 'running'; + + const venusMessageType = String(progress.venusMessageType || '').toLowerCase(); + if (/loading|running|generating|research|progress/.test(venusMessageType)) return 'running'; + + if (progress.asyncTaskConversationId + || progress.widgetSessionId + || progress.asyncStatus !== undefined + || progress.venusStatus + || progress.planId + || progress.planTitle) { + return 'not_ready'; + } + return ''; +} + +function buildDeepResearchProgressResult(state, responseMetadata, source) { + const widgetState = state && typeof state === 'object' ? state : {}; + const response = responseMetadata && typeof responseMetadata === 'object' ? responseMetadata : {}; + const plan = pickFirstObject(widgetState.plan, widgetState.current_plan, widgetState.research_plan); + const stepStatusesByPlan = pickFirstObject( + widgetState.step_statuses_by_plan, + widgetState.stepStatusesByPlan, + widgetState.step_statuses, + ); + const progress = { + asyncTaskConversationId: stringOrEmpty( + response.async_task_conversation_id + || response.asyncTaskConversationId + || response['openai/asyncTaskConversationId'], + ), + widgetSessionId: stringOrEmpty( + response['openai/widgetSessionId'] + || response.widget_session_id + || response.widgetSessionId, + ), + asyncStatus: response['openai/asyncStatus'] ?? response.async_status ?? response.asyncStatus, + venusMessageType: stringOrEmpty(response.venus_message_type || response.venusMessageType), + venusStatus: stringOrEmpty(widgetState.status || widgetState.venus_status || widgetState.venusStatus), + waitingForUserUntil: stringOrEmpty( + widgetState.waiting_for_user_response_on_plan_until + || widgetState.waitingForUserResponseOnPlanUntil + || widgetState.waiting_for_user_until, + ), + planId: stringOrEmpty(plan.plan_id || plan.planId || plan.id), + planTitle: stringOrEmpty(plan.title || plan.name), + planSteps: compactDeepResearchPlanSteps(plan, stepStatusesByPlan), + stepStatusesByPlan, + }; + const status = deepResearchProgressStatus(progress); + if (!status) return null; + + if (/^completed$/i.test(progress.venusStatus) + && !progress.asyncTaskConversationId + && !progress.widgetSessionId + && progress.asyncStatus === undefined + && !progress.venusMessageType + && !progress.planId + && !progress.planTitle + && !progress.planSteps.length + && !Object.keys(progress.stepStatusesByPlan || {}).length) { + return null; + } + return { - status: 'completed', - report, + status, + report: '', html: '', - method: source, - sources: extractDeepResearchSourcesFromReportMessage(reportMessage), - widgetStatus: String(state.status || ''), - reportMessageId: String(reportMessage?.id || ''), - reportLength: report.length, + method: source.includes('widget-state') ? source.replace('widget-state', 'widget-progress') : `${source}-progress`, + sources: [], + progress, + asyncTaskConversationId: progress.asyncTaskConversationId, + widgetSessionId: progress.widgetSessionId, + asyncStatus: progress.asyncStatus, + venusMessageType: progress.venusMessageType, + venusStatus: progress.venusStatus, + waitingForUserUntil: progress.waitingForUserUntil, + planId: progress.planId, + planTitle: progress.planTitle, }; } +function deepResearchCandidateScore(candidate) { + if (!candidate) return 0; + if (candidate.status === 'completed') return 1000000 + (candidate.reportLength || candidate.report?.length || 0); + if (candidate.status === 'waiting_for_user' || candidate.status === 'needs_user_action') return 500000; + if (candidate.status === 'running') return 400000; + if (candidate.status === 'not_ready') return 300000; + return 1; +} + +function extractDeepResearchFromWidgetState(widgetState, source = 'conversation-widget-state', responseMetadata = null) { + const state = parseJsonMaybe(widgetState); + if ((!state || typeof state !== 'object') && !responseMetadata) return null; + const widgetStateObject = state && typeof state === 'object' ? state : {}; + const reportMessage = widgetStateObject.report_message || widgetStateObject.reportMessage || null; + const parts = Array.isArray(reportMessage?.content?.parts) ? reportMessage.content.parts : []; + const report = normalizeDeepResearchText(parts.filter((part) => typeof part === 'string').join('\n\n')); + if (looksLikeDeepResearchReport(report)) { + return { + status: 'completed', + report, + html: '', + method: source, + sources: extractDeepResearchSourcesFromReportMessage(reportMessage), + widgetStatus: String(widgetStateObject.status || ''), + reportMessageId: String(reportMessage?.id || ''), + reportLength: report.length, + }; + } + return buildDeepResearchProgressResult(widgetStateObject, pickFirstObject(responseMetadata), source); +} + function extractDeepResearchFromConversationPayload(payload, { expectedConversationId = '' } = {}) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction.'); @@ -1300,14 +1525,33 @@ function extractDeepResearchFromConversationPayload(payload, { expectedConversat const candidates = []; for (const [messageId, node] of Object.entries(mapping)) { const message = node?.message || {}; - const sdk = message?.metadata?.chatgpt_sdk; + const metadata = message?.metadata || {}; + const sdk = metadata?.chatgpt_sdk || {}; + const responseMetadata = pickFirstObject( + sdk?.response_metadata, + sdk?.responseMetadata, + metadata?.response_metadata, + metadata?.responseMetadata, + ); + let sawWidgetState = false; for (const widgetState of [ sdk?.widget_state, sdk?.widgetState, - message?.metadata?.widget_state, - message?.metadata?.widgetState, + metadata?.widget_state, + metadata?.widgetState, ]) { - const extracted = extractDeepResearchFromWidgetState(widgetState); + if (widgetState === undefined || widgetState === null) continue; + sawWidgetState = true; + const extracted = extractDeepResearchFromWidgetState(widgetState, 'conversation-widget-state', responseMetadata); + if (extracted) { + candidates.push({ + ...extracted, + conversationMessageId: messageId, + }); + } + } + if (!sawWidgetState && Object.keys(responseMetadata).length) { + const extracted = extractDeepResearchFromWidgetState(null, 'conversation-widget-state', responseMetadata); if (extracted) { candidates.push({ ...extracted, @@ -1316,7 +1560,7 @@ function extractDeepResearchFromConversationPayload(payload, { expectedConversat } } } - candidates.sort((a, b) => b.report.length - a.report.length); + candidates.sort((a, b) => deepResearchCandidateScore(b) - deepResearchCandidateScore(a)); return candidates[0] || null; } @@ -1340,12 +1584,14 @@ function extractDeepResearchFromNetworkEntries(entries, { expectedConversationId if (extracted) { candidates.push({ ...extracted, - method: 'network-conversation-widget-state', + method: extracted.status === 'completed' + ? 'network-conversation-widget-state' + : 'network-conversation-widget-progress', networkUrl: url, }); } } - candidates.sort((a, b) => b.report.length - a.report.length); + candidates.sort((a, b) => deepResearchCandidateScore(b) - deepResearchCandidateScore(a)); return candidates[0] || null; } @@ -1579,6 +1825,7 @@ export async function getChatGPTDeepResearchResult(page, { conversationId = '', ); } } + let progressCandidate = null; const diagnostics = { iframeCount: Array.isArray(iframeState.iframes) ? iframeState.iframes.length : 0, iframe: iframe ? { @@ -1614,19 +1861,26 @@ export async function getChatGPTDeepResearchResult(page, { conversationId = '', const extracted = extractDeepResearchFromNetworkEntries(relevantEntries, { expectedConversationId: conversationId }); if (extracted) { diagnostics.networkConversation = { - foundReport: true, + foundReport: extracted.status === 'completed', + foundProgress: extracted.status !== 'completed', + status: extracted.status, reportLength: extracted.reportLength || extracted.report.length, sourceCount: Array.isArray(extracted.sources) ? extracted.sources.length : 0, + venusStatus: extracted.venusStatus || '', + asyncTaskConversationId: extracted.asyncTaskConversationId || '', }; - return { - status: 'completed', - report: extracted.report, - html: '', - url: iframeState.url, - method: extracted.method, - sources: extracted.sources || [], - diagnostics, - }; + if (extracted.status === 'completed') { + return { + status: 'completed', + report: extracted.report, + html: '', + url: iframeState.url, + method: extracted.method, + sources: extracted.sources || [], + diagnostics, + }; + } + progressCandidate = extracted; } } catch (error) { if (error instanceof CommandExecutionError) throw error; @@ -1649,12 +1903,16 @@ export async function getChatGPTDeepResearchResult(page, { conversationId = '', status: conversation?.status || 0, contentType: conversation?.contentType || '', transport: conversation?.transport || '', - foundReport: !!extracted, + foundReport: extracted?.status === 'completed', + foundProgress: !!extracted && extracted.status !== 'completed', + deepResearchStatus: extracted?.status || '', reportLength: extracted?.reportLength || 0, widgetStatus: extracted?.widgetStatus || '', + venusStatus: extracted?.venusStatus || '', + asyncTaskConversationId: extracted?.asyncTaskConversationId || '', sourceCount: Array.isArray(extracted?.sources) ? extracted.sources.length : 0, }; - if (extracted) { + if (extracted?.status === 'completed') { return { status: 'completed', report: extracted.report, @@ -1665,6 +1923,7 @@ export async function getChatGPTDeepResearchResult(page, { conversationId = '', diagnostics, }; } + if (extracted) progressCandidate = extracted; } } catch (error) { if (error instanceof CommandExecutionError) throw error; @@ -1672,6 +1931,14 @@ export async function getChatGPTDeepResearchResult(page, { conversationId = '', } } + if (progressCandidate) { + return { + ...progressCandidate, + url: iframeState.url, + diagnostics, + }; + } + if (iframe?.text && looksLikeDeepResearchReport(iframe.text)) { return { status: 'completed', @@ -1829,6 +2096,8 @@ export async function waitForChatGPTDeepResearchResult(page, { conversationId = lastReport = result.report; stableStartedAt = Date.now(); } + } else if (result.status !== 'running' && result.status !== 'not_ready') { + return result; } await page.sleep(3); } diff --git a/clis/chatgpt/utils.test.js b/clis/chatgpt/utils.test.js index d22f225f9..89545a3bb 100644 --- a/clis/chatgpt/utils.test.js +++ b/clis/chatgpt/utils.test.js @@ -4,7 +4,7 @@ import path from 'node:path'; import { JSDOM } from 'jsdom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; -import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTResponsePairCounts, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, getVisibleMessages, isGenerating, navigateToProject, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDetailRows, waitForChatGPTImages, waitForChatGPTResponse } from './utils.js'; +import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTResponsePairCounts, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, getVisibleMessages, isGenerating, navigateToProject, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDeepResearchResult, waitForChatGPTDetailRows, waitForChatGPTImages, waitForChatGPTResponse } from './utils.js'; const tempDirs = []; @@ -189,6 +189,41 @@ function makeDeepResearchPayload(report = makeDeepResearchReport(), { conversati return payload; } +function makeDeepResearchProgressPayload() { + return { + mapping: { + progress_node: { + message: { + metadata: { + chatgpt_sdk: { + widget_state: JSON.stringify({ + status: 'waiting_for_user_response_on_plan', + waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z', + plan: { + plan_id: 'plan-demo', + title: 'LLM 不确定性推理调研计划', + steps: [ + { id: 'step-1', title: 'Collect sources', status: 'pending' }, + ], + }, + step_statuses_by_plan: { + 'step-1': 'pending', + }, + }), + response_metadata: { + async_task_conversation_id: '6a45ccdd-811c-83e8-af35-ca4df176e91e', + 'openai/widgetSessionId': 'a9c86290-7600-4c7b-a4cb-11229ff0fa4c', + 'openai/asyncStatus': 7, + venus_message_type: 'initial_loading_message', + }, + }, + }, + }, + }, + }, + }; +} + describe('chatgpt deep research result extraction', () => { it('extracts report markdown and sources from conversation widget_state', () => { const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload()); @@ -252,9 +287,113 @@ describe('chatgpt deep research result extraction', () => { .toThrow(CommandExecutionError); }); + it('extracts waiting-for-user progress from widget metadata without a report', () => { + const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchProgressPayload()); + + expect(result).toMatchObject({ + status: 'waiting_for_user', + method: 'conversation-widget-progress', + asyncTaskConversationId: '6a45ccdd-811c-83e8-af35-ca4df176e91e', + widgetSessionId: 'a9c86290-7600-4c7b-a4cb-11229ff0fa4c', + asyncStatus: 7, + venusMessageType: 'initial_loading_message', + venusStatus: 'waiting_for_user_response_on_plan', + waitingForUserUntil: '2026-07-02T02:29:48.298274Z', + planId: 'plan-demo', + planTitle: 'LLM 不确定性推理调研计划', + }); + expect(result.report).toBe(''); + expect(result.progress.planSteps).toEqual([ + { id: 'step-1', title: 'Collect sources', status: 'pending' }, + ]); + }); + it('ignores short widget previews that are not completed reports', () => { expect(__test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload('short preview'))).toBeNull(); }); + + it('stops waiting immediately when widget state needs user input', async () => { + const page = { + getCookies: vi.fn().mockResolvedValue([]), + wait: vi.fn().mockResolvedValue(undefined), + sleep: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn((script) => { + const source = String(script); + if (source.includes("document.querySelectorAll('iframe')")) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/6a45ccd8-23b8-83ee-9dbc-6a51d1c8195e', + title: 'ChatGPT', + iframes: [], + deepResearchIframe: null, + }); + } + if (source.includes('Stop generating') || source.includes('Thinking')) { + return Promise.resolve(false); + } + if (source.includes('/backend-api/conversation/')) { + return Promise.resolve({ + ok: true, + status: 200, + contentType: 'application/json', + text: JSON.stringify(makeDeepResearchProgressPayload()), + }); + } + return Promise.resolve(undefined); + }), + }; + + const result = await waitForChatGPTDeepResearchResult(page, { timeoutSeconds: 180, stableSeconds: 3 }); + + expect(result.status).toBe('waiting_for_user'); + expect(result.venusStatus).toBe('waiting_for_user_response_on_plan'); + expect(page.wait).not.toHaveBeenCalledWith(3); + }); + + it('polls deep research progress with pure client-side sleeps', async () => { + let now = 0; + vi.spyOn(Date, 'now').mockImplementation(() => { + now += 2000; + return now; + }); + const payload = makeDeepResearchProgressPayload(); + const widgetState = JSON.parse(payload.mapping.progress_node.message.metadata.chatgpt_sdk.widget_state); + widgetState.status = 'running'; + payload.mapping.progress_node.message.metadata.chatgpt_sdk.widget_state = JSON.stringify(widgetState); + const page = { + getCookies: vi.fn().mockResolvedValue([]), + wait: vi.fn().mockResolvedValue(undefined), + sleep: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn((script) => { + const source = String(script); + if (source.includes("document.querySelectorAll('iframe')")) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/6a45ccd8-23b8-83ee-9dbc-6a51d1c8195e', + title: 'ChatGPT', + iframes: [], + deepResearchIframe: null, + }); + } + if (source.includes('Stop generating') || source.includes('Thinking')) { + return Promise.resolve(false); + } + if (source.includes('/backend-api/conversation/')) { + return Promise.resolve({ + ok: true, + status: 200, + contentType: 'application/json', + text: JSON.stringify(payload), + }); + } + return Promise.resolve(undefined); + }), + }; + + await expect(waitForChatGPTDeepResearchResult(page, { timeoutSeconds: 5, stableSeconds: 3 })) + .rejects.toThrow(/deep-research-result timed out/); + + expect(page.sleep).toHaveBeenCalledWith(3); + expect(page.wait).not.toHaveBeenCalledWith(3); + }); }); describe('chatgpt model selection validation', () => { From 7ca12b72b36b010e1f90aa39568fa25b65226a80 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 12 Jul 2026 01:22:47 +0800 Subject: [PATCH 2/2] fix(chatgpt): preserve deep research progress rows --- clis/chatgpt/commands.test.js | 87 ++++++++++++++++++++++++++++ clis/chatgpt/deep-research-result.js | 18 +++++- clis/chatgpt/utils.js | 10 ++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/clis/chatgpt/commands.test.js b/clis/chatgpt/commands.test.js index cacb8026f..ed0a4a9be 100644 --- a/clis/chatgpt/commands.test.js +++ b/clis/chatgpt/commands.test.js @@ -195,6 +195,93 @@ describe('chatgpt browser command registration', () => { .rejects.toBeInstanceOf(EmptyResultError); }); + it('returns structured deep research progress rows without a completed report', async () => { + const command = getRegistry().get('chatgpt/deep-research-result'); + const payload = { + conversation_id: 'requested123', + mapping: { + progress_node: { + message: { + metadata: { + chatgpt_sdk: { + widget_state: JSON.stringify({ + status: 'waiting_for_user_response_on_plan', + waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z', + plan: { + plan_id: 'plan-demo', + title: 'Research plan', + }, + }), + response_metadata: { + async_task_conversation_id: 'async-conversation-123', + 'openai/widgetSessionId': 'widget-session-123', + 'openai/asyncStatus': 7, + venus_message_type: 'initial_loading_message', + }, + }, + }, + }, + }, + }, + }; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(true), + readNetworkCapture: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + evaluate: vi.fn((script) => { + const s = String(script); + if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/'); + if (s.includes("fetch('/backend-api/conversation/requested123'")) { + return Promise.resolve({ + ok: true, + status: 200, + contentType: 'application/json', + text: JSON.stringify(payload), + }); + } + if (s.includes("document.querySelectorAll('iframe')")) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/requested123', + title: 'ChatGPT', + iframes: [], + deepResearchIframe: null, + }); + } + if (s.includes('composerSelectors') && s.includes('hasComposer')) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/requested123', + title: 'ChatGPT', + hasComposer: true, + isLoggedIn: true, + hasLoginGate: false, + }); + } + if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false); + return Promise.resolve(undefined); + }), + }; + + await expect(command.func(page, { id: 'requested123' })).resolves.toEqual([ + expect.objectContaining({ + conversationId: 'requested123', + status: 'waiting_for_user', + report: '', + sources: [], + asyncTaskConversationId: 'async-conversation-123', + widgetSessionId: 'widget-session-123', + asyncStatus: 7, + venusMessageType: 'initial_loading_message', + venusStatus: 'waiting_for_user_response_on_plan', + waitingForUserUntil: '2026-07-02T02:29:48.298274Z', + planTitle: 'Research plan', + planId: 'plan-demo', + method: 'conversation-widget-progress', + }), + ]); + }); + it('typed-fails malformed deep research source rows instead of falling back to empty success', async () => { const command = getRegistry().get('chatgpt/deep-research-result'); const report = `# Executive Summary\n\n${'Completed Deep Research report paragraph with enough detail to pass extraction heuristics. '.repeat(12)}\n\n## Sources`; diff --git a/clis/chatgpt/deep-research-result.js b/clis/chatgpt/deep-research-result.js index a4d794b8b..ea5907f32 100644 --- a/clis/chatgpt/deep-research-result.js +++ b/clis/chatgpt/deep-research-result.js @@ -13,6 +13,15 @@ import { waitForChatGPTDeepResearchResult, } from './utils.js'; +function hasDeepResearchProgress(result) { + return !!result + && result.status !== 'completed' + && result.progress + && typeof result.progress === 'object' + && !Array.isArray(result.progress) + && Object.keys(result.progress).length > 0; +} + export const deepResearchResultCommand = cli({ site: 'chatgpt', name: 'deep-research-result', @@ -76,7 +85,14 @@ export const deepResearchResultCommand = cli({ ? await waitForChatGPTDeepResearchResult(page, { conversationId: id, timeoutSeconds: timeout, stableSeconds }) : await getChatGPTDeepResearchResult(page, { conversationId: id, useBridgeProbes: true }); - if (result.status !== 'completed' || !result.report) { + if (result.status !== 'completed' && !hasDeepResearchProgress(result)) { + throw new EmptyResultError( + 'chatgpt deep-research-result', + `No completed Deep Research report was found for conversation ${id}.`, + ); + } + + if (result.status === 'completed' && !result.report) { throw new EmptyResultError( 'chatgpt deep-research-result', `No completed Deep Research report was found for conversation ${id}.`, diff --git a/clis/chatgpt/utils.js b/clis/chatgpt/utils.js index df1198ae0..726a7f58f 100644 --- a/clis/chatgpt/utils.js +++ b/clis/chatgpt/utils.js @@ -1163,6 +1163,16 @@ async function submitChatGPTMessage(page) { return true; } +/** + * Send a message to the ChatGPT composer and submit it. + * Returns true if the message was sent successfully. + */ +export async function sendChatGPTMessage(page, text) { + const filled = await fillChatGPTMessage(page, text); + if (!filled) return false; + return submitChatGPTMessage(page); +} + export async function getVisibleMessages(page, { textOnly = false } = {}) { // textOnly skips the per-turn innerHTML read (used only for --markdown // rendering) so poll loops that need only role + text don't allocate a