From a76f01a8895866f8308e4f2145ea025a4b9044a5 Mon Sep 17 00:00:00 2001 From: Coderdan Date: Mon, 24 Aug 2026 16:09:35 +0800 Subject: [PATCH] Fix empty Pro response recovery --- README.md | 2 +- src/app.ts | 27 ++++-- src/transport.ts | 196 +++++++++++++++++++++++++++++++++++++++- tests/cli.test.ts | 25 ++++- tests/transport.test.ts | 147 ++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b80d32b..fc01870 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ ChatGPT does not expose a standalone limits endpoint, so counters refresh whenev ## Conversations -New `ask` and `job create` requests default to temporary ChatGPT conversations. Use `--save` when the turn should be written to ChatGPT history. +New non-Pro `ask` and `job create` requests default to temporary ChatGPT conversations. Pro text requests use a saved conversation internally so `pro-cli` can recover answers that finish after the initial stream, then delete it after a successful result. Use `--temporary` to force the old non-recoverable temporary mode, or `--save` to keep the turn in ChatGPT history. Deep Research and image generation require saved conversations because ChatGPT completes those workflows asynchronously outside the initial response stream. Image conversations are deleted automatically after the files download (see Image Generation); Deep Research conversations are kept. diff --git a/src/app.ts b/src/app.ts index a2f6e7f..42d5124 100644 --- a/src/app.ts +++ b/src/app.ts @@ -970,16 +970,29 @@ function applyModelConversationRequirements( flags: Map, model: string, ): void { - if (!modelRequiresSavedConversation(model)) return; - const label = canonicalModelId(model) === "image" ? "Image generation" : "Deep Research"; const store = readBooleanFlag(flags, "store"); const requestedTemporary = flagBoolean(flags, "temporary") || store === false; - if (requestedTemporary) { - throw invalidArgs(`${label} does not support temporary chats.`, [ - `Remove --temporary or --store false; pro-cli uses a saved ChatGPT conversation for --model ${canonicalModelId(model)}.`, - ]); + if (modelRequiresSavedConversation(model)) { + const label = canonicalModelId(model) === "image" ? "Image generation" : "Deep Research"; + if (requestedTemporary) { + throw invalidArgs(`${label} does not support temporary chats.`, [ + `Remove --temporary or --store false; pro-cli uses a saved ChatGPT conversation for --model ${canonicalModelId(model)}.`, + ]); + } + if (!options.conversationId) options.temporary = false; + return; + } + + const explicitConversationMode = + requestedTemporary || + flagBoolean(flags, "save") || + flagBoolean(flags, "no-temporary") || + store === true || + Boolean(options.conversationId); + if (!explicitConversationMode && canonicalModelId(model).endsWith("-pro")) { + options.temporary = false; + if (!flagBoolean(flags, "keep-conversation")) options.deleteConversationAfterResult = true; } - if (!options.conversationId) options.temporary = false; } function applyModelTimeoutDefaults( diff --git a/src/transport.ts b/src/transport.ts index b0a582e..451debf 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -15,6 +15,7 @@ const DEFAULT_RESEARCH_TASK_POLL_MS = 5_000; const DEFAULT_RESEARCH_WIDGET_POLL_MS = 30_000; const DEFAULT_RESEARCH_WIDGET_RATE_LIMIT_POLL_MS = 60_000; const DEFAULT_BROWSER_REQUEST_TIMEOUT_MS = 30 * 60_000; +const DEFAULT_CONVERSATION_RESULT_POLL_MS = 5_000; const MAX_REQUEST_TIMEOUT_MS = 24 * 60 * 60_000; const DEEP_RESEARCH_CONNECTOR_ID = "connector_openai_deep_research"; const DEEP_RESEARCH_ROUTER_MODEL = "gpt-5-5"; @@ -240,8 +241,13 @@ async function postChatGptJob( } const model = canonicalModelId(job.model); + const reconcileSavedResponse = + model !== "image" && + model !== "research" && + isSavedConversation(job) && + !stringOption(job.options.conversationId); const parsed = readResponse(browserResult.body, options.onLimits, { - allowEmptyWithConversation: model === "image", + allowEmptyWithConversation: model === "image" || reconcileSavedResponse, }); if (model === "image") { if (parsed.text.toLowerCase().includes("image generation isn") && parsed.text.toLowerCase().includes("temporary chat")) { @@ -298,14 +304,114 @@ async function postChatGptJob( ); } } - validateCompletedResult(job, parsed.text); - return parsed.text; + const reconciledText = reconcileSavedResponse && !parsed.text && parsed.conversationId + ? await waitForConversationResult(parsed.conversationId, evaluate, cdpBase, timeoutMs) + : null; + const resultText = reconciledText ?? parsed.text; + if (!resultText && reconcileSavedResponse && parsed.conversationId) { + throw emptyResponseError(parsed.conversationId, timeoutMs); + } + validateCompletedResult(job, resultText); + if (job.options.deleteConversationAfterResult === true && parsed.conversationId) { + await evaluate( + cdpBase, + buildConversationPatchExpression(parsed.conversationId, { is_visible: false }), + 30_000, + ).catch(() => null); + } + return resultText; } catch (error) { if (error instanceof ProError) throw error; throw networkError(error); } } +function isSavedConversation(job: JobRecord): boolean { + const conversationId = stringOption(job.options.conversationId); + return booleanOption(job.options.temporary, !conversationId) === false; +} + +function emptyResponseError(conversationId: string, timeoutMs: number): ProError { + return new ProError( + "EMPTY_RESPONSE", + "ChatGPT completed without returning assistant text, and the saved conversation did not expose a final answer before the reconciliation timeout.", + { + exitCode: timeoutMs > 0 ? EXIT.timeout : EXIT.upstream, + suggestions: [ + "Open the saved ChatGPT conversation to inspect whether the response is still running.", + "Increase --timeout for long GPT-5.6 Pro tasks before retrying the same real request.", + "Do not send a probe or smoke-test query; a saved conversation may still finish asynchronously.", + ], + details: { + conversationId, + reconciliation: "timed_out", + timeoutMs: timeoutMs || DEFAULT_BROWSER_REQUEST_TIMEOUT_MS, + }, + }, + ); +} + +async function waitForConversationResult( + conversationId: string, + evaluate: PageEvaluator, + cdpBase: string, + timeoutMs: number, +): Promise { + const startedAt = Date.now(); + const effectiveTimeoutMs = timeoutMs > 0 ? timeoutMs : DEFAULT_BROWSER_REQUEST_TIMEOUT_MS; + + while (true) { + const remainingMs = effectiveTimeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) return null; + + let result: BrowserConversationResultFetchResult; + try { + result = await evaluate( + cdpBase, + buildConversationResultFetchExpression(conversationId), + Math.min(remainingMs, 30_000), + ); + } catch (error) { + const cause = error instanceof ProError ? error : networkError(error); + throw new ProError( + "CONVERSATION_RESULT_UNAVAILABLE", + `Saved ChatGPT conversation ${conversationId} could not be checked for its final answer.`, + { + exitCode: cause.exitCode, + suggestions: [ + "Open the saved ChatGPT conversation to inspect the response directly.", + "Do not retry the original prompt; it may still finish in the saved conversation.", + ], + details: { conversationId, causeCode: cause.code }, + cause, + }, + ); + } + + if (result.ok && result.finalText) return result.finalText; + if (!result.ok && !isTransientConversationResultStatus(result.status)) { + throw new ProError( + "CONVERSATION_RESULT_UNAVAILABLE", + `Saved ChatGPT conversation ${conversationId} returned HTTP ${result.status}.`, + { + exitCode: EXIT.upstream, + suggestions: [ + "Open the saved ChatGPT conversation to inspect the response directly.", + "Run pro-cli doctor --json if saved conversations consistently fail to load.", + ], + details: { conversationId, status: result.status, preview: result.preview }, + }, + ); + } + + await sleep(Math.min(DEFAULT_CONVERSATION_RESULT_POLL_MS, Math.max(1, remainingMs))); + } +} + +function isTransientConversationResultStatus(status: number): boolean { + return status === 404 || status === 408 || status === 425 || status === 429 || status >= 500; +} + function shouldRecoverCookieBloat(result: BrowserFetchResult): boolean { return result.status === 431 || result.body.includes("chrome-error://chromewebdata/"); } @@ -1501,6 +1607,81 @@ async function waitForResearchWidgetFromConversation( return null; } +function buildConversationResultFetchExpression(conversationId: string): string { + return `(${async function fetchConversationResult(id: string): Promise { + const sessionResponse = await fetch("https://chatgpt.com/api/auth/session", { + credentials: "include", + referrerPolicy: "no-referrer", + }); + const session = (await sessionResponse.json().catch(() => null)) as { accessToken?: unknown } | null; + const accessToken = typeof session?.accessToken === "string" ? session.accessToken : ""; + const response = await fetch( + `https://chatgpt.com/backend-api/conversation/${encodeURIComponent(id)}`, + { + credentials: "include", + referrer: "https://chatgpt.com/", + headers: { + accept: "application/json", + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), + }, + }, + ); + const text = await response.text().catch(() => ""); + let body: unknown = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = null; + } + + const record = body && typeof body === "object" ? body as Record : {}; + const mapping = record.mapping && typeof record.mapping === "object" + ? record.mapping as Record + : {}; + const candidates: Array<{ text: string; status: string; endTurn: boolean | null; createTime: number; order: number }> = []; + let order = 0; + for (const node of Object.values(mapping)) { + const message = node?.message; + if (!message || typeof message !== "object") continue; + const messageRecord = message as Record; + const author = messageRecord.author && typeof messageRecord.author === "object" + ? messageRecord.author as Record + : {}; + if (author.role !== "assistant") continue; + const recipient = typeof messageRecord.recipient === "string" ? messageRecord.recipient : ""; + if (recipient && recipient !== "all") continue; + const metadata = messageRecord.metadata && typeof messageRecord.metadata === "object" + ? messageRecord.metadata as Record + : {}; + if (metadata.is_visually_hidden_from_conversation === true) continue; + const content = messageRecord.content && typeof messageRecord.content === "object" + ? messageRecord.content as Record + : {}; + const parts = Array.isArray(content.parts) + ? content.parts.filter((part): part is string => typeof part === "string").join("") + : typeof content.text === "string" ? content.text : ""; + const status = typeof messageRecord.status === "string" ? messageRecord.status.toLowerCase() : ""; + const endTurn = typeof messageRecord.end_turn === "boolean" ? messageRecord.end_turn : null; + const createTime = typeof messageRecord.create_time === "number" ? messageRecord.create_time : 0; + candidates.push({ text: parts, status, endTurn, createTime, order: order++ }); + } + + const latest = candidates.sort((left, right) => left.createTime - right.createTime || left.order - right.order).at(-1) ?? null; + const latestIsComplete = latest && ["finished_successfully", "completed", "complete"].includes(latest.status); + const latestHasUnknownStatus = latest && !latest.status; + const selected = latest && latest.endTurn !== false && (latestIsComplete || latestHasUnknownStatus) && latest.text.trim() + ? latest + : null; + return { + ok: response.ok, + status: response.status, + finalText: selected?.text ?? null, + statusText: selected?.status || null, + preview: text.replace(/\\s+/g, " ").slice(0, 240), + }; + }})(${JSON.stringify(conversationId)})`; +} + interface BrowserConversationTaskFetchResult { ok: boolean; status: number; @@ -1508,6 +1689,14 @@ interface BrowserConversationTaskFetchResult { preview?: string; } +interface BrowserConversationResultFetchResult { + ok: boolean; + status: number; + finalText: string | null; + statusText: string | null; + preview?: string; +} + interface BrowserConversationWidgetFetchResult { ok: boolean; status: number; @@ -2829,6 +3018,7 @@ function isAsyncArtifactError(code: string): boolean { code.startsWith("IMAGE_") || code.startsWith("RESEARCH_TASK_") || code.startsWith("RESEARCH_WIDGET_") || + code.startsWith("CONVERSATION_RESULT_") || code === "INCOMPLETE_RESEARCH_ACK" ); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 67b34fe..51bbdc7 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -232,6 +232,8 @@ describe("robot-mode CLI", () => { expect(created.data.job.prompt).toBe(""); expect(created.data.job.promptPreview).toBe("hello from agent"); expect(created.data.job.options.condensedResponseTokens).toBe(250); + expect(created.data.job.options.temporary).toBe(false); + expect(created.data.job.options.deleteConversationAfterResult).toBe(true); const status = await run(["job", "status", jobId, "--json"], { tty: true, home }); expect(status.code).toBe(0); @@ -239,6 +241,24 @@ describe("robot-mode CLI", () => { }); }); + test("preserves explicit temporary and saved Pro conversation modes", async () => { + await withHome(async (home) => { + const temporary = await run( + ["job", "create", "hello", "--no-start", "--temporary", "--json"], + { tty: true, home }, + ); + const saved = await run( + ["job", "create", "hello", "--no-start", "--save", "--json"], + { tty: true, home }, + ); + + expect(temporary.code).toBe(0); + expect(saved.code).toBe(0); + expect(JSON.parse(temporary.stdout).data.job.options).toEqual({ temporary: true }); + expect(JSON.parse(saved.stdout).data.job.options).toEqual({ temporary: false }); + }); + }); + test("creates durable GPT-4.5 and Deep Research jobs without reasoning effort", async () => { await withHome(async (home) => { const gpt45 = await run(["job", "create", "hello", "--no-start", "--model", "4.5", "--json"], { @@ -358,7 +378,8 @@ describe("robot-mode CLI", () => { const payload = JSON.parse(result.stdout); expect(payload.data.job.status).toBe("succeeded"); expect(payload.data.job.prompt).toBe(""); - expect(payload.data.job.options.temporary).toBe(true); + expect(payload.data.job.options.temporary).toBe(false); + expect(payload.data.job.options.deleteConversationAfterResult).toBe(true); expect(payload.data.result).toBe("OK"); expect(payload.data.agentInstruction).toContain("data.result is the primary deliverable"); expect(payload.data.agentInstruction).toContain("preserve Pro's prose language"); @@ -373,7 +394,7 @@ describe("robot-mode CLI", () => { expect(requestBody.model).toBe("gpt-5-5-pro"); expect(requestBody.thinking_effort).toBe("extended"); expect(requestBody.verbosity).toBe("low"); - expect(requestBody.history_and_training_disabled).toBe(true); + expect(requestBody.history_and_training_disabled).toBeUndefined(); expect(requestBody).not.toHaveProperty("text"); }); }); diff --git a/tests/transport.test.ts b/tests/transport.test.ts index 78938c0..2e9a221 100644 --- a/tests/transport.test.ts +++ b/tests/transport.test.ts @@ -1042,6 +1042,137 @@ describe("ChatGPT transport", () => { }); }); + test("recovers and deletes an auto-saved Pro response when the initial stream has no text", async () => { + await withTokenFile(async (sessionTokenPath) => { + const calls: string[] = []; + const pageEvaluator = (async (_base: string, expression: string): Promise => { + calls.push(expression); + if (expression.includes("fetchConversationResult")) { + return { + ok: true, + status: 200, + statusText: "finished_successfully", + finalText: "Recovered from the saved conversation.", + } as T; + } + if (expression.includes("is_visible")) { + return { ok: true, status: 200 } as T; + } + return { + ok: true, + status: 200, + body: savedEmptyConversationStream("conversation-reconcile"), + } as T; + }); + const savedJob = job(); + savedJob.options.temporary = false; + savedJob.options.deleteConversationAfterResult = true; + + const result = await runChatGptJob(savedJob, { sessionTokenPath, pageEvaluator }); + + expect(result).toBe("Recovered from the saved conversation."); + expect(calls).toHaveLength(3); + expect(calls[1]).toContain("fetchConversationResult"); + expect(calls[1]).toContain("is_visually_hidden_from_conversation"); + expect(calls[1]).toContain("end_turn"); + expect(calls[2]).toContain("is_visible"); + }); + }); + + test("does not delete an explicitly saved Pro conversation", async () => { + await withTokenFile(async (sessionTokenPath) => { + const calls: string[] = []; + const pageEvaluator = (async (_base: string, expression: string): Promise => { + calls.push(expression); + if (expression.includes("is_visible")) { + throw new Error("Explicitly saved conversations must not be deleted."); + } + return { + ok: true, + status: 200, + body: savedConversationStream("conversation-kept", "Kept answer."), + } as T; + }); + const savedJob = job(); + savedJob.options.temporary = false; + + const result = await runChatGptJob(savedJob, { sessionTokenPath, pageEvaluator }); + + expect(result).toBe("Kept answer."); + expect(calls).toHaveLength(1); + }); + }); + + test("does not reconcile a continued conversation to a stale assistant answer", async () => { + await withTokenFile(async (sessionTokenPath) => { + const calls: string[] = []; + const pageEvaluator = (async (_base: string, expression: string): Promise => { + calls.push(expression); + if (expression.includes("fetchConversationResult")) { + return { + ok: true, + status: 200, + statusText: "finished_successfully", + finalText: "Stale previous answer.", + } as T; + } + return { + ok: true, + status: 200, + body: savedEmptyConversationStream("conversation-continued"), + } as T; + }); + const continuedJob = job(); + continuedJob.options.temporary = false; + continuedJob.options.conversationId = "conversation-continued"; + continuedJob.options.parentMessageId = "previous-assistant-message"; + + try { + await runChatGptJob(continuedJob, { sessionTokenPath, pageEvaluator }); + throw new Error("Expected EMPTY_RESPONSE."); + } catch (error) { + expect(error).toBeInstanceOf(ProError); + expect((error as ProError).code).toBe("EMPTY_RESPONSE"); + } + expect(calls).toHaveLength(1); + }); + }); + + test("does not resubmit a Pro prompt when saved-result polling loses connectivity", async () => { + await withTokenFile(async (sessionTokenPath) => { + let submissions = 0; + const pageEvaluator = (async (_base: string, expression: string): Promise => { + if (expression.includes("fetchConversationResult")) throw new Error("CDP disconnected"); + submissions += 1; + return { + ok: true, + status: 200, + body: savedEmptyConversationStream("conversation-disconnected"), + } as T; + }); + const savedJob = job(); + savedJob.options.temporary = false; + + try { + await runChatGptJob(savedJob, { + sessionTokenPath, + pageEvaluator, + retries: 1, + retryDelayMs: 0, + }); + throw new Error("Expected CONVERSATION_RESULT_UNAVAILABLE."); + } catch (error) { + expect(error).toBeInstanceOf(ProError); + expect((error as ProError).code).toBe("CONVERSATION_RESULT_UNAVAILABLE"); + expect((error as ProError).details).toMatchObject({ + attempts: 1, + conversationId: "conversation-disconnected", + }); + } + expect(submissions).toBe(1); + }); + }); + test("reads patch-style /f/conversation streams", async () => { await withTokenFile(async (sessionTokenPath) => { const pageEvaluator = (async (): Promise => @@ -1423,6 +1554,22 @@ function conversationStream(text: string): string { ].join("\n\n"); } +function savedConversationStream(conversationId: string, text: string): string { + return [ + `data: {"conversation_id":${JSON.stringify(conversationId)},"message":{"author":{"role":"assistant"},"content":{"content_type":"text","parts":[${JSON.stringify(text)}]},"status":"finished_successfully"}}`, + "data: [DONE]", + "", + ].join("\n\n"); +} + +function savedEmptyConversationStream(conversationId: string): string { + return [ + `data: {"conversation_id":${JSON.stringify(conversationId)},"type":"message_stream_complete"}`, + "data: [DONE]", + "", + ].join("\n\n"); +} + function researchLaunchStream(taskId: string, acknowledgement: string): string { return [ `data: {"message":{"author":{"role":"assistant"},"content":{"content_type":"text","parts":[${JSON.stringify(acknowledgement)}]},"status":"finished_successfully"}}`,