Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Browser: `oracle session <id> --harvest` and `--live` now auto-recover when the original Chrome has been closed by relaunching the manual-login profile and reopening the saved conversation URL, then retrying the harvest against the recovered tab. Resolves the failure mode where a long GPT-5 Pro Extended response completed in the background after the CLI's 20-minute wall expired and the conversation was archived. Recovery URL selection prefers `browser.harvest.url` over `browser.runtime.tabUrl` and is gated by a shared ChatGPT-conversation-URL check (rejects home, project shell, and external URLs so the persistent profile can't be navigated to the wrong page from stale metadata). Opt out with `--no-recover` on the `session` subcommand.

### Fixed

- Browser: Deep Research runs now return the report again. ChatGPT renders the report inside an out-of-process sandboxed iframe (`connector_openai_deep_research.*.oaiusercontent.com`) that is invisible to the main page's frame tree, so the in-page extraction never saw it and the run timed out / harvested only the `"ChatGPT said:"` placeholder. `waitForDeepResearchCompletion` now prefers the CDP target-attach path (which reaches the OOPIF and its nested frame) and treats a target-confirmed completion as authoritative even when the main DOM has no assistant turn. Target discovery is scoped to the current Oracle-controlled page via page-session auto-attach (the browser-wide `Target.getTargets` scan is dropped) so a foreign completed Deep Research tab in a shared/persistent Chrome profile cannot be saved into the current session. The auto-attach is bound to the page session explicitly (passing the wrapper's page session id) so the scoping also holds on the browser-WSEndpoint / remote-Chrome path, where the session-bound client's raw `send` is otherwise browser-level. When a page exposes more than one Deep Research iframe target, scanning prefers a completed read over an earlier in-progress one, and an incomplete target read no longer suppresses the in-page frame fallback.

## 0.13.0 — 2026-05-22

### Added
Expand Down
155 changes: 107 additions & 48 deletions src/browser/actions/deepResearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,21 +203,40 @@ export async function waitForDeepResearchCompletion(
);
}

const frameResult = Page
? await readDeepResearchFrameResult(Runtime, Page).catch(() => null)
: client
? await readDeepResearchTargetResult(client).catch(() => null)
// ChatGPT renders the Deep Research report inside an out-of-process,
// sandboxed iframe (connector_openai_deep_research.*.oaiusercontent.com),
// doubly nested and same-origin. That OOPIF does NOT appear in the main
// page's frame tree, so the in-page isolated-world path
// (readDeepResearchFrameResult) can never see it. The target-attach path
// (readDeepResearchTargetResult) attaches to the iframe's own CDP target and
// walks its nested frames, so it CAN read the report. Prefer the target path
// and fall back to the in-page frame path for legacy/inline rendering.
const targetResult = client
? await readDeepResearchTargetResult(client).catch(() => null)
: null;
// A completed target read is authoritative. If the target read is missing or
// only in-progress, still try the in-page frame path so an incomplete target
// read does not suppress a completed report there (legacy/inline rendering).
const inPageResult =
!targetResult?.completed && Page
? await readDeepResearchFrameResult(Runtime, Page).catch(() => null)
: null;
const read = pickPreferredDeepResearchRead(targetResult, inPageResult);
// A target-confirmed completion read the live connector iframe directly, so
// it is authoritative even when the main DOM exposes no assistant turn (the
// report lives entirely in the OOPIF). The main-DOM hasActiveScopedResearch
// heuristic no longer holds in that case, so don't gate on it.
const completedFromTarget = Boolean(targetResult?.completed);
const scopedToNewTurns = minTurnLiteral >= 0;
if (
frameResult?.completed &&
frameResult.text &&
(!scopedToNewTurns || val?.hasActiveScopedResearch)
read?.completed &&
read.text &&
(completedFromTarget || !scopedToNewTurns || val?.hasActiveScopedResearch)
) {
logger(`Deep Research completed (${Math.round((Date.now() - start) / 1000)}s elapsed)`);
return {
text: frameResult.text,
html: frameResult.html,
text: read.text,
html: read.html,
meta: { turnId: null, messageId: null },
};
}
Expand All @@ -232,9 +251,9 @@ export async function waitForDeepResearchCompletion(
const now = Date.now();
if (now - lastLogTime >= 60_000) {
const elapsed = Math.round((now - start) / 1000);
const chars = Math.max(val?.textLength ?? 0, frameResult?.textLength ?? 0);
const chars = Math.max(val?.textLength ?? 0, read?.textLength ?? 0);
const phase =
frameResult?.inProgress || val?.hasIframe
read?.inProgress || val?.hasIframe
? "researching"
: val?.stopVisible
? "generating"
Expand All @@ -243,7 +262,7 @@ export async function waitForDeepResearchCompletion(
lastLogTime = now;
}

lastTextLength = Math.max(val?.textLength ?? 0, frameResult?.textLength ?? 0, lastTextLength);
lastTextLength = Math.max(val?.textLength ?? 0, read?.textLength ?? 0, lastTextLength);
await delay(DEEP_RESEARCH_POLL_INTERVAL_MS);
}

Expand Down Expand Up @@ -324,6 +343,34 @@ interface DeepResearchFrameStatus {
html?: string;
}

/**
* Choose the authoritative Deep Research read between the target-attach result
* and the in-page frame result. A completed read wins (target preferred, since
* it reads the live OOPIF directly); otherwise the best in-progress/text-bearing
* read is kept so progress logging still advances. This preserves the legacy
* Page-first inline behaviour: when the target read is missing or incomplete,
* a completed in-page result is still returned.
*/
function pickPreferredDeepResearchRead(
targetResult: DeepResearchFrameStatus | null,
inPageResult: DeepResearchFrameStatus | null,
): DeepResearchFrameStatus | null {
if (targetResult?.completed) {
return targetResult;
}
if (inPageResult?.completed) {
return inPageResult;
}
return targetResult ?? inPageResult;
}

export function pickPreferredDeepResearchReadForTest(
targetResult: DeepResearchFrameStatus | null,
inPageResult: DeepResearchFrameStatus | null,
): DeepResearchFrameStatus | null {
return pickPreferredDeepResearchRead(targetResult, inPageResult);
}

async function readDeepResearchFrameResult(
Runtime: ChromeClient["Runtime"],
Page: ChromeClient["Page"],
Expand Down Expand Up @@ -372,11 +419,19 @@ async function readDeepResearchTargetResult(
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
oraclePageSessionId?: string;
};
if (typeof rawClient.send !== "function") {
return null;
}

// On the browser-WSEndpoint path, `client` is a session-bound wrapper whose
// domain methods target the page session but whose raw `send` is the
// browser-level send. We must therefore pass the page session id explicitly so
// Target.setAutoAttach binds to THIS page (not the whole browser). For a direct
// tab client this is undefined and `send` already defaults to the page session.
const pageSessionId = rawClient.oraclePageSessionId;

const sessionIds = new Set<string>();
const ownedSessionIds = new Set<string>();
const onAttached = (params: unknown, sessionId?: string) => {
Expand All @@ -393,55 +448,59 @@ async function readDeepResearchTargetResult(

client.on?.("Target.attachedToTarget", onAttached as never);
try {
await rawClient.send("Target.setDiscoverTargets", { discover: true }).catch(() => undefined);
// Scope discovery to the current Oracle-controlled page. `client` is
// connected to the conversation page target, so enabling auto-attach on this
// session only attaches THIS page's related targets (its Deep Research OOPIF
// subframe) and emits Target.attachedToTarget for them.
//
// We deliberately do NOT enumerate Target.getTargets / attachToTarget here:
// that scan is browser-wide, and in a shared/persistent Chrome profile it
// would surface another tab's completed Deep Research report and let it be
// saved into the current session (cross-tab leak). Only auto-attached,
// page-scoped sessions are treated as belonging to this run.
await rawClient
.send("Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
})
.send(
"Target.setAutoAttach",
{
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
},
pageSessionId,
)
.catch(() => undefined);
await delay(100);

const targets = (await rawClient.send("Target.getTargets", {})) as
| {
targetInfos?: Array<{
targetId?: string;
type?: string;
url?: string;
}>;
}
| undefined;
for (const target of targets?.targetInfos ?? []) {
if (!target.targetId || !isDeepResearchTarget(target.url ?? "", target.type ?? "")) {
continue;
}
const attached = (await rawClient
.send("Target.attachToTarget", { targetId: target.targetId, flatten: true })
.catch(() => null)) as { sessionId?: string } | null;
if (attached?.sessionId) {
sessionIds.add(attached.sessionId);
ownedSessionIds.add(attached.sessionId);
}
}

// A page can expose more than one Deep Research iframe target (e.g. a stale
// in-progress one alongside the completed report). Always prefer a completed
// read; only fall back to the best in-progress/text-bearing read when no
// session reports completion, so we never miss a later completed OOPIF.
let best: DeepResearchFrameStatus | null = null;
for (const sessionId of sessionIds) {
const value = await readDeepResearchTargetSession(rawClient, sessionId);
if (value?.completed) {
return value;
}
if (value?.inProgress || value?.textLength) {
return value;
if (
value &&
(value.inProgress || value.textLength) &&
(value.textLength ?? 0) >= (best?.textLength ?? 0)
) {
best = value;
}
}
return null;
return best;
} finally {
await rawClient
.send("Target.setAutoAttach", {
autoAttach: false,
waitForDebuggerOnStart: false,
flatten: true,
})
.send(
"Target.setAutoAttach",
{
autoAttach: false,
waitForDebuggerOnStart: false,
flatten: true,
},
pageSessionId,
)
.catch(() => undefined);
await Promise.all(
Array.from(ownedSessionIds, (sessionId) =>
Expand Down
4 changes: 4 additions & 0 deletions src/browser/chromeLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,10 @@ function createSessionBoundChromeClient(browser: ChromeClient, sessionId: string

return {
...browser,
// Raw `send` here is the browser-level send (not session-bound), so callers
// that issue Target.* via `send` must pass this page session id explicitly to
// stay scoped to this tab (e.g. Deep Research OOPIF auto-attach).
oraclePageSessionId: sessionId,
Network: bindDomain("Network"),
Page: bindDomain("Page"),
Runtime: bindDomain("Runtime"),
Expand Down
Loading