Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed

- Browser: ignore transient `/c/WEB:<request-id>` 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!
Expand Down
17 changes: 17 additions & 0 deletions src/browser/conversationUrl.ts
Original file line number Diff line number Diff line change
@@ -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:<request-id>`
* 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;
}
7 changes: 2 additions & 5 deletions src/browser/conversationUrlMonitor.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -76,7 +77,3 @@ export function createConversationUrlMonitor(options: {
},
};
}

function isConversationUrl(url: string): boolean {
return /\/c\/[a-z0-9-]+/i.test(url);
}
13 changes: 4 additions & 9 deletions src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 [
Expand All @@ -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<string> {
// On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
if (isWsl()) {
Expand Down
4 changes: 2 additions & 2 deletions src/browser/liveTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 3 additions & 4 deletions src/browser/reattachHelpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -52,17 +53,15 @@ 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(
runtime: { tabUrl?: string; conversationId?: string },
baseUrl: string,
): string | null {
if (runtime.tabUrl) {
if (runtime.tabUrl.includes("/c/")) {
if (extractConversationIdFromUrl(runtime.tabUrl)) {
return runtime.tabUrl;
}
return null;
Expand Down
3 changes: 2 additions & 1 deletion src/browser/reattachability.ts
Original file line number Diff line number Diff line change
@@ -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/<id>`) on
Expand All @@ -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.pathname);
} catch {
return false;
}
Expand Down
26 changes: 26 additions & 0 deletions tests/browser/conversationUrlMonitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ describe("createConversationUrlMonitor", () => {
);
});

test("ignores transient WEB request routes until the durable conversation URL appears", async () => {
const readUrl = vi
.fn<() => Promise<string>>()
.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 () => {});
Expand Down
5 changes: 5 additions & 0 deletions tests/browser/reattach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
11 changes: 11 additions & 0 deletions tests/browser/reattachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,21 @@ 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", () => {
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,
);
});
});