diff --git a/bin/oracle-cli.ts b/bin/oracle-cli.ts index aefc6b830..15a61c0de 100755 --- a/bin/oracle-cli.ts +++ b/bin/oracle-cli.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node import "dotenv/config"; -import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { Command, Option } from "commander"; import type { OptionValues } from "commander"; @@ -47,7 +46,7 @@ import { } from "../src/cli/options.js"; import { copyToClipboard } from "../src/cli/clipboard.js"; import { buildMarkdownBundle } from "../src/cli/markdownBundle.js"; -import { shouldDetachSession } from "../src/cli/detach.js"; +import { shouldDetachSession, shouldLaunchDetachedSessionFinalizer } from "../src/cli/detach.js"; import { applyHiddenAliases } from "../src/cli/hiddenAliases.js"; import type { BrowserSessionRunnerDeps } from "../src/browser/sessionRunner.js"; import { isMediaFile } from "../src/browser/prompt.js"; @@ -80,6 +79,10 @@ import { createPerfTrace, isTraceValueFlag, } from "../src/cli/perfTrace.js"; +import { + launchDetachedSessionFinalizer, + launchDetachedSessionRunner, +} from "../src/cli/detachedSession.js"; interface CliOptions extends OptionValues { prompt?: string; @@ -106,6 +109,7 @@ interface CliOptions extends OptionValues { apiKey?: string; session?: string; execSession?: string; + finalizeSession?: string; followup?: string; followupModel?: string; notify?: boolean; @@ -205,6 +209,39 @@ interface RestartCommandOptions { remoteToken?: string; } +interface FollowUpCommandOptions { + prompt?: string; + slug?: string; + wait?: boolean; + recover?: boolean; + file?: string[]; +} + +function collectFollowUpCommandOptions(...values: unknown[]): FollowUpCommandOptions { + const options: FollowUpCommandOptions = {}; + for (const value of values) { + if (!value || typeof value !== "object") continue; + const candidate = + typeof (value as Command).opts === "function" + ? (value as Command).opts() + : (value as FollowUpCommandOptions); + for (const [key, candidateValue] of Object.entries(candidate)) { + if (candidateValue === undefined) continue; + if ( + key === "file" && + Array.isArray(candidateValue) && + candidateValue.length === 0 && + options.file && + options.file.length > 0 + ) { + continue; + } + (options as Record)[key] = candidateValue; + } + } + return options; +} + const VERSION = getCliVersion(); const CLI_ENTRYPOINT = fileURLToPath(import.meta.url); const LEGACY_FLAG_ALIASES = new Map([ @@ -535,6 +572,7 @@ program ).default(undefined), ) .addOption(new Option("--exec-session ").hideHelp()) + .addOption(new Option("--finalize-session ").hideHelp()) .addOption(new Option("--session ").hideHelp()) .addOption( new Option("--status", "Show stored sessions (alias for `oracle status`).") @@ -1284,6 +1322,36 @@ program await restartSession(sessionId, restartOptions); }); +program + .command("follow-up [prompt]") + .description("Continue a stored browser session as a new child session.") + .option("-p, --prompt ", "Follow-up prompt to send to the saved ChatGPT conversation.") + .option("-s, --slug ", "Custom child session slug (3-5 words).") + .addOption(new Option("--wait").default(undefined)) + .addOption(new Option("--no-wait").default(undefined).hideHelp()) + .option( + "-f, --file ", + "Unsupported for follow-up v1; start a new consult to attach files.", + collectPaths, + [], + ) + .option( + "--no-recover", + "Do not relaunch Chrome to reopen the saved conversation URL; require a live matching tab.", + ) + .action( + async ( + parentSessionId: string, + promptArg: string | undefined, + optionsOrCommand: FollowUpCommandOptions | Command, + cmd?: Command, + ) => { + const options = collectFollowUpCommandOptions(program, cmd, optionsOrCommand, promptArg); + const positionalPrompt = typeof promptArg === "string" ? promptArg : undefined; + await runFollowUpCommand(parentSessionId, positionalPrompt, options); + }, + ); + function buildRunOptions( options: ResolvedCliOptions, overrides: Partial = {}, @@ -2009,6 +2077,11 @@ async function runRootCommand(options: CliOptions): Promise { return; } + if (options.finalizeSession) { + await finalizeSession(options.finalizeSession); + return; + } + if (renderMarkdown || copyMarkdown) { if (!options.prompt) { throw new Error("Prompt is required when using --render-markdown or --copy-markdown."); @@ -2311,15 +2384,16 @@ async function runRootCommand(options: CliOptions): Promise { waitPreference, disableDetachEnv, }); - const detached = !detachAllowed - ? false - : await launchDetachedSession(sessionMeta.id).catch((error) => { + const detachedLaunch = !detachAllowed + ? { runnerStarted: false, finalizerStarted: false } + : await launchDetachedSession(sessionMeta.id, { engine }).catch((error) => { const message = error instanceof Error ? error.message : String(error); console.log( chalk.yellow(`Unable to detach session runner (${message}). Running inline...`), ); - return false; + return { runnerStarted: false, finalizerStarted: false }; }); + const detached = detachedLaunch.runnerStarted; const lifecycle = buildSessionLifecycle({ engine, detached, @@ -2327,6 +2401,15 @@ async function runRootCommand(options: CliOptions): Promise { }); await sessionStore.updateSession(sessionMeta.id, { lifecycle }); const sessionWithLifecycle: SessionMetadata = { ...sessionMeta, lifecycle }; + if ( + detached && + shouldLaunchDetachedSessionFinalizer({ engine }) && + !detachedLaunch.finalizerStarted + ) { + console.log( + chalk.yellow("Detached finalizer did not start; use `oracle session --render` if needed."), + ); + } if (!waitPreference) { if (!detached) { @@ -2431,25 +2514,94 @@ async function runInteractiveSession( } } -async function launchDetachedSession(sessionId: string): Promise { - return new Promise((resolve, reject) => { - try { - const args = ["--", CLI_ENTRYPOINT, "--exec-session", sessionId]; - const env = buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionId); - const child = spawn(process.execPath, args, { - detached: true, - stdio: "ignore", - env, - }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(true); - }); - } catch (error) { - reject(error); - } +interface DetachedLaunchResult { + runnerStarted: boolean; + finalizerStarted: boolean; +} + +async function launchDetachedSession( + sessionId: string, + { engine }: { engine: EngineMode }, +): Promise { + const env = buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionId); + const launchOptions = { + cliEntrypoint: CLI_ENTRYPOINT, + env, + }; + const runnerStarted = await launchDetachedSessionRunner(sessionId, launchOptions); + const finalizerStarted = shouldLaunchDetachedSessionFinalizer({ engine }) + ? await launchDetachedSessionFinalizer(sessionId, launchOptions).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.log(chalk.yellow(`Unable to detach session finalizer (${message}).`)); + return false; + }) + : false; + return { + runnerStarted, + finalizerStarted, + }; +} + +async function runFollowUpCommand( + parentSessionId: string, + promptArg: string | undefined, + options: FollowUpCommandOptions, +): Promise { + const prompt = (await resolveDashPrompt(options.prompt ?? promptArg ?? "")) ?? ""; + if (!prompt.trim()) { + console.error( + chalk.red("Prompt is required for follow-up. Use positional [prompt] or --prompt."), + ); + process.exitCode = 1; + return; + } + if (options.file && options.file.length > 0) { + console.error( + chalk.red( + "Browser follow-up is prompt-only in v1. Start a new `oracle consult` run to attach files.", + ), + ); + process.exitCode = 1; + return; + } + + const { startBrowserFollowUpSession, waitForFollowUpSession } = + await import("../src/cli/browserFollowUp.js"); + const result = await startBrowserFollowUpSession(parentSessionId, { + prompt, + slug: options.slug, + wait: options.wait, + recover: options.recover !== false, + files: options.file, + cliEntrypoint: CLI_ENTRYPOINT, + env: process.env, + log: console.log, }); + + console.log(chalk.blue(`Follow-up session: ${result.session.id}`)); + console.log(chalk.dim(`Parent session: ${result.parentSessionId}`)); + console.log(chalk.dim(`Conversation: ${result.parentConversationUrl}`)); + if (!result.finalizerStarted) { + console.log(chalk.yellow("Detached finalizer did not start; use oracle-await if needed.")); + } + + const shouldWait = result.session.options?.waitPreference === true; + if (!shouldWait) { + for (const line of formatSessionLifecycleBlock(result.session)) { + console.log(line); + } + console.log(chalk.blue(`Reattach via: ${result.reattachCommand}`)); + return; + } + + const finalMeta = await waitForFollowUpSession(result.session.id, { log: console.log }); + if (!finalMeta) { + console.log(chalk.red(`Follow-up session ${result.session.id} disappeared.`)); + process.exitCode = 1; + return; + } + const { attachSession } = await import("../src/cli/sessionDisplay.js"); + await attachSession(result.session.id, { renderMarkdown: true, suppressMetadata: true }); } async function restartSession(sessionId: string, options: RestartCommandOptions): Promise { @@ -2593,15 +2745,16 @@ async function restartSession(sessionId: string, options: RestartCommandOptions) waitPreference, disableDetachEnv, }); - const detached = !detachAllowed - ? false - : await launchDetachedSession(sessionMeta.id).catch((error) => { + const detachedLaunch = !detachAllowed + ? { runnerStarted: false, finalizerStarted: false } + : await launchDetachedSession(sessionMeta.id, { engine }).catch((error) => { const message = error instanceof Error ? error.message : String(error); console.log( chalk.yellow(`Unable to detach session runner (${message}). Running inline...`), ); - return false; + return { runnerStarted: false, finalizerStarted: false }; }); + const detached = detachedLaunch.runnerStarted; const lifecycle = buildSessionLifecycle({ engine, detached, @@ -2609,6 +2762,15 @@ async function restartSession(sessionId: string, options: RestartCommandOptions) }); await sessionStore.updateSession(sessionMeta.id, { lifecycle }); const sessionWithLifecycle: SessionMetadata = { ...sessionMeta, lifecycle }; + if ( + detached && + shouldLaunchDetachedSessionFinalizer({ engine }) && + !detachedLaunch.finalizerStarted + ) { + console.log( + chalk.yellow("Detached finalizer did not start; use `oracle session --render` if needed."), + ); + } if (!waitPreference) { if (!detached) { @@ -2684,6 +2846,18 @@ async function executeSession(sessionId: string) { } } +async function finalizeSession(sessionId: string) { + const { logLine, stream } = sessionStore.createLogWriter(sessionId); + try { + const { finalizeBrowserSessionUntilComplete } = await import("../src/cli/sessionFinalizer.js"); + await finalizeBrowserSessionUntilComplete(sessionId, { + log: logLine, + }); + } finally { + stream.end(); + } +} + function printDebugHelp(cliName: string): void { console.log(chalk.bold("Advanced Options")); printDebugOptionGroup([ diff --git a/package.json b/package.json index 42ed42a02..b3d44ef68 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ }, "bin": { "oracle": "dist/bin/oracle-cli.js", + "oracle-await": "skills/oracle/scripts/oracle-await", "oracle-mcp": "dist/bin/oracle-mcp.js" }, "files": [ "dist/**/*", + "skills/oracle/scripts/oracle-await", "assets-oracle-icon.png", "vendor/oracle-notifier/OracleNotifier.swift", "vendor/oracle-notifier/OracleNotifier.app/**", @@ -51,6 +53,7 @@ "test:packed-cli": "node scripts/packed-cli-smoke.mjs", "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts", "test:live:fast": "ORACLE_LIVE_TEST=1 ORACLE_LIVE_TEST_FAST=1 vitest run tests/live/browser-fast-live.test.ts", + "test:live:fast-long": "ORACLE_LIVE_TEST=1 ORACLE_LIVE_TEST_FAST=1 ORACLE_LIVE_TEST_LONG=1 vitest run tests/live/browser-fast-live.test.ts -t long-haul", "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts", "test:coverage": "vitest run --coverage", "prepare": "pnpm run build", diff --git a/skills/oracle/RUNBOOK.md b/skills/oracle/RUNBOOK.md new file mode 100644 index 000000000..8e6b9bb5b --- /dev/null +++ b/skills/oracle/RUNBOOK.md @@ -0,0 +1,76 @@ +# Oracle Runbook + +## Waiting out long browser consults (`oracle-await`) + +Browser GPT-5.5 Pro / Pro Extended consults can outlive MCP client request +timeouts. An MCP `-32001` timeout does not prove the ChatGPT run stopped. The +browser tab may continue, finish, and still leave local metadata stale if the +MCP/controller process was killed before finalization. + +Do not trust `meta.json` `status:"running"` as authoritative after a client +timeout. Render every poll cycle: + +```bash +oracle-await +# defaults: first wait 5m, render every 3m, cap 22m +# override: oracle-await [first_wait_s] [interval_s] [max_s] +``` + +Exit codes: + +- `0`: READY, transcript path and answer printed. +- `2`: still running or not captured by the cap. +- `3`: session reported an error. +- `4`: unknown session or missing wrapper. + +Manual equivalent: + +```bash +oracle session --render +``` + +`--render` is both check and recovery: it reattaches to the stored ChatGPT tab, +captures `artifacts/transcript.md`, and flips the session to `completed` when a +stable answer is present. + +If metadata says `status:"error"` but `artifacts/transcript.md` exists and is +non-empty, treat the transcript as the result. This can happen when Node/undici +crashes during wrapper cleanup (for example `setTypeOfService EINVAL`) after +the browser answer was already captured. Do not rerun; read/render the saved +session. + +## Continuing a saved browser conversation (`oracle follow-up`) + +Use `follow-up` when the saved ChatGPT conversation has useful context and you +want one more turn: + +```bash +oracle follow-up --prompt "Ask the next question" --slug "next review turn" +oracle follow-up "Ask the next question" --wait +``` + +This creates a new child session with its own metadata, log, lifecycle, and +`artifacts/transcript.md`. The parent session remains the audit record for the +earlier run. `oracle session --harvest` and `oracle session --live` stay +read-only recovery/inspection tools; they do not add turns. + +Follow-up v1 is prompt-only. Start a new `oracle consult` if the next turn needs +fresh files or attachments. + +## MCP timeout triage + +MCP `consult` browser runs block by default for compatibility. If an agent needs +recoverable early-return behavior for a long browser consult, pass +`browserDetached:true` in the MCP `consult` input or set +`ORACLE_MCP_BROWSER_DETACHED=1` for that MCP host. + +If an MCP browser consult times out after opening Chrome: + +1. Do not rerun immediately. +2. List sessions with `oracle status --hours 72`. +3. Run `oracle-await ` if the session exists. +4. If no session exists, restart the MCP host and verify it points at the + canonical wrapper/build, not a stale checkout path. + +Always pass an explicit `slug` for long MCP browser consults so the session is +easy to recover after a timeout. diff --git a/skills/oracle/SKILL.md b/skills/oracle/SKILL.md index c8588ba8c..fd9b02f36 100644 --- a/skills/oracle/SKILL.md +++ b/skills/oracle/SKILL.md @@ -110,6 +110,22 @@ Recommended defaults: - Runs may detach or take a long time (browser/API + GPT‑5.5 Pro often does). If the CLI times out: don’t re-run; reattach. - List: `oracle status --hours 72` - Attach: `oracle session --render` +- To ask a follow-up in the same saved ChatGPT conversation, create a child + session instead of mutating the old one: + - `oracle follow-up --prompt "..." --slug "<3-5 words>"` + - Add `--wait` to observe the child session; otherwise reattach with + `oracle session --render`. + - Follow-up v1 is prompt-only. Start a fresh consult if you need new files. +- MCP/browser timeouts can leave `meta.json` stale at `status:"running"` even + when the ChatGPT tab already has a complete answer. Use `oracle-await ` + (or `oracle session --render`) to render every poll cycle; render is the + check and the recovery path. +- MCP `consult` browser runs block by default for compatibility. Use + `browserDetached:true` (or `ORACLE_MCP_BROWSER_DETACHED=1` on the MCP host) + only when the caller prefers recoverable early-return behavior. +- If a run reports `status:"error"` after saving `artifacts/transcript.md` + (for example Node/undici `setTypeOfService EINVAL` during wrapper cleanup), + read the saved transcript instead of rerunning. - Use `--slug "<3-5 words>"` to keep session IDs readable. - Duplicate prompt guard exists; use `--force` only when you truly want a fresh run. - CLI guardrails: root runs without a prompt exit nonzero; `--dry-run` conflicts with `--render` / `--render-markdown`; Ctrl-C exits foreground API runs with code 130 while browser cleanup/reattach still runs. diff --git a/skills/oracle/scripts/oracle-await b/skills/oracle/scripts/oracle-await new file mode 100755 index 000000000..eca43e90a --- /dev/null +++ b/skills/oracle/scripts/oracle-await @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# oracle-await — wait for a long Oracle browser session and recover stale "running" sessions. +# +# Browser GPT-5.5 Pro consults can outlive MCP client request timeouts. When +# that happens, the ChatGPT tab may finish while session metadata still says +# "running". Rendering is both a check and a recovery path: it reattaches to the +# stored browser tab, captures artifacts/transcript.md, and finalizes metadata. +# +# Usage: +# oracle-await [first_wait_s] [interval_s] [max_s] +# Defaults: +# first_wait=300s, interval=180s, max=1320s +# +# Exit codes: +# 0 READY; transcript captured and printed +# 2 timed out/still running +# 3 session reported an error without a captured transcript +# 4 unknown session or missing wrapper + +set -uo pipefail + +ID="${1:?usage: oracle-await [first_wait_s interval_s max_s]}" +FIRST="${2:-300}" +INTERVAL="${3:-180}" +MAX="${4:-1320}" + +HOME_DIR="${ORACLE_HOME_DIR:-$HOME/.oracle}" +SDIR="$HOME_DIR/sessions/$ID" +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +if [ -x "$SELF_DIR/oracle-local" ]; then + WRAP="$SELF_DIR/oracle-local" +else + WRAP="${ORACLE:-oracle}" +fi +TRANSCRIPT="$SDIR/artifacts/transcript.md" + +[ -d "$SDIR" ] || { echo "oracle-await: unknown session '$ID' (no $SDIR)"; exit 4; } +command -v "$WRAP" >/dev/null 2>&1 || [ -x "$WRAP" ] || { + echo "oracle-await: missing oracle wrapper ($WRAP)" + exit 4 +} + +meta_status() { + python3 -c "import json;print(json.load(open('$SDIR/meta.json')).get('status') or '?')" 2>/dev/null || echo "?" +} + +tsize() { + wc -c < "$TRANSCRIPT" 2>/dev/null | tr -d ' ' || echo 0 +} + +START=$(date +%s) +elapsed() { echo $(( $(date +%s) - START )); } + +echo "oracle-await: $ID - first ${FIRST}s, then every ${INTERVAL}s, cap ${MAX}s" + +attempt() { + "$WRAP" session "$ID" --render >/dev/null 2>&1 || true + local st sz + st="$(meta_status)" + sz="$(tsize)" + echo " [$(elapsed)s] status=$st transcript=${sz}B" + if [ "${sz:-0}" -gt 20 ] && { [ "$st" = "completed" ] || [ "$st" = "error" ]; }; then + return 0 + fi + [ "$st" = "error" ] && return 3 + return 1 +} + +sleep "$FIRST" + +while :; do + attempt + rc=$? + if [ "$rc" -eq 0 ]; then + echo "READY $TRANSCRIPT" + echo "----- answer -----" + sed -n '/^## Answer/,$p' "$TRANSCRIPT" 2>/dev/null || cat "$TRANSCRIPT" + exit 0 + fi + if [ "$rc" -eq 3 ]; then + echo "ERROR session '$ID' reported an error without a captured transcript; inspect: $WRAP session $ID --render" + exit 3 + fi + if [ "$(elapsed)" -ge "$MAX" ]; then + echo "TIMEOUT after $(elapsed)s (status=$(meta_status)); re-run oracle-await or render manually." + exit 2 + fi + sleep "$INTERVAL" +done diff --git a/src/browser/actions/navigation.ts b/src/browser/actions/navigation.ts index e847064b9..b30d29aea 100644 --- a/src/browser/actions/navigation.ts +++ b/src/browser/actions/navigation.ts @@ -1,5 +1,10 @@ import type { ChromeClient, BrowserLogger } from "../types.js"; -import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS } from "../constants.js"; +import { + CLOUDFLARE_SCRIPT_SELECTOR, + CLOUDFLARE_TITLE, + CONVERSATION_TURN_SELECTOR, + INPUT_SELECTORS, +} from "../constants.js"; import { delay } from "../utils.js"; import { logDomFailure } from "../domDebug.js"; import { BrowserAutomationError } from "../../oracle/errors.js"; @@ -351,6 +356,64 @@ export async function ensurePromptReady( } } +export interface ResumedConversationHydrationDeps { + ensurePromptReady?: typeof ensurePromptReady; + requirePriorTurns?: boolean; +} + +/** + * Resumed ChatGPT conversations hydrate prior turns asynchronously and can reset + * the composer while doing so. Wait until history stops growing before typing. + */ +export async function waitForResumedConversationHydration( + Runtime: ChromeClient["Runtime"], + timeoutMs: number, + logger: BrowserLogger, + deps: ResumedConversationHydrationDeps = {}, +): Promise { + const ensureReady = deps.ensurePromptReady ?? ensurePromptReady; + const hydrationDeadline = Date.now() + Math.min(timeoutMs || 30_000, 30_000); + let priorTurns = 0; + let stableChecks = 0; + while (Date.now() < hydrationDeadline) { + let turns = 0; + try { + const { result } = await Runtime.evaluate({ + expression: `document.querySelectorAll(${JSON.stringify( + CONVERSATION_TURN_SELECTOR, + )}).length`, + returnByValue: true, + }); + turns = typeof result?.value === "number" ? result.value : 0; + } catch { + // Keep polling until the conversation hydrates or the bounded deadline expires. + } + if (turns > 0 && turns === priorTurns) { + stableChecks += 1; + if (stableChecks >= 3) { + break; + } + } else { + stableChecks = 0; + } + priorTurns = turns; + await delay(250); + } + await delay(1_000); + await ensureReady(Runtime, timeoutMs, logger); + if ((deps.requirePriorTurns ?? false) && priorTurns <= 0) { + throw new BrowserAutomationError( + "Saved ChatGPT conversation did not load prior turns; refusing to submit follow-up as a fresh chat.", + { + stage: "resume-conversation", + priorTurns, + }, + ); + } + logger(`[browser] Resumed conversation hydrated (${priorTurns} prior turns); composer settled.`); + return priorTurns; +} + async function waitForDocumentReady(Runtime: ChromeClient["Runtime"], timeoutMs: number) { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/src/browser/config.ts b/src/browser/config.ts index 000cbbecf..9b67096cc 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -32,6 +32,7 @@ export const DEFAULT_BROWSER_CONFIG: ResolvedBrowserConfig = { chromeCookiePath: null, attachRunning: false, browserTabRef: null, + resumeConversationUrl: null, url: CHATGPT_URL, chatgptUrl: CHATGPT_URL, timeoutMs: 1_200_000, @@ -139,6 +140,8 @@ export function resolveBrowserConfig( chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath, attachRunning: config?.attachRunning ?? DEFAULT_BROWSER_CONFIG.attachRunning, browserTabRef: config?.browserTabRef ?? DEFAULT_BROWSER_CONFIG.browserTabRef, + resumeConversationUrl: + config?.resumeConversationUrl ?? DEFAULT_BROWSER_CONFIG.resumeConversationUrl, debug: config?.debug ?? DEFAULT_BROWSER_CONFIG.debug, allowCookieErrors: config?.allowCookieErrors ?? envAllowCookieErrors ?? DEFAULT_BROWSER_CONFIG.allowCookieErrors, diff --git a/src/browser/index.ts b/src/browser/index.ts index b8eac986e..95439d91b 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -29,6 +29,7 @@ import { ensureNotBlocked, ensureLoggedIn, ensurePromptReady, + waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, @@ -547,6 +548,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise 0) { throw new BrowserAutomationError( @@ -877,6 +879,13 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise ensureModelSelection(Runtime, config.desiredModel as string, logger, modelStrategy), @@ -1016,12 +1037,16 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise ensureModelSelection(Runtime, config.desiredModel as string, logger, modelStrategy), { @@ -2411,12 +2446,16 @@ async function runRemoteBrowserMode( logger( `Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`, ); - } else if (modelStrategy === "ignore") { + } else if (modelStrategy === "ignore" || config.resumeConversationUrl) { modelSelectionEvidence = buildSkippedModelSelectionEvidence( config.desiredModel, modelStrategy, ); - logger("Model picker: skipped (strategy=ignore)"); + logger( + config.resumeConversationUrl + ? "Model picker: skipped (resumed conversation)" + : "Model picker: skipped (strategy=ignore)", + ); } const deepResearch = config.researchMode === "deep"; // Handle thinking time selection if specified. Deep Research owns its own effort flow. diff --git a/src/browser/pageActions.ts b/src/browser/pageActions.ts index 33c73c42f..a80f5cc21 100644 --- a/src/browser/pageActions.ts +++ b/src/browser/pageActions.ts @@ -4,6 +4,7 @@ export { ensureNotBlocked, ensureLoggedIn, ensurePromptReady, + waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, } from "./actions/navigation.js"; export { ensureModelSelection } from "./actions/modelSelection.js"; diff --git a/src/browser/types.ts b/src/browser/types.ts index 622ff2081..98b3c0cf6 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -49,6 +49,8 @@ export interface BrowserAutomationConfig { chromeCookiePath?: string | null; attachRunning?: boolean; browserTabRef?: string | null; + /** Existing ChatGPT conversation URL to continue without starting a new thread. */ + resumeConversationUrl?: string | null; url?: string; chatgptUrl?: string | null; timeoutMs?: number; diff --git a/src/cli/browserFollowUp.ts b/src/cli/browserFollowUp.ts new file mode 100644 index 000000000..b65b59352 --- /dev/null +++ b/src/cli/browserFollowUp.ts @@ -0,0 +1,223 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BrowserSessionConfig, SessionMetadata, SessionStore } from "../sessionStore.js"; +import { sessionStore, wait } from "../sessionStore.js"; +import { DEFAULT_MODEL } from "../oracle/config.js"; +import { CHATGPT_URL } from "../browser/constants.js"; +import { resolveRecoveryUrl } from "../browser/recoverConversation.js"; +import { launchDetachedSessionFinalizer, launchDetachedSessionRunner } from "./detachedSession.js"; +import { buildSessionLifecycle } from "./sessionLifecycle.js"; + +const DEFAULT_FOLLOW_UP_POLL_MS = 2_000; +const TERMINAL_STATUSES = new Set(["completed", "partial", "error", "cancelled"]); + +export interface StartBrowserFollowUpOptions { + prompt: string; + slug?: string; + wait?: boolean; + recover?: boolean; + files?: string[]; + cliEntrypoint?: string; + env?: NodeJS.ProcessEnv; + log?: (line: string) => void; +} + +export interface BrowserFollowUpDeps { + sessionStore?: SessionStore; + launchDetachedSessionRunner?: typeof launchDetachedSessionRunner; + launchDetachedSessionFinalizer?: typeof launchDetachedSessionFinalizer; +} + +export interface BrowserFollowUpSessionResult { + parentSessionId: string; + parentConversationUrl: string; + session: SessionMetadata; + detached: boolean; + finalizerStarted: boolean; + reattachCommand: string; +} + +export interface WaitForFollowUpOptions { + timeoutMs?: number; + pollMs?: number; + log?: (line: string) => void; + now?: () => number; +} + +function resolveCliEntrypoint(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bin/oracle-cli.js"); +} + +function assertPromptOnly(files: string[] | undefined): void { + if (files && files.length > 0) { + throw new Error( + "Browser follow-up is prompt-only in v1. Start a new `oracle consult` run to attach files.", + ); + } +} + +function cloneBrowserConfigForFollowUp( + parentConfig: BrowserSessionConfig, + conversationUrl: string, + recover: boolean, +): BrowserSessionConfig { + const base: BrowserSessionConfig = { + ...parentConfig, + browserTabRef: null, + resumeConversationUrl: null, + researchMode: "off", + archiveConversations: "never", + }; + if (!recover) { + return { + ...base, + attachRunning: parentConfig.remoteChrome ? parentConfig.attachRunning : true, + url: parentConfig.url ?? parentConfig.chatgptUrl ?? CHATGPT_URL, + browserTabRef: conversationUrl, + resumeConversationUrl: conversationUrl, + }; + } + return { + ...base, + url: parentConfig.chatgptUrl ?? CHATGPT_URL, + chatgptUrl: parentConfig.chatgptUrl ?? CHATGPT_URL, + resumeConversationUrl: conversationUrl, + }; +} + +export function resolveBrowserFollowUpParent( + parent: SessionMetadata | null, + parentSessionId: string, +): { + parent: SessionMetadata; + conversationUrl: string; + browserConfig: BrowserSessionConfig; +} { + if (!parent) { + throw new Error(`No parent session found with ID ${parentSessionId}.`); + } + if (parent.mode !== "browser") { + throw new Error(`Parent session ${parent.id} is not a browser session.`); + } + const browserConfig = parent.browser?.config; + if (!browserConfig) { + throw new Error(`Parent session ${parent.id} is missing browser configuration.`); + } + const conversationUrl = resolveRecoveryUrl(parent); + if (!conversationUrl) { + throw new Error( + `Parent session ${parent.id} has no recoverable ChatGPT conversation URL. Run ` + + `\`oracle session ${parent.id} --harvest\` first, or start a new consult.`, + ); + } + return { parent, conversationUrl, browserConfig }; +} + +export async function startBrowserFollowUpSession( + parentSessionId: string, + options: StartBrowserFollowUpOptions, + deps: BrowserFollowUpDeps = {}, +): Promise { + const store = deps.sessionStore ?? sessionStore; + const launchRunner = deps.launchDetachedSessionRunner ?? launchDetachedSessionRunner; + const launchFinalizer = deps.launchDetachedSessionFinalizer ?? launchDetachedSessionFinalizer; + const prompt = options.prompt.trim(); + if (!prompt) { + throw new Error("Prompt is required for browser follow-up."); + } + assertPromptOnly(options.files); + + await store.ensureStorage(); + const { parent, conversationUrl, browserConfig } = resolveBrowserFollowUpParent( + await store.readSession(parentSessionId), + parentSessionId, + ); + const recover = options.recover !== false; + const childBrowserConfig = cloneBrowserConfigForFollowUp(browserConfig, conversationUrl, recover); + const waitPreference = options.wait === true; + const model = parent.options?.model ?? parent.model ?? DEFAULT_MODEL; + const cwd = parent.cwd ?? process.cwd(); + const child = await store.createSession( + { + prompt, + model, + mode: "browser", + browserConfig: childBrowserConfig, + parentSessionId: parent.id, + followUpOfSessionId: parent.id, + waitPreference, + verbose: parent.options?.verbose, + heartbeatIntervalMs: parent.options?.heartbeatIntervalMs, + browserAttachments: "never", + slug: options.slug, + }, + cwd, + parent.notifications, + options.slug ? undefined : `${parent.id}-follow-up`, + ); + + const cliEntrypoint = options.cliEntrypoint ?? resolveCliEntrypoint(); + const launchOptions = { cliEntrypoint, env: options.env }; + const detached = await launchRunner(child.id, launchOptions); + const finalizerStarted = await launchFinalizer(child.id, launchOptions).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + options.log?.(`[follow-up] Unable to launch detached finalizer: ${message}`); + return false; + }); + const lifecycle = buildSessionLifecycle({ + engine: "browser", + detached, + reattachCommand: `oracle session ${child.id}`, + }); + const session = await store.updateSession(child.id, { + lifecycle, + parentSessionId: parent.id, + followUpOfSessionId: parent.id, + }); + return { + parentSessionId: parent.id, + parentConversationUrl: conversationUrl, + session, + detached, + finalizerStarted, + reattachCommand: `oracle session ${child.id} --render`, + }; +} + +export async function waitForFollowUpSession( + sessionId: string, + options: WaitForFollowUpOptions = {}, +): Promise { + const pollMs = options.pollMs ?? DEFAULT_FOLLOW_UP_POLL_MS; + const now = options.now ?? Date.now; + const timeoutMs = options.timeoutMs ?? Number.POSITIVE_INFINITY; + const deadline = Number.isFinite(timeoutMs) ? now() + timeoutMs : Number.POSITIVE_INFINITY; + let lastStatus = ""; + while (now() < deadline) { + const metadata = await sessionStore.readSession(sessionId); + if (!metadata) { + return null; + } + if (metadata.status !== lastStatus) { + lastStatus = metadata.status; + options.log?.(`[follow-up] Session ${sessionId} status: ${metadata.status}`); + } + if (TERMINAL_STATUSES.has(metadata.status)) { + return metadata; + } + await wait(Math.min(pollMs, Math.max(0, deadline - now()))); + } + return sessionStore.readSession(sessionId); +} + +export async function readFollowUpLogTail( + sessionId: string, + maxBytes = 4000, +): Promise { + try { + const log = await sessionStore.readLog(sessionId); + return log.length > maxBytes ? log.slice(-maxBytes) : log; + } catch { + return undefined; + } +} diff --git a/src/cli/detach.ts b/src/cli/detach.ts index 176c3afb4..2fbd340ed 100644 --- a/src/cli/detach.ts +++ b/src/cli/detach.ts @@ -21,3 +21,7 @@ export function shouldDetachSession({ if (isProModel(model) && engine === "api") return true; return false; } + +export function shouldLaunchDetachedSessionFinalizer({ engine }: { engine: EngineMode }): boolean { + return engine === "browser"; +} diff --git a/src/cli/detachedSession.ts b/src/cli/detachedSession.ts new file mode 100644 index 000000000..9ac64c189 --- /dev/null +++ b/src/cli/detachedSession.ts @@ -0,0 +1,47 @@ +import { spawn } from "node:child_process"; + +export interface LaunchDetachedSessionRunnerOptions { + cliEntrypoint: string; + env?: NodeJS.ProcessEnv; + nodeExecPath?: string; +} + +export function launchDetachedSessionRunner( + sessionId: string, + options: LaunchDetachedSessionRunnerOptions, +): Promise { + return launchDetachedCli(["--exec-session", sessionId], options); +} + +export function launchDetachedSessionFinalizer( + sessionId: string, + options: LaunchDetachedSessionRunnerOptions, +): Promise { + return launchDetachedCli(["--finalize-session", sessionId], options); +} + +function launchDetachedCli( + cliArgs: string[], + { + cliEntrypoint, + env = process.env, + nodeExecPath = process.execPath, + }: LaunchDetachedSessionRunnerOptions, +): Promise { + return new Promise((resolve, reject) => { + try { + const child = spawn(nodeExecPath, ["--", cliEntrypoint, ...cliArgs], { + detached: true, + stdio: "ignore", + env, + }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(true); + }); + } catch (error) { + reject(error); + } + }); +} diff --git a/src/cli/promptRequirement.ts b/src/cli/promptRequirement.ts index bd623082e..54400205d 100644 --- a/src/cli/promptRequirement.ts +++ b/src/cli/promptRequirement.ts @@ -2,6 +2,7 @@ interface PromptCheckOptions { prompt?: string; session?: string; execSession?: string; + finalizeSession?: string; status?: boolean; debugHelp?: boolean; route?: boolean; @@ -22,10 +23,12 @@ export function shouldRequirePrompt(rawArgs: string[], options: PromptCheckOptio const bypassPrompt = Boolean( options.session || options.execSession || + options.finalizeSession || options.status || options.debugHelp || options.route || options.preflight || + firstArg === "follow-up" || firstArg === "status" || firstArg === "session", ); diff --git a/src/cli/reattachGuidance.ts b/src/cli/reattachGuidance.ts new file mode 100644 index 000000000..92d887d0e --- /dev/null +++ b/src/cli/reattachGuidance.ts @@ -0,0 +1,8 @@ +export function formatBrowserReattachGuidance(sessionId: string): string { + return [ + "This run did not return cleanly, but it may still be alive. Reattach:", + ` oracle session ${sessionId} --render # final markdown when complete`, + ` oracle session ${sessionId} --live # tail until done`, + ` oracle session ${sessionId} --harvest # snapshot the current answer now`, + ].join("\n"); +} diff --git a/src/cli/sessionDisplay.ts b/src/cli/sessionDisplay.ts index 055a226aa..f766784a4 100644 --- a/src/cli/sessionDisplay.ts +++ b/src/cli/sessionDisplay.ts @@ -772,6 +772,17 @@ interface StatusTreeRow { detachedParentLabel?: string; } +function formatLineageParentLabel( + lineage: ReturnType, +): string | undefined { + if (!lineage?.parentSessionId) { + return undefined; + } + return lineage.parentResponseId + ? `${lineage.parentSessionId} (${abbreviateResponseId(lineage.parentResponseId)})` + : lineage.parentSessionId; +} + function buildStatusTreeRows( entries: SessionMetadata[], responseOwners: ReadonlyMap, @@ -822,7 +833,7 @@ function buildStatusTreeRows( const lineage = lineageById.get(entry.id); const hiddenParent = lineage?.parentSessionId && !entryById.has(lineage.parentSessionId) - ? `${lineage.parentSessionId} (${abbreviateResponseId(lineage.parentResponseId)})` + ? formatLineageParentLabel(lineage) : undefined; const children = childMap.get(entry.id) ?? []; rows.push({ entry, displaySlug: entry.id, detachedParentLabel: hiddenParent }); @@ -851,15 +862,21 @@ async function buildSessionChainLine(metadata: SessionMetadata): Promise ${metadata.id}`; } if (lineageWithoutLookup.parentSessionId) { - return `${lineageWithoutLookup.parentSessionId} (${abbreviateResponseId(lineageWithoutLookup.parentResponseId)}) -> ${ - metadata.id - }`; + const parentLabel = formatLineageParentLabel(lineageWithoutLookup); + return `${parentLabel} -> ${metadata.id}`; + } + if (!lineageWithoutLookup.parentResponseId) { + return `root -> ${metadata.id}`; } const sessions = await sessionStore.listSessions().catch(() => []); const responseOwners = buildResponseOwnerIndex(sessions); const lineage = resolveSessionLineage(metadata, responseOwners) ?? lineageWithoutLookup; if (lineage.parentSessionId) { - return `${lineage.parentSessionId} (${abbreviateResponseId(lineage.parentResponseId)}) -> ${metadata.id}`; + const parentLabel = formatLineageParentLabel(lineage); + return `${parentLabel} -> ${metadata.id}`; + } + if (!lineage.parentResponseId) { + return `root -> ${metadata.id}`; } return `${abbreviateResponseId(lineage.parentResponseId)} -> ${metadata.id}`; } diff --git a/src/cli/sessionFinalizer.ts b/src/cli/sessionFinalizer.ts new file mode 100644 index 000000000..b3c7c6cc6 --- /dev/null +++ b/src/cli/sessionFinalizer.ts @@ -0,0 +1,184 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { sessionStore, wait } from "../sessionStore.js"; +import type { SessionArtifact, SessionMetadata } from "../sessionStore.js"; +import { formatElapsed } from "../oracle/format.js"; +import { attachSession } from "./sessionDisplay.js"; + +export interface BrowserSessionFinalizerOptions { + firstWaitMs?: number; + intervalMs?: number; + maxWaitMs?: number; + log?: (message: string) => void; + now?: () => number; + waitFn?: (ms: number) => Promise; + attachSessionFn?: typeof attachSession; +} + +const DEFAULT_FIRST_WAIT_MS = 5 * 60_000; +const DEFAULT_INTERVAL_MS = 3 * 60_000; +const DEFAULT_MAX_WAIT_MS = 22 * 60_000; +const MIN_READY_TRANSCRIPT_BYTES = 20; + +function mergeTranscriptArtifact( + artifacts: SessionMetadata["artifacts"], + transcript: SessionArtifact, +): SessionMetadata["artifacts"] { + const merged = new Map(); + for (const artifact of artifacts ?? []) { + merged.set(`${artifact.kind}:${artifact.path}`, artifact); + } + merged.set(`${transcript.kind}:${transcript.path}`, transcript); + return Array.from(merged.values()); +} + +async function findCapturedTranscript( + sessionId: string, + metadata: SessionMetadata, +): Promise { + const candidates = new Set(); + for (const artifact of metadata.artifacts ?? []) { + if (artifact.kind === "transcript" && artifact.path) { + candidates.add(artifact.path); + } + } + try { + const paths = await sessionStore.getPaths(sessionId); + candidates.add(path.join(paths.dir, "artifacts", "transcript.md")); + } catch { + // If the paths are unavailable the caller already has a missing/broken session. + } + + for (const candidate of candidates) { + try { + const stat = await fs.stat(candidate); + if (stat.size > MIN_READY_TRANSCRIPT_BYTES) { + return { + kind: "transcript", + path: candidate, + label: "Browser transcript", + mimeType: "text/markdown", + sizeBytes: stat.size, + }; + } + } catch { + // Try the next candidate. + } + } + return null; +} + +async function finalizeCapturedTranscriptIfPresent( + sessionId: string, + metadata: SessionMetadata, + log: (message: string) => void, +): Promise { + const transcript = await findCapturedTranscript(sessionId, metadata); + if (!transcript) { + return false; + } + log( + `[finalizer] Session ${sessionId} has captured transcript (${transcript.sizeBytes} B) despite status ${metadata.status}; marking completed.`, + ); + await sessionStore.updateSession(sessionId, { + status: "completed", + completedAt: metadata.completedAt ?? new Date().toISOString(), + errorMessage: undefined, + artifacts: mergeTranscriptArtifact(metadata.artifacts, transcript), + response: { status: "completed" }, + error: undefined, + transport: undefined, + }); + return true; +} + +export async function finalizeBrowserSessionUntilComplete( + sessionId: string, + options: BrowserSessionFinalizerOptions = {}, +): Promise<"completed" | "error" | "timeout" | "missing"> { + const firstWaitMs = Math.max(0, options.firstWaitMs ?? DEFAULT_FIRST_WAIT_MS); + const intervalMs = Math.max(1_000, options.intervalMs ?? DEFAULT_INTERVAL_MS); + const maxWaitMs = Math.max(firstWaitMs, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS); + const now = options.now ?? Date.now; + const log = options.log ?? (() => {}); + const waitFor = options.waitFn ?? wait; + const attach = options.attachSessionFn ?? attachSession; + const startedAt = now(); + const deadline = startedAt + maxWaitMs; + + const initial = await sessionStore.readSession(sessionId); + if (!initial) { + log(`[finalizer] Session ${sessionId} not found.`); + return "missing"; + } + if (initial.mode !== "browser") { + log(`[finalizer] Session ${sessionId} is not a browser session; skipping.`); + return initial.status === "completed" ? "completed" : "error"; + } + if (initial.status === "completed") { + log(`[finalizer] Session ${sessionId} already completed.`); + return "completed"; + } + if (initial.status === "error" || initial.status === "partial") { + if (await finalizeCapturedTranscriptIfPresent(sessionId, initial, log)) { + return "completed"; + } + log(`[finalizer] Session ${sessionId} already ${initial.status}.`); + return "error"; + } + + if (firstWaitMs > 0) { + log(`[finalizer] Waiting ${formatElapsed(firstWaitMs)} before first recovery render.`); + await waitFor(firstWaitMs); + } + + let attempt = 0; + while (now() <= deadline) { + attempt += 1; + const before = await sessionStore.readSession(sessionId); + if (!before) { + log(`[finalizer] Session ${sessionId} disappeared.`); + return "missing"; + } + if (before.status === "completed") { + log(`[finalizer] Session ${sessionId} completed before attempt ${attempt}.`); + return "completed"; + } + if (before.status === "error" && before.response?.incompleteReason !== "incomplete-capture") { + if (await finalizeCapturedTranscriptIfPresent(sessionId, before, log)) { + return "completed"; + } + log(`[finalizer] Session ${sessionId} is error; stopping.`); + return "error"; + } + + log(`[finalizer] Recovery render attempt ${attempt} for ${sessionId} (${before.status}).`); + await attach(sessionId, { + renderMarkdown: false, + renderPrompt: false, + suppressMetadata: true, + }); + + const after = await sessionStore.readSession(sessionId); + if (after?.status === "completed") { + log(`[finalizer] Session ${sessionId} finalized as completed.`); + return "completed"; + } + if (after?.status === "error" && after.response?.incompleteReason !== "incomplete-capture") { + if (await finalizeCapturedTranscriptIfPresent(sessionId, after, log)) { + return "completed"; + } + log(`[finalizer] Session ${sessionId} finalized as error.`); + return "error"; + } + + const remainingMs = deadline - now(); + if (remainingMs <= 0) { + break; + } + await waitFor(Math.min(intervalMs, remainingMs)); + } + + log(`[finalizer] Timed out after ${formatElapsed(maxWaitMs)} waiting for ${sessionId}.`); + return "timeout"; +} diff --git a/src/cli/sessionLineage.ts b/src/cli/sessionLineage.ts index 01015c202..4eb0eacdd 100644 --- a/src/cli/sessionLineage.ts +++ b/src/cli/sessionLineage.ts @@ -3,7 +3,7 @@ import type { SessionMetadata } from "../sessionStore.js"; type ResponseRecord = { responseId?: unknown; id?: unknown }; export interface SessionLineage { - parentResponseId: string; + parentResponseId?: string; parentSessionId?: string; } @@ -56,17 +56,22 @@ export function resolveSessionLineage( responseOwners?: ReadonlyMap, ): SessionLineage | null { const previous = meta.options?.previousResponseId?.trim(); - if (!previous) { + let parentSessionId = + meta.options?.followupSessionId?.trim() || + meta.options?.followUpOfSessionId?.trim() || + meta.options?.parentSessionId?.trim() || + meta.followUpOfSessionId?.trim() || + meta.parentSessionId?.trim(); + if (!previous && !parentSessionId) { return null; } - let parentSessionId = meta.options?.followupSessionId?.trim(); - if (!parentSessionId && responseOwners) { + if (!parentSessionId && previous && responseOwners) { parentSessionId = responseOwners.get(previous); } if (parentSessionId === meta.id) { parentSessionId = undefined; } - return { parentResponseId: previous, parentSessionId }; + return { parentResponseId: previous || undefined, parentSessionId }; } export function abbreviateResponseId(responseId: string, max = 18): string { diff --git a/src/cli/sessionRunner.ts b/src/cli/sessionRunner.ts index eca54c03a..f88b56894 100644 --- a/src/cli/sessionRunner.ts +++ b/src/cli/sessionRunner.ts @@ -50,6 +50,7 @@ import { hasRecoverableChatGptConversation } from "../browser/reattachability.js import { estimateTokenCount } from "../browser/utils.js"; import type { BrowserLogger } from "../browser/types.js"; import { formatElapsed } from "../oracle/format.js"; +import { formatBrowserReattachGuidance } from "./reattachGuidance.js"; const isTty = process.stdout.isTTY; const dim = (text: string): string => (isTty ? kleur.dim(text) : text); @@ -505,6 +506,19 @@ export async function performSessionRun({ const cloudflareChallenge = userError?.category === "browser-automation" && (userError.details as { stage?: string } | undefined)?.stage === "cloudflare-challenge"; + let reattachGuidanceLogged = false; + const logBrowserReattachGuidance = (runtime?: BrowserRuntimeMetadata | null): void => { + if (reattachGuidanceLogged || mode !== "browser") return; + const recoverableRuntime = runtime ?? sessionMeta.browser?.runtime; + if ( + !hasRecoverableChatGptConversation(recoverableRuntime) && + recoverableRuntime?.promptSubmitted !== true + ) { + return; + } + reattachGuidanceLogged = true; + log(formatBrowserReattachGuidance(sessionMeta.id)); + }; if (connectionLost && mode === "browser") { const runtime = (userError.details as { runtime?: BrowserRuntimeMetadata } | undefined) ?.runtime; @@ -565,6 +579,7 @@ export async function performSessionRun({ }, response: { status: "running", incompleteReason: "chrome-disconnected" }, }); + logBrowserReattachGuidance(runtime ?? sessionMeta.browser?.runtime); return; } if (assistantTimeout && mode === "browser") { @@ -615,7 +630,7 @@ export async function performSessionRun({ return; } } - log(dim(`Reattach later with: oracle session ${sessionMeta.id}`)); + logBrowserReattachGuidance(runtime ?? sessionMeta.browser?.runtime); return; } if (cloudflareChallenge && mode === "browser") { @@ -645,6 +660,7 @@ export async function performSessionRun({ mode === "browser" ? (userError?.details as { runtime?: BrowserRuntimeMetadata } | undefined)?.runtime : undefined; + logBrowserReattachGuidance(browserRuntime); await sessionStore.updateSession(sessionMeta.id, { status: "error", completedAt: new Date().toISOString(), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 743bb4f2b..5fbadbe83 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { getCliVersion } from "../version.js"; import { registerConsultTool } from "./tools/consult.js"; +import { registerFollowUpTool } from "./tools/followUp.js"; import { registerProjectSourcesTool } from "./tools/projectSources.js"; import { registerSessionsTool } from "./tools/sessions.js"; import { registerSessionResources } from "./tools/sessionResources.js"; @@ -24,6 +25,7 @@ export async function startMcpServer(): Promise { ); registerConsultTool(server); + registerFollowUpTool(server); registerProjectSourcesTool(server); registerSessionsTool(server); registerSessionResources(server); diff --git a/src/mcp/tools/consult.ts b/src/mcp/tools/consult.ts index 1f93be7d5..1bb6279af 100644 --- a/src/mcp/tools/consult.ts +++ b/src/mcp/tools/consult.ts @@ -1,13 +1,93 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { z } from "zod"; import { getCliVersion } from "../../version.js"; import { LoggingMessageNotificationParamsSchema } from "@modelcontextprotocol/sdk/types.js"; import { ensureBrowserAvailable, mapConsultToRunOptions } from "../utils.js"; -import type { BrowserSessionConfig, SessionModelRun } from "../../sessionStore.js"; -import { sessionStore } from "../../sessionStore.js"; +import type { BrowserSessionConfig, SessionMetadata, SessionModelRun } from "../../sessionStore.js"; +import { sessionStore, wait } from "../../sessionStore.js"; import { resolveRemoteServiceConfig } from "../../remote/remoteServiceConfig.js"; import { createRemoteBrowserExecutor } from "../../remote/client.js"; import type { BrowserSessionRunnerDeps } from "../../browser/sessionRunner.js"; +import { + launchDetachedSessionFinalizer, + launchDetachedSessionRunner, +} from "../../cli/detachedSession.js"; +import { buildSessionLifecycle } from "../../cli/sessionLifecycle.js"; +import { formatElapsed } from "../../oracle/format.js"; + +interface ConsultToolDeps { + launchDetachedSessionRunner?: typeof launchDetachedSessionRunner; + launchDetachedSessionFinalizer?: typeof launchDetachedSessionFinalizer; + cliEntrypoint?: string; + browserWaitMs?: number; + browserPollMs?: number; + browserDetached?: boolean; + now?: () => number; +} + +const DEFAULT_MCP_BROWSER_WAIT_MS = 105_000; +const DEFAULT_MCP_BROWSER_POLL_MS = 2_000; + +function resolveMcpCliEntrypoint(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../bin/oracle-cli.js"); +} + +function resolvePositiveIntegerEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function resolveBooleanEnv(name: string, fallback = false): boolean { + const raw = process.env[name]?.trim().toLowerCase(); + if (!raw) return fallback; + if (["1", "true", "yes", "on"].includes(raw)) return true; + if (["0", "false", "no", "off"].includes(raw)) return false; + return fallback; +} + +async function waitForDetachedBrowserSession({ + sessionId, + waitMs, + pollMs, + log, + now = Date.now, +}: { + sessionId: string; + waitMs: number; + pollMs: number; + log: (line?: string) => void; + now?: () => number; +}): Promise { + const deadline = now() + waitMs; + let lastStatus = ""; + let lastProgressLogAt = 0; + while (now() < deadline) { + await wait(Math.min(pollMs, Math.max(0, deadline - now()))); + const metadata = await sessionStore.readSession(sessionId); + if (!metadata) { + return null; + } + if (metadata.status !== lastStatus) { + lastStatus = metadata.status; + log(`[mcp] Detached browser session status: ${metadata.status}`); + } else if (now() - lastProgressLogAt >= 30_000) { + lastProgressLogAt = now(); + log(`[mcp] Detached browser session still ${metadata.status}; worker continues.`); + } + if ( + metadata.status === "completed" || + metadata.status === "partial" || + metadata.status === "error" + ) { + return metadata; + } + } + return sessionStore.readSession(sessionId); +} async function readSessionLogTail(sessionId: string, maxBytes: number): Promise { try { @@ -113,12 +193,30 @@ const consultInputShape = { .boolean() .optional() .describe("Browser-only: keep Chrome running after completion (useful for debugging)."), + browserDetached: z + .boolean() + .optional() + .describe( + "Browser-only: run the local browser consult in a detached worker and return recoverable session status after a short wait. Defaults to false to preserve blocking consult behavior.", + ), dryRun: z .boolean() .optional() .describe( "Preview the resolved Oracle run without creating a session or touching the browser.", ), + run_in_background: z + .boolean() + .optional() + .describe( + "Unsupported compatibility trap: do not pass this field. Use browserDetached:true for explicit detached browser consults.", + ), + runInBackground: z + .boolean() + .optional() + .describe( + "Unsupported compatibility trap: do not pass this field. Use browserDetached:true for explicit detached browser consults.", + ), search: z .boolean() .optional() @@ -395,7 +493,7 @@ export function formatConsultDryRunResolved(details: ConsultDryRunResolved): str return lines; } -export function registerConsultTool(server: McpServer): void { +export function registerConsultTool(server: McpServer, deps: ConsultToolDeps = {}): void { server.registerTool( "consult", { @@ -434,6 +532,7 @@ export function registerConsultTool(server: McpServer): void { browserArchive, browserFollowUps, browserKeepBrowser, + browserDetached, dryRun, slug, } = parsedInput; @@ -577,19 +676,65 @@ export function registerConsultTool(server: McpServer): void { }; try { - await performSessionRun({ - sessionMeta, - runOptions, - mode: resolvedEngine, - browserConfig, - cwd, - log, - write, - version: getCliVersion(), - notifications, - muteStdout: true, - browserDeps, - }); + const detachedBrowser = + browserDetached ?? + deps.browserDetached ?? + resolveBooleanEnv("ORACLE_MCP_BROWSER_DETACHED", false); + + if (resolvedEngine === "browser" && !browserDeps && detachedBrowser) { + const cliEntrypoint = deps.cliEntrypoint ?? resolveMcpCliEntrypoint(); + const launchRunner = deps.launchDetachedSessionRunner ?? launchDetachedSessionRunner; + const launchFinalizer = + deps.launchDetachedSessionFinalizer ?? launchDetachedSessionFinalizer; + await sessionStore.updateSession(sessionMeta.id, { + lifecycle: buildSessionLifecycle({ + engine: resolvedEngine, + detached: true, + reattachCommand: `oracle session ${sessionMeta.id}`, + }), + }); + log( + `[mcp] Starting detached browser worker for ${sessionMeta.id}; client timeouts will not stop the run.`, + ); + await launchRunner(sessionMeta.id, { cliEntrypoint }); + await launchFinalizer(sessionMeta.id, { cliEntrypoint }).catch((error) => { + log( + `[mcp] Detached finalizer failed to start: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + const waitMs = + deps.browserWaitMs ?? + resolvePositiveIntegerEnv("ORACLE_MCP_BROWSER_WAIT_MS", DEFAULT_MCP_BROWSER_WAIT_MS); + const pollMs = + deps.browserPollMs ?? + resolvePositiveIntegerEnv("ORACLE_MCP_BROWSER_POLL_MS", DEFAULT_MCP_BROWSER_POLL_MS); + log( + `[mcp] Waiting up to ${formatElapsed(waitMs)} for inline completion before returning session status.`, + ); + await waitForDetachedBrowserSession({ + sessionId: sessionMeta.id, + waitMs, + pollMs, + log, + now: deps.now, + }); + } else { + await performSessionRun({ + sessionMeta, + runOptions, + mode: resolvedEngine, + browserConfig, + cwd, + log, + write, + version: getCliVersion(), + notifications, + muteStdout: true, + browserDeps, + }); + } } catch (error) { log(`Run failed: ${error instanceof Error ? error.message : String(error)}`); return { @@ -604,7 +749,15 @@ export function registerConsultTool(server: McpServer): void { try { const finalMeta = (await sessionStore.readSession(sessionMeta.id)) ?? sessionMeta; - const summary = `Session ${sessionMeta.id} (${finalMeta.status})`; + const running = finalMeta.status === "pending" || finalMeta.status === "running"; + const summary = running + ? [ + `Session ${sessionMeta.id} (${finalMeta.status})`, + "Detached browser worker is still running; inspect with `oracle session " + + `${sessionMeta.id} --render` + + "` or use `oracle-await` after MCP client timeout.", + ].join("\n") + : `Session ${sessionMeta.id} (${finalMeta.status})`; const logTail = await readSessionLogTail(sessionMeta.id, 4000); const modelsSummary = summarizeModelRunsForConsult(finalMeta.models); return { diff --git a/src/mcp/tools/followUp.ts b/src/mcp/tools/followUp.ts new file mode 100644 index 000000000..0f2e9c3ac --- /dev/null +++ b/src/mcp/tools/followUp.ts @@ -0,0 +1,108 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import { + readFollowUpLogTail, + startBrowserFollowUpSession, + waitForFollowUpSession, +} from "../../cli/browserFollowUp.js"; +import { followUpInputSchema } from "../types.js"; + +const DEFAULT_MCP_FOLLOW_UP_WAIT_MS = 105_000; +const DEFAULT_MCP_FOLLOW_UP_POLL_MS = 2_000; + +const followUpInputShape = { + parentSessionId: z + .string() + .describe("Stored browser session id/slug whose saved ChatGPT conversation should continue."), + prompt: z.string().describe("Follow-up prompt to send as the next ChatGPT turn."), + slug: z.string().optional().describe("Optional child session slug (3-5 words)."), + wait: z + .boolean() + .optional() + .describe("Wait briefly for completion before returning. The child session remains detached."), + files: z + .array(z.string()) + .optional() + .describe("Unsupported in follow_up v1; start a new consult to attach files."), +} satisfies z.ZodRawShape; + +const followUpOutputShape = { + sessionId: z.string(), + parentSessionId: z.string(), + status: z.string(), + logTail: z.string().optional(), +} satisfies z.ZodRawShape; + +interface FollowUpToolDeps { + startBrowserFollowUpSession?: typeof startBrowserFollowUpSession; + waitForFollowUpSession?: typeof waitForFollowUpSession; + readFollowUpLogTail?: typeof readFollowUpLogTail; + cliEntrypoint?: string; + waitMs?: number; + pollMs?: number; +} + +function resolveMcpCliEntrypoint(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../bin/oracle-cli.js"); +} + +function resolvePositiveIntegerEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function registerFollowUpTool(server: McpServer, deps: FollowUpToolDeps = {}): void { + server.registerTool( + "follow_up", + { + title: "Continue an oracle browser session", + description: + "Create a child Oracle session that sends one prompt to an existing stored ChatGPT browser conversation. This is prompt-only in v1; use consult for new file attachments.", + inputSchema: followUpInputShape, + outputSchema: followUpOutputShape, + }, + async (input: unknown) => { + const parsed = followUpInputSchema.parse(input); + if (parsed.files && parsed.files.length > 0) { + throw new Error( + "Oracle follow_up is prompt-only in v1. Start a new consult to attach files.", + ); + } + const start = deps.startBrowserFollowUpSession ?? startBrowserFollowUpSession; + const waitForSession = deps.waitForFollowUpSession ?? waitForFollowUpSession; + const readLogTail = deps.readFollowUpLogTail ?? readFollowUpLogTail; + const result = await start(parsed.parentSessionId, { + prompt: parsed.prompt, + slug: parsed.slug, + wait: parsed.wait, + files: parsed.files, + cliEntrypoint: deps.cliEntrypoint ?? resolveMcpCliEntrypoint(), + }); + const waitMs = + deps.waitMs ?? + resolvePositiveIntegerEnv("ORACLE_MCP_BROWSER_WAIT_MS", DEFAULT_MCP_FOLLOW_UP_WAIT_MS); + const pollMs = + deps.pollMs ?? + resolvePositiveIntegerEnv("ORACLE_MCP_BROWSER_POLL_MS", DEFAULT_MCP_FOLLOW_UP_POLL_MS); + const metadata = parsed.wait + ? await waitForSession(result.session.id, { timeoutMs: waitMs, pollMs }) + : result.session; + const status = metadata?.status ?? result.session.status; + const logTail = await readLogTail(result.session.id, 4000); + const output = `Follow-up session ${result.session.id} (${status}) from ${result.parentSessionId}. Reattach via: ${result.reattachCommand}`; + return { + content: [{ type: "text" as const, text: output }], + structuredContent: { + sessionId: result.session.id, + parentSessionId: result.parentSessionId, + status, + logTail, + }, + }; + }, + ); +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 2ba6fc0e1..80eeb1563 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -20,7 +20,10 @@ export const consultInputSchema = z browserArchive: z.enum(["auto", "always", "never"]).optional(), browserFollowUps: z.array(z.string()).optional(), browserKeepBrowser: z.boolean().optional(), + browserDetached: z.boolean().optional(), dryRun: z.boolean().optional(), + run_in_background: z.never().optional(), + runInBackground: z.never().optional(), search: z.boolean().optional(), slug: z.string().optional(), }) @@ -37,3 +40,15 @@ export const sessionsInputSchema = z.object({ }); export type SessionsInput = z.infer; + +export const followUpInputSchema = z + .object({ + parentSessionId: z.string().min(1, "Parent session id is required."), + prompt: z.string().min(1, "Prompt is required."), + slug: z.string().optional(), + wait: z.boolean().optional(), + files: z.array(z.string()).optional(), + }) + .strict(); + +export type FollowUpInput = z.infer; diff --git a/src/sessionManager.ts b/src/sessionManager.ts index e919ba523..ea0f55b4c 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -32,6 +32,8 @@ export interface BrowserSessionConfig { chromeCookiePath?: string | null; attachRunning?: boolean; browserTabRef?: string | null; + /** Existing ChatGPT conversation URL to continue without starting a new thread. */ + resumeConversationUrl?: string | null; chatgptUrl?: string | null; url?: string; timeoutMs?: number; @@ -183,6 +185,10 @@ export interface StoredRunOptions { followupSessionId?: string; /** Optional model selector used with --followup-model for multi-model parent sessions. */ followupModel?: string; + /** Browser session this run continues as a child follow-up. */ + parentSessionId?: string; + /** Alias for parentSessionId used by follow-up APIs. */ + followUpOfSessionId?: string; maxInput?: number; system?: string; maxOutput?: number; @@ -225,6 +231,8 @@ export interface SessionMetadata { id: string; createdAt: string; status: string; + parentSessionId?: string; + followUpOfSessionId?: string; promptPreview?: string; model?: string; models?: SessionModelRun[]; @@ -516,6 +524,8 @@ export async function initializeSession( })), cwd, mode, + parentSessionId: options.parentSessionId, + followUpOfSessionId: options.followUpOfSessionId, browser: browserConfig ? { config: browserConfig } : undefined, notifications, options: { @@ -527,6 +537,8 @@ export async function initializeSession( previousResponseId: options.previousResponseId, followupSessionId: options.followupSessionId, followupModel: options.followupModel, + parentSessionId: options.parentSessionId, + followUpOfSessionId: options.followUpOfSessionId, effectiveModelId: options.effectiveModelId, maxInput: options.maxInput, system: options.system, diff --git a/tests/browser/pageActions.test.ts b/tests/browser/pageActions.test.ts index 015633b6b..0f71d1db5 100644 --- a/tests/browser/pageActions.test.ts +++ b/tests/browser/pageActions.test.ts @@ -7,6 +7,7 @@ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensurePromptReady, + waitForResumedConversationHydration, ensureNotBlocked, ensureLoggedIn, } from "../../src/browser/pageActions.js"; @@ -156,6 +157,66 @@ describe("ensurePromptReady", () => { }); }); +describe("waitForResumedConversationHydration", () => { + test("waits until prior turns stop growing and rechecks the composer", async () => { + vi.useFakeTimers(); + try { + const runtime = { + evaluate: vi + .fn() + .mockResolvedValueOnce({ result: { value: 1 } }) + .mockResolvedValueOnce({ result: { value: 2 } }) + .mockResolvedValueOnce({ result: { value: 2 } }) + .mockResolvedValueOnce({ result: { value: 2 } }) + .mockResolvedValueOnce({ result: { value: 2 } }), + } as unknown as ChromeClient["Runtime"]; + const ensurePromptReadyMock = vi.fn().mockResolvedValue(undefined); + + const promise = waitForResumedConversationHydration(runtime, 5_000, logger, { + ensurePromptReady: ensurePromptReadyMock, + }); + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe(2); + expect(runtime.evaluate).toHaveBeenCalledTimes(5); + expect(ensurePromptReadyMock).toHaveBeenCalledWith(runtime, 5_000, logger); + expect(logger).toHaveBeenCalledWith( + "[browser] Resumed conversation hydrated (2 prior turns); composer settled.", + ); + } finally { + vi.useRealTimers(); + } + }); + + test("rejects resumed conversations with zero prior turns when required", async () => { + vi.useFakeTimers(); + try { + const runtime = { + evaluate: vi.fn().mockResolvedValue({ result: { value: 0 } }), + } as unknown as ChromeClient["Runtime"]; + const ensurePromptReadyMock = vi.fn().mockResolvedValue(undefined); + + const promise = waitForResumedConversationHydration(runtime, 1_000, logger, { + ensurePromptReady: ensurePromptReadyMock, + requirePriorTurns: true, + }); + const assertion = expect(promise).rejects.toMatchObject({ + message: expect.stringMatching(/did not load prior turns/i), + details: { + stage: "resume-conversation", + priorTurns: 0, + }, + }); + await vi.runAllTimersAsync(); + + await assertion; + expect(ensurePromptReadyMock).toHaveBeenCalledWith(runtime, 1_000, logger); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("ensureNotBlocked", () => { test("throws descriptive error when cloudflare detected", async () => { const runtime = { diff --git a/tests/cli/browserFollowUp.test.ts b/tests/cli/browserFollowUp.test.ts new file mode 100644 index 000000000..fc801d6c7 --- /dev/null +++ b/tests/cli/browserFollowUp.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { setOracleHomeDirOverrideForTest } from "../../src/oracleHome.js"; +import { sessionStore } from "../../src/sessionStore.js"; +import { startBrowserFollowUpSession } from "../../src/cli/browserFollowUp.js"; + +afterEach(() => { + setOracleHomeDirOverrideForTest(null); + vi.restoreAllMocks(); +}); + +async function withOracleHome(fn: (tmpHome: string) => Promise): Promise { + const tmpHome = await mkdtemp(path.join(os.tmpdir(), "oracle-follow-up-")); + setOracleHomeDirOverrideForTest(tmpHome); + try { + return await fn(tmpHome); + } finally { + await rm(tmpHome, { recursive: true, force: true }); + } +} + +async function createBrowserParent(waitPreference = false) { + const parent = await sessionStore.createSession( + { + prompt: "parent prompt", + model: "gpt-5.5-pro", + mode: "browser", + waitPreference, + browserConfig: { + manualLogin: true, + manualLoginProfileDir: "/tmp/oracle-profile", + url: "https://chatgpt.com/", + chatgptUrl: "https://chatgpt.com/", + researchMode: "deep", + archiveConversations: "auto", + }, + }, + "/tmp/project", + undefined, + "parent-session", + ); + await sessionStore.updateSession(parent.id, { + status: "completed", + browser: { + ...(parent.browser ?? {}), + runtime: { + tabUrl: "https://chatgpt.com/c/abc123", + conversationId: "abc123", + }, + }, + }); + return (await sessionStore.readSession(parent.id)) ?? parent; +} + +describe("browser follow-up sessions", () => { + test("creates a detached child session linked to the parent conversation", async () => { + await withOracleHome(async () => { + const parent = await createBrowserParent(); + const launchDetachedSessionRunner = vi.fn(async () => true); + const launchDetachedSessionFinalizer = vi.fn(async () => true); + + const result = await startBrowserFollowUpSession( + parent.id, + { + prompt: "challenge the recommendation", + slug: "child follow up now", + cliEntrypoint: "/tmp/oracle-cli.js", + }, + { launchDetachedSessionRunner, launchDetachedSessionFinalizer }, + ); + + expect(result.session.id).toBe("child-follow-up-now"); + expect(result.session.parentSessionId).toBe(parent.id); + expect(result.session.followUpOfSessionId).toBe(parent.id); + expect(result.session.options.parentSessionId).toBe(parent.id); + expect(result.session.options.followUpOfSessionId).toBe(parent.id); + expect(result.session.options.prompt).toBe("challenge the recommendation"); + expect(result.session.options.waitPreference).toBe(false); + expect(result.session.browser?.config).toMatchObject({ + url: "https://chatgpt.com/", + resumeConversationUrl: "https://chatgpt.com/c/abc123", + browserTabRef: null, + researchMode: "off", + archiveConversations: "never", + }); + expect(result.session.lifecycle).toMatchObject({ + engine: "browser", + detached: true, + reattachCommand: `oracle session ${result.session.id}`, + }); + expect(launchDetachedSessionRunner).toHaveBeenCalledWith(result.session.id, { + cliEntrypoint: "/tmp/oracle-cli.js", + env: undefined, + }); + expect(launchDetachedSessionFinalizer).toHaveBeenCalledWith(result.session.id, { + cliEntrypoint: "/tmp/oracle-cli.js", + env: undefined, + }); + }); + }); + + test("uses a live tab ref instead of recovery when recover is disabled", async () => { + await withOracleHome(async () => { + const parent = await createBrowserParent(); + const result = await startBrowserFollowUpSession( + parent.id, + { + prompt: "one more turn", + recover: false, + cliEntrypoint: "/tmp/oracle-cli.js", + }, + { + launchDetachedSessionRunner: vi.fn(async () => true), + launchDetachedSessionFinalizer: vi.fn(async () => true), + }, + ); + + expect(result.session.id).toBe("parent-session-follow-up"); + expect(result.session.browser?.config).toMatchObject({ + attachRunning: true, + url: "https://chatgpt.com/", + browserTabRef: "https://chatgpt.com/c/abc123", + resumeConversationUrl: "https://chatgpt.com/c/abc123", + }); + }); + }); + + test("does not inherit parent wait preference unless wait is explicit", async () => { + await withOracleHome(async () => { + const parent = await createBrowserParent(true); + const launchDetachedSessionRunner = vi.fn(async () => true); + const launchDetachedSessionFinalizer = vi.fn(async () => true); + + const detachedResult = await startBrowserFollowUpSession( + parent.id, + { + prompt: "detached child", + cliEntrypoint: "/tmp/oracle-cli.js", + }, + { launchDetachedSessionRunner, launchDetachedSessionFinalizer }, + ); + expect(detachedResult.session.options.waitPreference).toBe(false); + + const waitingResult = await startBrowserFollowUpSession( + parent.id, + { + prompt: "waiting child", + wait: true, + cliEntrypoint: "/tmp/oracle-cli.js", + }, + { launchDetachedSessionRunner, launchDetachedSessionFinalizer }, + ); + expect(waitingResult.session.options.waitPreference).toBe(true); + }); + }); + + test("rejects invalid parent sessions and unsupported files", async () => { + await withOracleHome(async () => { + await expect( + startBrowserFollowUpSession("missing", { + prompt: "next", + cliEntrypoint: "/tmp/oracle-cli.js", + }), + ).rejects.toThrow(/No parent session found/i); + + const apiParent = await sessionStore.createSession( + { prompt: "api", model: "gpt-5.1", mode: "api" }, + "/tmp/project", + undefined, + "api-parent-session", + ); + await expect( + startBrowserFollowUpSession(apiParent.id, { + prompt: "next", + cliEntrypoint: "/tmp/oracle-cli.js", + }), + ).rejects.toThrow(/not a browser session/i); + + await expect( + startBrowserFollowUpSession(apiParent.id, { + prompt: "next", + files: ["a.ts"], + cliEntrypoint: "/tmp/oracle-cli.js", + }), + ).rejects.toThrow(/prompt-only/i); + }); + }); + + test("rejects browser parents without a recoverable conversation url", async () => { + await withOracleHome(async () => { + const parent = await sessionStore.createSession( + { + prompt: "parent", + model: "gpt-5.5-pro", + mode: "browser", + browserConfig: { manualLogin: true, manualLoginProfileDir: "/tmp/oracle-profile" }, + }, + "/tmp/project", + undefined, + "no-url-parent", + ); + + await expect( + startBrowserFollowUpSession(parent.id, { + prompt: "next", + cliEntrypoint: "/tmp/oracle-cli.js", + }), + ).rejects.toThrow(/no recoverable ChatGPT conversation URL/i); + }); + }); +}); diff --git a/tests/cli/detach.test.ts b/tests/cli/detach.test.ts index 34b4afe8a..062e8df56 100644 --- a/tests/cli/detach.test.ts +++ b/tests/cli/detach.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { shouldDetachSession } from "../../src/cli/detach.js"; +import { shouldDetachSession, shouldLaunchDetachedSessionFinalizer } from "../../src/cli/detach.js"; describe("shouldDetachSession", () => { test("disables detach when env disables it", () => { @@ -57,4 +57,9 @@ describe("shouldDetachSession", () => { }); expect(pro52).toBe(true); }); + + test("only launches detached session finalizers for browser sessions", () => { + expect(shouldLaunchDetachedSessionFinalizer({ engine: "browser" })).toBe(true); + expect(shouldLaunchDetachedSessionFinalizer({ engine: "api" })).toBe(false); + }); }); diff --git a/tests/cli/integrationCli.test.ts b/tests/cli/integrationCli.test.ts index c50762e63..ca23ff668 100644 --- a/tests/cli/integrationCli.test.ts +++ b/tests/cli/integrationCli.test.ts @@ -130,6 +130,29 @@ describe("oracle CLI integration", () => { INTEGRATION_TIMEOUT, ); + test( + "parses follow-up command options and rejects unsupported files", + async () => { + const result = await execCli( + [ + "follow-up", + "parent-session", + "--prompt", + "next turn", + "--slug", + "child follow up", + "--file", + "a.ts", + ], + { timeout: INTEGRATION_TIMEOUT }, + ); + + expect(result.code).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("prompt-only in v1"); + }, + INTEGRATION_TIMEOUT, + ); + test( "SIGINT exits promptly", async () => { diff --git a/tests/cli/promptRequirement.test.ts b/tests/cli/promptRequirement.test.ts index 65748d035..025fd2491 100644 --- a/tests/cli/promptRequirement.test.ts +++ b/tests/cli/promptRequirement.test.ts @@ -12,6 +12,11 @@ describe("shouldRequirePrompt", () => { expect(requires).toBe(false); }); + test("allows follow-up subcommand to resolve its own prompt", () => { + const requires = shouldRequirePrompt(["follow-up", "abc123"], {}); + expect(requires).toBe(false); + }); + test("requires prompt for default run", () => { const requires = shouldRequirePrompt(["--model", "gpt-5.1"], {}); expect(requires).toBe(true); @@ -26,4 +31,11 @@ describe("shouldRequirePrompt", () => { const requires = shouldRequirePrompt(["--session", "abc123"], { session: "abc123" }); expect(requires).toBe(false); }); + + test("allows hidden finalizer command without prompt", () => { + const requires = shouldRequirePrompt(["--finalize-session", "abc123"], { + finalizeSession: "abc123", + }); + expect(requires).toBe(false); + }); }); diff --git a/tests/cli/reattachGuidance.test.ts b/tests/cli/reattachGuidance.test.ts new file mode 100644 index 000000000..e183c8706 --- /dev/null +++ b/tests/cli/reattachGuidance.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { formatBrowserReattachGuidance } from "../../src/cli/reattachGuidance.js"; + +describe("formatBrowserReattachGuidance", () => { + test("includes the real session slug and all reattach commands", () => { + const message = formatBrowserReattachGuidance("gpt55-pro-plan-review"); + + expect(message).toContain( + "This run did not return cleanly, but it may still be alive. Reattach:", + ); + expect(message).toContain( + "oracle session gpt55-pro-plan-review --render # final markdown when complete", + ); + expect(message).toContain("oracle session gpt55-pro-plan-review --live # tail until done"); + expect(message).toContain( + "oracle session gpt55-pro-plan-review --harvest # snapshot the current answer now", + ); + }); +}); diff --git a/tests/cli/sessionDisplay.coverage.test.ts b/tests/cli/sessionDisplay.coverage.test.ts index 6074ba043..e4c3392ef 100644 --- a/tests/cli/sessionDisplay.coverage.test.ts +++ b/tests/cli/sessionDisplay.coverage.test.ts @@ -101,6 +101,45 @@ describe("sessionDisplay helpers", () => { log.mockRestore(); }, 15_000); + it("shows browser child follow-up lineage without Responses API ids", async () => { + const parent = { + id: "browser-parent", + status: "completed", + createdAt: "2025-11-20T00:00:00.000Z", + model: "gpt-5.5-pro", + mode: "browser", + options: { prompt: "parent" }, + }; + const child = { + id: "browser-child", + status: "completed", + createdAt: "2025-11-20T00:01:00.000Z", + model: "gpt-5.5-pro", + mode: "browser", + parentSessionId: "browser-parent", + followUpOfSessionId: "browser-parent", + options: { + prompt: "child", + parentSessionId: "browser-parent", + followUpOfSessionId: "browser-parent", + }, + }; + mockSessionStore.listSessions.mockResolvedValue([child, parent]); + mockSessionStore.filterSessions.mockReturnValue({ + entries: [child, parent], + truncated: false, + total: 2, + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { showStatus } = await import("../../src/cli/sessionDisplay.js"); + await showStatus({ hours: 24, includeAll: false, limit: 5 }); + + expect(log).toHaveBeenCalledWith(expect.stringMatching(/browser-parent/)); + expect(log).toHaveBeenCalledWith(expect.stringMatching(/└─ browser-child/)); + log.mockRestore(); + }, 15_000); + it("renders nested follow-up branches with stable tree connectors", async () => { const parent = { id: "parent-session", diff --git a/tests/cli/sessionFinalizer.test.ts b/tests/cli/sessionFinalizer.test.ts new file mode 100644 index 000000000..01cc967e2 --- /dev/null +++ b/tests/cli/sessionFinalizer.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { setOracleHomeDirOverrideForTest } from "../../src/oracleHome.js"; +import { sessionStore } from "../../src/sessionStore.js"; +import { finalizeBrowserSessionUntilComplete } from "../../src/cli/sessionFinalizer.js"; + +afterEach(() => { + setOracleHomeDirOverrideForTest(null); +}); + +async function withOracleHome(fn: (tmpHome: string) => Promise): Promise { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "oracle-finalizer-")); + setOracleHomeDirOverrideForTest(tmpHome); + try { + return await fn(tmpHome); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } +} + +describe("browser session finalizer", () => { + test("treats an errored browser session with a captured transcript as completed", async () => { + await withOracleHome(async () => { + const session = await sessionStore.createSession( + { + prompt: "ask oracle", + model: "gpt-5.5-pro", + mode: "browser", + browserConfig: { manualLogin: true }, + }, + "/tmp/project", + undefined, + "saved-transcript-after-wrapper-error", + ); + const paths = await sessionStore.getPaths(session.id); + const transcriptPath = path.join(paths.dir, "artifacts", "transcript.md"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "## Answer\n\nThe answer was already captured.\n", "utf8"); + await sessionStore.updateSession(session.id, { + status: "error", + completedAt: new Date().toISOString(), + errorMessage: "TypeError: setTypeOfService EINVAL", + response: { status: "error" }, + error: { + category: "browser", + message: "TypeError: setTypeOfService EINVAL", + }, + }); + + const result = await finalizeBrowserSessionUntilComplete(session.id, { + firstWaitMs: 0, + maxWaitMs: 0, + }); + const updated = await sessionStore.readSession(session.id); + + expect(result).toBe("completed"); + expect(updated?.status).toBe("completed"); + expect(updated?.errorMessage).toBeUndefined(); + expect(updated?.response).toEqual({ status: "completed" }); + expect(updated?.artifacts?.[0]).toMatchObject({ + kind: "transcript", + path: transcriptPath, + }); + }); + }); + + test("keeps recovering past a 15 minute client impatience window", async () => { + await withOracleHome(async () => { + const session = await sessionStore.createSession( + { + prompt: "long browser prompt", + model: "gpt-5.5", + mode: "browser", + browserConfig: { manualLogin: true }, + }, + "/tmp/project", + undefined, + "long-haul-stale-running", + ); + await sessionStore.updateSession(session.id, { + status: "running", + startedAt: new Date().toISOString(), + }); + + const paths = await sessionStore.getPaths(session.id); + const transcriptPath = path.join(paths.dir, "artifacts", "transcript.md"); + let currentMs = 0; + const logs: string[] = []; + const attachSessionFn = vi.fn(async () => { + if (currentMs < 16 * 60_000) { + return; + } + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile( + transcriptPath, + "## Answer\n\nRecovered after a long-running browser response.\n", + "utf8", + ); + await sessionStore.updateSession(session.id, { + status: "completed", + completedAt: new Date().toISOString(), + artifacts: [ + { + kind: "transcript", + path: transcriptPath, + label: "Browser transcript", + mimeType: "text/markdown", + }, + ], + response: { status: "completed" }, + }); + }); + + const result = await finalizeBrowserSessionUntilComplete(session.id, { + now: () => currentMs, + waitFn: async (ms) => { + currentMs += ms; + }, + attachSessionFn, + log: (line) => logs.push(line), + }); + const updated = await sessionStore.readSession(session.id); + + expect(result).toBe("completed"); + expect(currentMs).toBeGreaterThanOrEqual(17 * 60_000); + expect(attachSessionFn).toHaveBeenCalledTimes(5); + expect(updated?.status).toBe("completed"); + expect(updated?.artifacts?.[0]).toMatchObject({ + kind: "transcript", + path: transcriptPath, + }); + expect(logs).toEqual( + expect.arrayContaining([ + "[finalizer] Waiting 5m 0s before first recovery render.", + "[finalizer] Recovery render attempt 5 for long-haul-stale-running (running).", + "[finalizer] Session long-haul-stale-running finalized as completed.", + ]), + ); + }); + }); +}); diff --git a/tests/cli/sessionRunner.test.ts b/tests/cli/sessionRunner.test.ts index 5f8211340..5a0fe8374 100644 --- a/tests/cli/sessionRunner.test.ts +++ b/tests/cli/sessionRunner.test.ts @@ -1260,6 +1260,12 @@ describe("performSessionRun", () => { expect(logLines).toContain( "Chrome disconnected before completion; keeping session running for reattach.", ); + expect(logLines).toContain( + "This run did not return cleanly, but it may still be alive. Reattach:", + ); + expect(logLines).toContain("oracle session sess-1 --render"); + expect(logLines).toContain("oracle session sess-1 --live"); + expect(logLines).toContain("oracle session sess-1 --harvest"); }); test("marks early browser disconnect as error before a conversation exists", async () => { @@ -1307,6 +1313,8 @@ describe("performSessionRun", () => { expect(logLines).toContain( "Chrome disconnected before a ChatGPT conversation was created; marking session error.", ); + expect(logLines).not.toContain("This run did not return cleanly"); + expect(logLines).not.toContain("oracle session sess-1 --render"); }); test("marks browser capture incomplete when assistant response times out", async () => { @@ -1365,9 +1373,15 @@ describe("performSessionRun", () => { expect(logLines).toContain( "Assistant response timed out; marking capture incomplete for reattach.", ); + expect(logLines).toContain( + "This run did not return cleanly, but it may still be alive. Reattach:", + ); + expect(logLines).toContain("oracle session sess-1 --render"); + expect(logLines).toContain("oracle session sess-1 --live"); + expect(logLines).toContain("oracle session sess-1 --harvest"); }); - test("records runtime and guidance when cloudflare challenge is detected", async () => { + test("records runtime and profile reuse guidance when cloudflare challenge is detected", async () => { const automationError = new BrowserAutomationError( "Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.", { @@ -1418,6 +1432,7 @@ describe("performSessionRun", () => { expect(logLines).toContain( "Reuse this browser profile with: oracle --engine browser --browser-manual-login", ); + expect(logLines).not.toContain("This run did not return cleanly"); }); test("auto-reattaches after assistant timeout when configured", async () => { @@ -1513,7 +1528,12 @@ describe("performSessionRun", () => { }); const logLines = log.mock.calls.map((c) => String(c[0])).join("\n"); expect(logLines).toContain("Auto-reattach stopped"); - expect(logLines).toContain("Reattach later with: oracle session"); + expect(logLines).toContain( + "This run did not return cleanly, but it may still be alive. Reattach:", + ); + expect(logLines).toContain("oracle session sess-1 --render"); + expect(logLines).toContain("oracle session sess-1 --live"); + expect(logLines).toContain("oracle session sess-1 --harvest"); } finally { vi.useRealTimers(); } diff --git a/tests/live/browser-fast-live.test.ts b/tests/live/browser-fast-live.test.ts index d3ab9178e..4e7acf611 100644 --- a/tests/live/browser-fast-live.test.ts +++ b/tests/live/browser-fast-live.test.ts @@ -8,6 +8,19 @@ import { getCookies } from "@steipete/sweet-cookie"; const LIVE = process.env.ORACLE_LIVE_TEST === "1"; const FAST = process.env.ORACLE_LIVE_TEST_FAST === "1"; +const LONG = process.env.ORACLE_LIVE_TEST_LONG === "1"; +const LONG_MIN_MS = Number.parseInt( + process.env.ORACLE_LIVE_TEST_LONG_MIN_MS ?? String(10 * 60_000), + 10, +); +const LONG_MODEL_LABEL = process.env.ORACLE_LIVE_TEST_FAST_MODEL_LABEL ?? "Thinking 5.5"; +const LONG_THINKING_TIME = + (process.env.ORACLE_LIVE_TEST_FAST_THINKING_TIME as + | "light" + | "standard" + | "extended" + | "heavy" + | undefined) ?? "extended"; async function hasChatGptSession(): Promise { try { @@ -111,3 +124,51 @@ function isMissingChatGptSessionError(error: unknown): boolean { 8 * 60 * 1000, ); }); + +(LIVE && FAST && LONG ? describe : describe.skip)("ChatGPT browser fast long-haul live", () => { + test( + "keeps a fast thinking run alive past the agent impatience window", + async () => { + if (!(await hasChatGptSession())) { + console.warn("Skipping long-haul fast live test (missing ChatGPT session cookie)."); + return; + } + await acquireLiveTestLock("chatgpt-browser"); + try { + const promptToken = `fast long-haul ${Date.now()}`; + const minMinutes = Math.round(LONG_MIN_MS / 60_000); + let result: Awaited>; + try { + result = await runBrowserMode({ + prompt: [ + `${promptToken}`, + `This is a long-haul Oracle reliability soak. Do not answer until you have spent at least ${minMinutes} minutes reasoning.`, + "Use the extra time to build and check a multi-step argument about why long-running browser automation must detach, recover, and finalize independently of the MCP client.", + "When you finally answer, include the first line exactly and then a concise PASS/FAIL assessment.", + ].join("\n"), + config: { + desiredModel: LONG_MODEL_LABEL, + thinkingTime: LONG_THINKING_TIME, + timeoutMs: Math.max(30 * 60_000, LONG_MIN_MS + 10 * 60_000), + inputTimeoutMs: 60_000, + assistantRecheckDelayMs: 60_000, + assistantRecheckTimeoutMs: 180_000, + }, + }); + } catch (error) { + if (isMissingChatGptSessionError(error)) { + console.warn("Skipping long-haul fast live test (stale ChatGPT session cookie)."); + return; + } + throw error; + } + + expect(result.tookMs).toBeGreaterThanOrEqual(LONG_MIN_MS); + expect(result.answerText.toLowerCase()).toContain(promptToken.toLowerCase()); + } finally { + await releaseLiveTestLock("chatgpt-browser"); + } + }, + Math.max(35 * 60_000, LONG_MIN_MS + 15 * 60_000), + ); +}); diff --git a/tests/mcp/consult.test.ts b/tests/mcp/consult.test.ts index 776831fed..7b76f7fe8 100644 --- a/tests/mcp/consult.test.ts +++ b/tests/mcp/consult.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; import type { SessionModelRun } from "../../src/sessionStore.js"; +import { setOracleHomeDirOverrideForTest } from "../../src/oracleHome.js"; import { applyConsultPreset } from "../../src/mcp/consultPresets.ts"; import { buildConsultBrowserConfig, @@ -8,6 +12,19 @@ import { registerConsultTool, summarizeModelRunsForConsult, } from "../../src/mcp/tools/consult.ts"; +import { performSessionRun } from "../../src/cli/sessionRunner.js"; + +vi.mock("../../src/cli/sessionRunner.js", () => ({ + performSessionRun: vi.fn(async ({ log }: { log: (line?: string) => void }) => { + log("[test] inline browser run"); + }), +})); + +afterEach(() => { + setOracleHomeDirOverrideForTest(null); + vi.unstubAllEnvs(); + vi.mocked(performSessionRun).mockClear(); +}); describe("summarizeModelRunsForConsult", () => { test("applies the ChatGPT Pro Heavy consult preset as overridable defaults", () => { @@ -283,4 +300,104 @@ describe("summarizeModelRunsForConsult", () => { expect(result.isError).toBe(true); expect(result.content[0]?.text).toContain("run_in_background"); }); + + test("keeps browser MCP consults blocking by default", async () => { + const tmpHome = await mkdtemp(path.join(os.tmpdir(), "oracle-mcp-consult-inline-")); + setOracleHomeDirOverrideForTest(tmpHome); + vi.stubEnv("CHROME_PATH", "/bin/true"); + + const handlers: Array<(input: unknown) => Promise> = []; + const launchDetachedSessionRunner = vi.fn(async () => true); + const launchDetachedSessionFinalizer = vi.fn(async () => true); + registerConsultTool( + { + registerTool: (_name: string, _def: unknown, fn: (input: unknown) => Promise) => { + handlers.push(fn); + }, + server: { + sendLoggingMessage: async () => undefined, + }, + } as unknown as Parameters[0], + { + launchDetachedSessionRunner, + launchDetachedSessionFinalizer, + cliEntrypoint: "/tmp/oracle-cli.js", + }, + ); + const handler = handlers[0]; + if (!handler) throw new Error("handler not registered"); + + try { + await handler({ + engine: "browser", + model: "gpt-5.5-pro", + prompt: "review this", + files: [], + slug: "mcp-inline-test", + }); + + expect(performSessionRun).toHaveBeenCalledOnce(); + expect(launchDetachedSessionRunner).not.toHaveBeenCalled(); + expect(launchDetachedSessionFinalizer).not.toHaveBeenCalled(); + } finally { + await rm(tmpHome, { recursive: true, force: true }); + } + }); + + test("starts explicit detached browser MCP consults in session workers", async () => { + const tmpHome = await mkdtemp(path.join(os.tmpdir(), "oracle-mcp-consult-")); + setOracleHomeDirOverrideForTest(tmpHome); + vi.stubEnv("CHROME_PATH", "/bin/true"); + + const handlers: Array<(input: unknown) => Promise> = []; + const launchDetachedSessionRunner = vi.fn(async () => true); + const launchDetachedSessionFinalizer = vi.fn(async () => true); + registerConsultTool( + { + registerTool: (_name: string, _def: unknown, fn: (input: unknown) => Promise) => { + handlers.push(fn); + }, + server: { + sendLoggingMessage: async () => undefined, + }, + } as unknown as Parameters[0], + { + launchDetachedSessionRunner, + launchDetachedSessionFinalizer, + cliEntrypoint: "/tmp/oracle-cli.js", + browserWaitMs: 1, + browserPollMs: 1, + }, + ); + const handler = handlers[0]; + if (!handler) throw new Error("handler not registered"); + + try { + const result = (await handler({ + engine: "browser", + model: "gpt-5.5-pro", + prompt: "review this", + files: [], + slug: "mcp-detached-test", + browserDetached: true, + })) as { + content: Array<{ type: "text"; text: string }>; + structuredContent: { sessionId: string; status: string }; + }; + + expect(launchDetachedSessionRunner).toHaveBeenCalledWith("mcp-detached-test", { + cliEntrypoint: "/tmp/oracle-cli.js", + }); + expect(launchDetachedSessionFinalizer).toHaveBeenCalledWith("mcp-detached-test", { + cliEntrypoint: "/tmp/oracle-cli.js", + }); + expect(result.structuredContent).toMatchObject({ + sessionId: "mcp-detached-test", + status: "pending", + }); + expect(result.content[0]?.text).toContain("Detached browser worker is still running"); + } finally { + await rm(tmpHome, { recursive: true, force: true }); + } + }); }); diff --git a/tests/mcp/followUp.test.ts b/tests/mcp/followUp.test.ts new file mode 100644 index 000000000..e8eb495c9 --- /dev/null +++ b/tests/mcp/followUp.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test, vi } from "vitest"; +import { registerFollowUpTool } from "../../src/mcp/tools/followUp.js"; + +describe("follow_up MCP tool", () => { + test("starts a child follow-up session and returns status", async () => { + const handlers: Array<(input: unknown) => Promise> = []; + const startBrowserFollowUpSession = vi.fn(async () => ({ + parentSessionId: "parent-session", + parentConversationUrl: "https://chatgpt.com/c/abc123", + session: { + id: "child-session", + status: "pending", + options: {}, + }, + detached: true, + finalizerStarted: true, + reattachCommand: "oracle session child-session --render", + })); + const readFollowUpLogTail = vi.fn(async () => "log tail"); + registerFollowUpTool( + { + registerTool: (_name: string, _def: unknown, fn: (input: unknown) => Promise) => { + handlers.push(fn); + }, + } as unknown as Parameters[0], + { + startBrowserFollowUpSession: startBrowserFollowUpSession as never, + readFollowUpLogTail, + cliEntrypoint: "/tmp/oracle-cli.js", + }, + ); + const handler = handlers[0]; + if (!handler) throw new Error("handler not registered"); + + const result = (await handler({ + parentSessionId: "parent-session", + prompt: "continue this", + slug: "child session now", + })) as { + content: Array<{ type: "text"; text: string }>; + structuredContent: { + sessionId: string; + parentSessionId: string; + status: string; + logTail?: string; + }; + }; + + expect(startBrowserFollowUpSession).toHaveBeenCalledWith("parent-session", { + prompt: "continue this", + slug: "child session now", + wait: undefined, + files: undefined, + cliEntrypoint: "/tmp/oracle-cli.js", + }); + expect(result.structuredContent).toEqual({ + sessionId: "child-session", + parentSessionId: "parent-session", + status: "pending", + logTail: "log tail", + }); + expect(result.content[0]?.text).toContain("Follow-up session child-session"); + }); + + test("rejects files because follow_up is prompt-only in v1", async () => { + const handlers: Array<(input: unknown) => Promise> = []; + const startBrowserFollowUpSession = vi.fn(); + registerFollowUpTool( + { + registerTool: (_name: string, _def: unknown, fn: (input: unknown) => Promise) => { + handlers.push(fn); + }, + } as unknown as Parameters[0], + { startBrowserFollowUpSession: startBrowserFollowUpSession as never }, + ); + const handler = handlers[0]; + if (!handler) throw new Error("handler not registered"); + + await expect( + handler({ parentSessionId: "parent", prompt: "next", files: ["a.ts"] }), + ).rejects.toThrow(/prompt-only/i); + expect(startBrowserFollowUpSession).not.toHaveBeenCalled(); + }); + + test("uses the waited session status when wait is requested", async () => { + const handlers: Array<(input: unknown) => Promise> = []; + const startBrowserFollowUpSession = vi.fn(async () => ({ + parentSessionId: "parent-session", + parentConversationUrl: "https://chatgpt.com/c/abc123", + session: { + id: "child-session", + status: "pending", + options: {}, + }, + detached: true, + finalizerStarted: true, + reattachCommand: "oracle session child-session --render", + })); + const waitForFollowUpSession = vi.fn(async () => ({ + id: "child-session", + status: "completed", + })); + registerFollowUpTool( + { + registerTool: (_name: string, _def: unknown, fn: (input: unknown) => Promise) => { + handlers.push(fn); + }, + } as unknown as Parameters[0], + { + startBrowserFollowUpSession: startBrowserFollowUpSession as never, + waitForFollowUpSession: waitForFollowUpSession as never, + readFollowUpLogTail: vi.fn(async () => undefined), + waitMs: 123, + pollMs: 45, + }, + ); + const handler = handlers[0]; + if (!handler) throw new Error("handler not registered"); + + const result = (await handler({ + parentSessionId: "parent-session", + prompt: "continue this", + wait: true, + })) as { + structuredContent: { + status: string; + }; + }; + + expect(waitForFollowUpSession).toHaveBeenCalledWith("child-session", { + timeoutMs: 123, + pollMs: 45, + }); + expect(result.structuredContent.status).toBe("completed"); + }); +});