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..ed0a4a9be 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 () => {
@@ -178,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 a92ba8d82..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',
@@ -29,7 +38,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);
@@ -59,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}.`,
@@ -71,6 +104,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..726a7f58f 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);
@@ -1060,6 +1163,16 @@ export async function sendChatGPTMessage(page, text) {
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
@@ -1264,25 +1377,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 +1535,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 +1570,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 +1594,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 +1835,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 +1871,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 +1913,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 +1933,7 @@ export async function getChatGPTDeepResearchResult(page, { conversationId = '',
diagnostics,
};
}
+ if (extracted) progressCandidate = extracted;
}
} catch (error) {
if (error instanceof CommandExecutionError) throw error;
@@ -1672,6 +1941,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 +2106,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', () => {