From 2157ab73eb5445ebcfbb7ebe52fbf988352334d6 Mon Sep 17 00:00:00 2001 From: Dzmitry Bachko Date: Wed, 22 Jul 2026 11:10:11 -0700 Subject: [PATCH 1/4] fix(browser): ignore transient conversation URLs --- CHANGELOG.md | 1 + src/browser/conversationUrl.ts | 17 +++++++++++++ src/browser/conversationUrlMonitor.ts | 7 ++---- src/browser/index.ts | 13 +++------- src/browser/liveTabs.ts | 4 +-- src/browser/reattachHelpers.ts | 7 +++--- tests/browser/conversationUrlMonitor.test.ts | 26 ++++++++++++++++++++ tests/browser/reattach.test.ts | 5 ++++ 8 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 src/browser/conversationUrl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bed14c5d..beaa64323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Browser: ignore transient `/c/WEB:` routes until ChatGPT exposes the durable conversation URL, preventing completed GPT-5.6 and Pro answers from hanging until timeout under a mismatched response scope. Fixes #333. Thanks @kesslerio! - Browser: recover completed answers after a recoverable DevTools disconnect by confirming target liveness and attempting bounded reattachment, while preserving fail-closed handling for unavailable targets. Fixes #326. Thanks @piyushbag! - CLI: avoid inheriting `browser.thinkingTime` from config when `--browser-model-strategy current` is explicit, while preserving an explicit `--browser-thinking-time` override. Thanks @jung0han! - Browser/Serve: keep the authenticated manual-login Chrome process alive while closing each successfully captured service-owned run tab, preventing renderer and memory accumulation across repeated remote consultations without changing explicit `--browser-keep-browser`, attached-tab, or incomplete-run recovery behavior. Thanks @rtl-ai! diff --git a/src/browser/conversationUrl.ts b/src/browser/conversationUrl.ts new file mode 100644 index 000000000..6e9aef6f3 --- /dev/null +++ b/src/browser/conversationUrl.ts @@ -0,0 +1,17 @@ +const CONVERSATION_ID_PATH = /\/c\/([a-zA-Z0-9-]+)(?=[/?#]|$)/; + +/** + * Extract a durable ChatGPT conversation id from a URL. + * + * ChatGPT can briefly expose client-created routes such as `/c/WEB:` + * before replacing them with the persisted conversation URL. Those transient + * routes must not be used to scope assistant-response capture or reattachment. + */ +export function extractStableConversationIdFromUrl(url: string): string | undefined { + if (!url) return undefined; + return url.match(CONVERSATION_ID_PATH)?.[1]; +} + +export function isStableConversationUrl(url: string): boolean { + return extractStableConversationIdFromUrl(url) !== undefined; +} diff --git a/src/browser/conversationUrlMonitor.ts b/src/browser/conversationUrlMonitor.ts index 0b33d3207..ca6c655f6 100644 --- a/src/browser/conversationUrlMonitor.ts +++ b/src/browser/conversationUrlMonitor.ts @@ -1,5 +1,6 @@ import type { BrowserLogger } from "./types.js"; import { delay } from "./utils.js"; +import { isStableConversationUrl } from "./conversationUrl.js"; export interface ConversationUrlMonitor { update: (label: string, timeoutMs?: number) => Promise; @@ -31,7 +32,7 @@ export function createConversationUrlMonitor(options: { if (stopped) { return false; } - if (url && isConversationUrl(url)) { + if (url && isStableConversationUrl(url)) { options.logger(`[browser] conversation url (${label}) = ${url}`); const persist = options.persistUrl(url); activePersists.add(persist); @@ -76,7 +77,3 @@ export function createConversationUrlMonitor(options: { }, }; } - -function isConversationUrl(url: string): boolean { - return /\/c\/[a-z0-9-]+/i.test(url); -} diff --git a/src/browser/index.ts b/src/browser/index.ts index e94a98fda..c9858d0c8 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -113,6 +113,10 @@ import { createConversationUrlMonitor, type ConversationUrlMonitor, } from "./conversationUrlMonitor.js"; +import { + extractStableConversationIdFromUrl as extractConversationIdFromUrl, + isStableConversationUrl as isConversationUrl, +} from "./conversationUrl.js"; export type { BrowserAutomationConfig, BrowserRunOptions, BrowserRunResult } from "./types.js"; export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js"; @@ -4147,10 +4151,6 @@ async function readConversationTurnCount( return null; } -function isConversationUrl(url: string): boolean { - return /\/c\/[a-z0-9-]+/i.test(url); -} - function describeDevtoolsFirewallHint(host: string, port: number): string | null { if (!isWsl()) return null; return [ @@ -4170,11 +4170,6 @@ function isWsl(): boolean { return os.release().toLowerCase().includes("microsoft"); } -function extractConversationIdFromUrl(url: string): string | undefined { - const match = url.match(/\/c\/([a-zA-Z0-9-]+)/); - return match?.[1]; -} - async function resolveUserDataBaseDir(): Promise { // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder. if (isWsl()) { diff --git a/src/browser/liveTabs.ts b/src/browser/liveTabs.ts index b8d6cd9dc..ccbc3d4bc 100644 --- a/src/browser/liveTabs.ts +++ b/src/browser/liveTabs.ts @@ -11,6 +11,7 @@ import { } from "./constants.js"; import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assistantResponse.js"; import { buildConversationTurnListExpression } from "./conversationTurns.js"; +import { extractStableConversationIdFromUrl } from "./conversationUrl.js"; import { delay } from "./utils.js"; export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1"; @@ -631,8 +632,7 @@ export async function harvestChatGptTab( } export function extractConversationIdFromUrl(url: string): string | undefined { - const match = normalizeUrl(url).match(/\/c\/([^/?#]+)/); - return match?.[1] ?? undefined; + return extractStableConversationIdFromUrl(normalizeUrl(url)); } export function formatBrowserTabState( diff --git a/src/browser/reattachHelpers.ts b/src/browser/reattachHelpers.ts index 7885f6695..0bb0e3e31 100644 --- a/src/browser/reattachHelpers.ts +++ b/src/browser/reattachHelpers.ts @@ -1,6 +1,7 @@ import type { BrowserLogger, ChromeClient } from "./types.js"; import { CONVERSATION_TURN_SELECTOR } from "./constants.js"; import { buildConversationTurnCountExpression } from "./conversationTurns.js"; +import { extractStableConversationIdFromUrl } from "./conversationUrl.js"; import { delay } from "./utils.js"; import { readAssistantSnapshot } from "./pageActions.js"; @@ -52,9 +53,7 @@ export function pickTarget( } export function extractConversationIdFromUrl(url: string): string | undefined { - if (!url) return undefined; - const match = url.match(/\/c\/([a-zA-Z0-9-]+)/); - return match?.[1]; + return extractStableConversationIdFromUrl(url); } export function buildConversationUrl( @@ -62,7 +61,7 @@ export function buildConversationUrl( baseUrl: string, ): string | null { if (runtime.tabUrl) { - if (runtime.tabUrl.includes("/c/")) { + if (extractConversationIdFromUrl(runtime.tabUrl)) { return runtime.tabUrl; } return null; diff --git a/tests/browser/conversationUrlMonitor.test.ts b/tests/browser/conversationUrlMonitor.test.ts index 7d2939e64..f5605e078 100644 --- a/tests/browser/conversationUrlMonitor.test.ts +++ b/tests/browser/conversationUrlMonitor.test.ts @@ -29,6 +29,32 @@ describe("createConversationUrlMonitor", () => { ); }); + test("ignores transient WEB request routes until the durable conversation URL appears", async () => { + const readUrl = vi + .fn<() => Promise>() + .mockResolvedValueOnce("https://chatgpt.com/c/WEB:32229414-5afa-4478-890c-9ca80aa82430") + .mockResolvedValue("https://chatgpt.com/c/6a61036f-4cc4-83e8-8415-efb820f52db9"); + const persistUrl = vi.fn(async () => {}); + let now = 0; + const monitor = createConversationUrlMonitor({ + readUrl, + persistUrl, + logger: vi.fn() as BrowserLogger, + wait: async () => { + now += 250; + }, + now: () => now, + }); + + await expect(monitor.update("assistant-wait", 1_000)).resolves.toBe(true); + + expect(readUrl).toHaveBeenCalledTimes(2); + expect(persistUrl).toHaveBeenCalledOnce(); + expect(persistUrl).toHaveBeenCalledWith( + "https://chatgpt.com/c/6a61036f-4cc4-83e8-8415-efb820f52db9", + ); + }); + test("keeps polling through read errors until the URL appears", async () => { let reads = 0; const persistUrl = vi.fn(async () => {}); diff --git a/tests/browser/reattach.test.ts b/tests/browser/reattach.test.ts index 760c25824..2849796d9 100644 --- a/tests/browser/reattach.test.ts +++ b/tests/browser/reattach.test.ts @@ -346,6 +346,11 @@ describe("reattach helpers", () => { test("extracts conversation id from a chat URL", () => { expect(extractConversationIdFromUrl("https://chatgpt.com/c/abc-123")).toBe("abc-123"); + expect( + extractConversationIdFromUrl( + "https://chatgpt.com/c/WEB:32229414-5afa-4478-890c-9ca80aa82430", + ), + ).toBeUndefined(); expect(extractConversationIdFromUrl("")).toBeUndefined(); }); From aa4e0f7559cb12142c21f4db9d38b421bfb97283 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 20:33:30 -0700 Subject: [PATCH 2/4] fix(browser): gate reattach on durable conversation URLs --- src/browser/reattachability.ts | 3 ++- tests/browser/reattachability.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/browser/reattachability.ts b/src/browser/reattachability.ts index 69a3d5897..7ab85dd4f 100644 --- a/src/browser/reattachability.ts +++ b/src/browser/reattachability.ts @@ -1,4 +1,5 @@ import type { BrowserRuntimeMetadata } from "../sessionStore.js"; +import { isStableConversationUrl } from "./conversationUrl.js"; /** * True when the URL points at a specific ChatGPT conversation (`/c/`) on @@ -19,7 +20,7 @@ export function isRecoverableChatGptConversationUrl(candidate: string | null | u if (url.hostname !== "chatgpt.com" && url.hostname !== "chat.openai.com") { return false; } - return /(?:^|\/)c\/[^/]+/.test(url.pathname); + return isStableConversationUrl(url.href); } catch { return false; } diff --git a/tests/browser/reattachability.test.ts b/tests/browser/reattachability.test.ts index fc5d9c66e..d340080f0 100644 --- a/tests/browser/reattachability.test.ts +++ b/tests/browser/reattachability.test.ts @@ -27,6 +27,11 @@ describe("hasRecoverableChatGptConversation", () => { tabUrl: "https://chatgpt.com/g/g-p-demo/project", }), ).toBe(false); + expect( + hasRecoverableChatGptConversation({ + tabUrl: "https://chatgpt.com/c/WEB:32229414-5afa-4478-890c-9ca80aa82430", + }), + ).toBe(false); }); test("rejects malformed or non-ChatGPT URLs", () => { From 7936b6e54d4177a51c8f771868bc668cde9a93e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 20:35:40 -0700 Subject: [PATCH 3/4] test(browser): reject non-path conversation hints --- src/browser/reattachability.ts | 2 +- tests/browser/reattachability.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/browser/reattachability.ts b/src/browser/reattachability.ts index 7ab85dd4f..1a4f21489 100644 --- a/src/browser/reattachability.ts +++ b/src/browser/reattachability.ts @@ -20,7 +20,7 @@ export function isRecoverableChatGptConversationUrl(candidate: string | null | u if (url.hostname !== "chatgpt.com" && url.hostname !== "chat.openai.com") { return false; } - return isStableConversationUrl(url.href); + return isStableConversationUrl(url.pathname); } catch { return false; } diff --git a/tests/browser/reattachability.test.ts b/tests/browser/reattachability.test.ts index d340080f0..0f90bd397 100644 --- a/tests/browser/reattachability.test.ts +++ b/tests/browser/reattachability.test.ts @@ -37,5 +37,11 @@ describe("hasRecoverableChatGptConversation", () => { test("rejects malformed or non-ChatGPT URLs", () => { expect(hasRecoverableChatGptConversation({ tabUrl: "not a url" })).toBe(false); expect(hasRecoverableChatGptConversation({ tabUrl: "https://example.com/c/abc" })).toBe(false); + expect(hasRecoverableChatGptConversation({ tabUrl: "https://chatgpt.com/?next=/c/abc" })).toBe( + false, + ); + expect(hasRecoverableChatGptConversation({ tabUrl: "https://chatgpt.com/#/c/abc" })).toBe( + false, + ); }); }); From 7374576925415b3db12e717aba5bfcfb6850d09a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 20:38:31 -0700 Subject: [PATCH 4/4] docs(changelog): credit durable URL contributors --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beaa64323..3cffd52dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Browser: ignore transient `/c/WEB:` routes until ChatGPT exposes the durable conversation URL, preventing completed GPT-5.6 and Pro answers from hanging until timeout under a mismatched response scope. Fixes #333. Thanks @kesslerio! +- Browser: ignore transient `/c/WEB:` routes until ChatGPT exposes the durable conversation URL, preventing completed GPT-5.6 and Pro answers from hanging until timeout under a mismatched response scope. Fixes #333. Thanks @dbachko and @kesslerio! - Browser: recover completed answers after a recoverable DevTools disconnect by confirming target liveness and attempting bounded reattachment, while preserving fail-closed handling for unavailable targets. Fixes #326. Thanks @piyushbag! - CLI: avoid inheriting `browser.thinkingTime` from config when `--browser-model-strategy current` is explicit, while preserving an explicit `--browser-thinking-time` override. Thanks @jung0han! - Browser/Serve: keep the authenticated manual-login Chrome process alive while closing each successfully captured service-owned run tab, preventing renderer and memory accumulation across repeated remote consultations without changing explicit `--browser-keep-browser`, attached-tab, or incomplete-run recovery behavior. Thanks @rtl-ai!