diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f014d7bc0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +bun.lock text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f97742eb0..2d479f31f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,16 @@ jobs: cd apps/web ./node_modules/.bin/playwright install --with-deps chromium + - name: Electron browser overlay lifecycle + timeout-minutes: 2 + run: | + ELECTRON_BINARY="$(cd apps/desktop && bun -e 'console.log(require("electron"))')" + ELECTRON_SANDBOX="$(dirname "$ELECTRON_BINARY")/chrome-sandbox" + test -f "$ELECTRON_SANDBOX" + sudo chown root:root "$ELECTRON_SANDBOX" + sudo chmod 4755 "$ELECTRON_SANDBOX" + xvfb-run -a bun run test:desktop-browser-overlay-lifecycle + # Blocking product-behavior coverage. Linux-only pixel/font/layout # comparisons run separately below while their rendering is quarantined. - name: Browser test (stable) @@ -176,6 +186,9 @@ jobs: - name: Test Effect Windows process spawn run: bun run --cwd apps/server test src/windowsProcessEffect.test.ts + - name: Test Windows provider runtime discovery + run: bun run --cwd apps/server test src/provider/Layers/ProviderRuntimeManager.test.ts + - name: Test Windows private state initialization run: | bun run --cwd apps/server test src/serverPrivateDirectories.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd320d79b..686b4c1ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,12 +126,23 @@ jobs: echo "Publication source verified at ${RELEASE_BRANCH} (${release_branch_sha})." + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + - id: release_meta name: Resolve release policy env: RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} run: node scripts/resolve-release-update-policy.ts "$RELEASE_VERSION" + - name: Verify release-note catalog structure + run: node scripts/verify-release-notes.ts "${{ steps.release_meta.outputs.version }}" + - name: Verify public updater repository env: GH_TOKEN: ${{ github.token }} @@ -177,14 +188,6 @@ jobs: fi done - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Verify Scient identity run: bun run brand:check diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index b9410b056..79a2bd8b3 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -52,9 +52,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) -- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state +- `chat.new`: create a new chat thread using the target project's configured workspace default - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) -- `chat.newTerminal`: create a new terminal-first thread preserving the active thread's branch/worktree state +- `chat.newTerminal`: create a new terminal-first thread using the target project's configured workspace default; if a configured worktree has no concrete path, safely fall back to the project's local checkout - `composer.focus.toggle`: focus or blur the chat prompt composer - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2a5d743b1..d3452ea8e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,7 @@ "start": "bun run scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", + "test:browser-overlay-lifecycle": "bun scripts/run-browser-overlay-lifecycle.mjs", "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { diff --git a/apps/desktop/scripts/browser-overlay-lifecycle.electron.ts b/apps/desktop/scripts/browser-overlay-lifecycle.electron.ts new file mode 100644 index 000000000..fb792a009 --- /dev/null +++ b/apps/desktop/scripts/browser-overlay-lifecycle.electron.ts @@ -0,0 +1,195 @@ +// FILE: browser-overlay-lifecycle.electron.ts +// Purpose: Real-Electron regression for adopted renderer webview overlay lifetime. +// Layer: Desktop integration harness (bundled and launched by the sibling runner). + +import { join } from "node:path"; + +import type { ThreadId } from "@synara/contracts"; +import { app, BrowserWindow, ipcMain, type WebContents } from "electron"; + +import { DesktopBrowserManager } from "../src/browserManager"; + +const THREAD_ID = "electron-overlay-lifecycle" as ThreadId; +const OVERLAY_HOLD_MS = 31_250; +const TEST_TIMEOUT_MS = 10_000; +const BOUNDS_CHANNEL = "scient:test:browser-overlay:set-bounds"; + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function withTimeout(promise: Promise, label: string): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(`Timed out waiting for ${label}.`)), TEST_TIMEOUT_MS); + }), + ]); +} + +async function waitForRendererHarness(hostWindow: BrowserWindow): Promise { + await withTimeout( + (async () => { + while (!hostWindow.isDestroyed()) { + const ready = await hostWindow.webContents.executeJavaScript( + `Boolean(window.scientBrowserOverlayLifecycle?.ready)`, + ); + if (ready) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("The host window was destroyed before its renderer harness loaded."); + })(), + "the renderer coordination harness", + ); +} + +async function main(): Promise { + const profileDir = process.env.SCIENT_BROWSER_OVERLAY_TEST_PROFILE; + const fixturePath = process.env.SCIENT_BROWSER_OVERLAY_TEST_FIXTURE; + const preloadPath = process.env.SCIENT_BROWSER_OVERLAY_TEST_PRELOAD; + invariant(profileDir, "Missing isolated Electron profile path."); + invariant(fixturePath, "Missing renderer fixture path."); + invariant(preloadPath, "Missing preload fixture path."); + app.setPath("userData", profileDir); + app.setPath("sessionData", join(profileDir, "session-data")); + + await app.whenReady(); + + let resolveGuest: ((webContents: WebContents) => void) | null = null; + const guestPromise = new Promise((resolve) => { + resolveGuest = resolve; + }); + app.on("web-contents-created", (_event, webContents) => { + if (webContents.getType() === "webview") { + resolveGuest?.(webContents); + resolveGuest = null; + } + }); + + const hostWindow = new BrowserWindow({ + show: false, + width: 900, + height: 700, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: preloadPath, + sandbox: true, + webviewTag: true, + }, + }); + const manager = new DesktopBrowserManager(); + manager.setWindow(hostWindow); + const boundsEvents: Array<{ width: number; height: number } | null> = []; + ipcMain.handle(BOUNDS_CHANNEL, (_event, bounds: { width: number; height: number } | null) => { + boundsEvents.push(bounds); + manager.setPanelBounds({ + threadId: THREAD_ID, + bounds: bounds ? { x: 20, y: 30, width: bounds.width, height: bounds.height } : null, + surface: "renderer", + }); + }); + + try { + await hostWindow.loadFile(fixturePath); + await waitForRendererHarness(hostWindow); + const guest = await withTimeout(guestPromise, "the renderer webview"); + const guestId = guest.id; + + const opened = manager.open({ threadId: THREAD_ID }); + const tabId = opened.activeTabId; + invariant(tabId, "The browser session did not create an active tab."); + manager.attachWebview({ threadId: THREAD_ID, tabId, webContentsId: guestId }); + const initialMode = await hostWindow.webContents.executeJavaScript( + `window.scientBrowserOverlayLifecycle.syncBounds()`, + ); + invariant(initialMode === "send", `Initial renderer bounds mode was ${String(initialMode)}.`); + + const beforeOverlay = manager.getState({ threadId: THREAD_ID }); + invariant(beforeOverlay.activeTabId === tabId, "The adopted tab was not active."); + invariant(beforeOverlay.tabs[0]?.status === "live", "The adopted tab was not live."); + const boundsEventsBeforeOverlay = boundsEvents.length; + + const openMode = await hostWindow.webContents.executeJavaScript( + `window.scientBrowserOverlayLifecycle.openOverlay()`, + ); + invariant(openMode === "suppress", `Overlay renderer bounds mode was ${String(openMode)}.`); + invariant( + boundsEvents.length === boundsEventsBeforeOverlay, + "Opening the overlay sent a lifecycle-changing bounds event.", + ); + + await new Promise((resolve) => setTimeout(resolve, OVERLAY_HOLD_MS)); + + const whileOccluded = manager.getState({ threadId: THREAD_ID }); + invariant(!guest.isDestroyed(), "The adopted renderer webview was destroyed while occluded."); + invariant(whileOccluded.activeTabId === tabId, "The active tab changed while occluded."); + invariant( + whileOccluded.tabs.some((tab) => tab.id === tabId && tab.status === "live"), + "The adopted tab session was suspended while occluded.", + ); + invariant( + !boundsEvents.slice(boundsEventsBeforeOverlay).includes(null), + "Overlay occlusion sent null bounds and started the hide lifecycle.", + ); + + const closeMode = await hostWindow.webContents.executeJavaScript( + `window.scientBrowserOverlayLifecycle.closeOverlay()`, + ); + invariant(closeMode === "send", `Recovered renderer bounds mode was ${String(closeMode)}.`); + + const recoveredWidth = await hostWindow.webContents.executeJavaScript( + `document.querySelector('#browser-host').getBoundingClientRect().width`, + ); + const afterOverlay = manager.getState({ threadId: THREAD_ID }); + const internals = manager as unknown as { + runtimes: Map; + suspendTimers: Map>; + }; + const runtime = internals.runtimes.get(`${THREAD_ID}:${tabId}`); + + invariant(recoveredWidth === 700, `Renderer geometry recovered to ${recoveredWidth}, not 700.`); + invariant( + boundsEvents.at(-1)?.width === 700, + "The renderer did not send recovered geometry after the overlay closed.", + ); + invariant( + runtime?.webContents.id === guestId, + "A different runtime replaced the adopted webview.", + ); + invariant(runtime.ownsWebContents === false, "The adopted renderer runtime changed ownership."); + invariant(internals.suspendTimers.size === 0, "Overlay occlusion scheduled a thread suspend."); + invariant( + afterOverlay.activeTabId === tabId, + "The tab session did not recover after occlusion.", + ); + + console.log( + JSON.stringify({ + result: "passed", + heldOccludedMs: OVERLAY_HOLD_MS, + adoptedWebContentsId: guestId, + activeTabId: tabId, + openMode, + closeMode, + recoveredWidth, + }), + ); + } finally { + ipcMain.removeHandler(BOUNDS_CHANNEL); + manager.dispose(); + if (!hostWindow.isDestroyed()) { + hostWindow.destroy(); + } + } +} + +void main().then( + () => app.quit(), + (error) => { + console.error(error instanceof Error ? error.stack : error); + app.exit(1); + }, +); diff --git a/apps/desktop/scripts/browser-overlay-lifecycle.preload.ts b/apps/desktop/scripts/browser-overlay-lifecycle.preload.ts new file mode 100644 index 000000000..3be4fe3ea --- /dev/null +++ b/apps/desktop/scripts/browser-overlay-lifecycle.preload.ts @@ -0,0 +1,11 @@ +// FILE: browser-overlay-lifecycle.preload.ts +// Purpose: Minimal isolated bridge for the Electron overlay lifecycle regression. + +import { contextBridge, ipcRenderer } from "electron"; + +const BOUNDS_CHANNEL = "scient:test:browser-overlay:set-bounds"; + +contextBridge.exposeInMainWorld("scientBrowserOverlayTestApi", { + setPanelBounds: (bounds: { width: number; height: number } | null) => + ipcRenderer.invoke(BOUNDS_CHANNEL, bounds), +}); diff --git a/apps/desktop/scripts/browser-overlay-lifecycle.renderer.ts b/apps/desktop/scripts/browser-overlay-lifecycle.renderer.ts new file mode 100644 index 000000000..eb853a474 --- /dev/null +++ b/apps/desktop/scripts/browser-overlay-lifecycle.renderer.ts @@ -0,0 +1,63 @@ +// FILE: browser-overlay-lifecycle.renderer.ts +// Purpose: Exercise the production overlay classifier and bounds decision in a real renderer. + +import { + hasNativeBrowserObscuringOverlay, + resolveNativeBrowserBoundsSyncMode, + setBrowserWebviewOverlayOcclusion, + type BrowserWebviewElement, +} from "../../web/src/components/BrowserPanel.overlay"; + +declare global { + interface Window { + scientBrowserOverlayTestApi: { + setPanelBounds(bounds: { width: number; height: number } | null): Promise; + }; + scientBrowserOverlayLifecycle: { + ready: true; + syncBounds(): Promise; + openOverlay(): Promise; + closeOverlay(): Promise; + }; + } +} + +const host = document.querySelector("#browser-host"); +const webview = document.querySelector("#browser"); +if (!host || !webview) { + throw new Error("Missing browser overlay lifecycle fixture elements."); +} + +async function syncBounds(): Promise { + const obscuredByOverlay = hasNativeBrowserObscuringOverlay(host); + setBrowserWebviewOverlayOcclusion(webview, obscuredByOverlay); + const rect = host.getBoundingClientRect(); + const mode = resolveNativeBrowserBoundsSyncMode({ + obscuredByOverlay, + paneIsActuallyHidden: rect.width <= 0 || rect.height <= 0, + }); + if (mode === "suppress") { + return mode; + } + await window.scientBrowserOverlayTestApi.setPanelBounds( + mode === "hide" ? null : { width: rect.width, height: rect.height }, + ); + return mode; +} + +window.scientBrowserOverlayLifecycle = { + ready: true, + syncBounds, + async openOverlay() { + const overlay = document.createElement("div"); + overlay.id = "test-overlay"; + overlay.dataset.nativeBrowserOverlay = "true"; + document.body.append(overlay); + host.style.width = "700px"; + return syncBounds(); + }, + async closeOverlay() { + document.querySelector("#test-overlay")?.remove(); + return syncBounds(); + }, +}; diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 3c3075ba2..4b46ec4bd 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -137,9 +137,13 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } -export function resolveElectronPath() { +export function resolveElectronPackagePath() { const require = createRequire(import.meta.url); - const electronBinaryPath = require("electron"); + return require("electron"); +} + +export function resolveElectronPath() { + const electronBinaryPath = resolveElectronPackagePath(); if (process.platform !== "darwin") { return electronBinaryPath; diff --git a/apps/desktop/scripts/run-browser-overlay-lifecycle.mjs b/apps/desktop/scripts/run-browser-overlay-lifecycle.mjs new file mode 100644 index 000000000..43db0ed08 --- /dev/null +++ b/apps/desktop/scripts/run-browser-overlay-lifecycle.mjs @@ -0,0 +1,270 @@ +// Bundles a hermetic Electron fixture, launches it with an isolated profile, and always +// tears down the detached process group and temporary state on completion or interruption. +import { spawn, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +import { resolveElectronPackagePath, resolveLinuxSandboxArgs } from "./electron-launcher.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const desktopDir = resolve(scriptDir, ".."); +const workspaceRoot = resolve(desktopDir, "../.."); +let tempDir = null; +let child = null; + +function cleanupTempDir() { + if (!tempDir) return; + const path = tempDir; + rmSync(path, { recursive: true, force: true }); + tempDir = null; +} + +let handleInterrupt = (signal, code) => { + process.stderr.write(`Electron browser overlay lifecycle test interrupted by ${signal}.\n`); + cleanupTempDir(); + process.exit(code); +}; +process.once("SIGINT", () => handleInterrupt("SIGINT", 130)); +process.once("SIGTERM", () => handleInterrupt("SIGTERM", 143)); + +tempDir = mkdtempSync(join(tmpdir(), "scient-browser-overlay-lifecycle-")); +// Register cleanup before any other setup can fail after creating the directory. +process.once("exit", cleanupTempDir); + +const setupSignalProbePath = process.env.SCIENT_BROWSER_OVERLAY_TEST_SETUP_SIGNAL_PROBE?.trim(); +if (setupSignalProbePath) { + writeFileSync(setupSignalProbePath, tempDir, "utf8"); + await new Promise((resolveProbe) => setTimeout(resolveProbe, 30_000)); + throw new Error("Setup signal probe was not interrupted."); +} + +const profileDir = join(tempDir, "profile"); +mkdirSync(join(profileDir, "session-data"), { recursive: true }); +// Allow both bounded 10s fixture startup waits, the required >30s overlay hold, +// Electron launch/load time, and cleanup without making a cold run race its watchdog. +const PROCESS_TIMEOUT_MS = 90_000; +const electronOutputPath = join(tempDir, "browser-overlay-lifecycle.cjs"); +const preloadOutputPath = join(tempDir, "browser-overlay-lifecycle.preload.cjs"); +const rendererOutputPath = join(tempDir, "browser-overlay-lifecycle.renderer.js"); +const fixturePath = join(tempDir, "browser-overlay-lifecycle.html"); +const macTestBundleId = `com.scientfactory.scient.browser-overlay-test.${process.pid}`; +const macSavedStatePaths = [ + join(tmpdir(), `${macTestBundleId}.savedState`), + join(homedir(), "Library", "Saved Application State", `${macTestBundleId}.savedState`), +]; + +function cleanupTemporaryState() { + cleanupTempDir(); + if (process.platform === "darwin") { + for (const savedStatePath of macSavedStatePaths) { + rmSync(savedStatePath, { recursive: true, force: true }); + } + } +} + +// Keep setup failures hermetic too: package resolution, sandbox validation, fixture +// generation, and process spawning can all fail before the child handlers are installed. +process.removeListener("exit", cleanupTempDir); +process.once("exit", cleanupTemporaryState); + +function interruptSetup(signal, code) { + process.stderr.write(`Electron browser overlay lifecycle test interrupted by ${signal}.\n`); + killChildTree(); + cleanupTemporaryState(); + process.exit(code); +} + +handleInterrupt = interruptSetup; + +function createMacTestElectronExecutable(originalExecutablePath) { + const originalAppPath = dirname(dirname(dirname(originalExecutablePath))); + const testAppPath = join(tempDir, "ScientBrowserOverlayTest.app"); + const testContentsPath = join(testAppPath, "Contents"); + const clone = spawnSync("cp", ["-cR", originalAppPath, testAppPath], { encoding: "utf8" }); + if (clone.status !== 0) { + throw new Error( + `Could not clone the Electron test app: ${clone.stderr || clone.stdout || "unknown error"}`, + ); + } + + const originalInfoPath = join(originalAppPath, "Contents", "Info.plist"); + const originalInfo = readFileSync(originalInfoPath, "utf8"); + const info = originalInfo.replace( + /(CFBundleIdentifier<\/key>\s*)[^<]+(<\/string>)/, + `$1${macTestBundleId}$2`, + ); + if (info === originalInfo) { + throw new Error("Could not isolate the Electron test app bundle identifier."); + } + writeFileSync(join(testContentsPath, "Info.plist"), info, "utf8"); + return join(testContentsPath, "MacOS", "Electron"); +} + +const builds = [ + { + entry: join(scriptDir, "browser-overlay-lifecycle.electron.ts"), + output: electronOutputPath, + args: ["--target=node", "--format=cjs", "--external=electron"], + }, + { + entry: join(scriptDir, "browser-overlay-lifecycle.preload.ts"), + output: preloadOutputPath, + args: ["--target=node", "--format=cjs", "--external=electron"], + }, + { + entry: join(scriptDir, "browser-overlay-lifecycle.renderer.ts"), + output: rendererOutputPath, + args: ["--target=browser", "--format=iife"], + }, +]; + +for (const build of builds) { + const result = spawnSync( + process.execPath, + ["build", build.entry, ...build.args, `--outfile=${build.output}`], + { cwd: workspaceRoot, encoding: "utf8" }, + ); + if (result.status !== 0) { + process.stderr.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + process.exit(result.status ?? 1); + } +} + +writeFileSync( + fixturePath, + ` + +
+ +
+ `, + "utf8", +); + +const electronPackagePath = resolveElectronPackagePath(); +const electronExecutablePath = + process.platform === "darwin" + ? createMacTestElectronExecutable(electronPackagePath) + : electronPackagePath; +const electronArgs = [ + ...resolveLinuxSandboxArgs(electronPackagePath, { development: false }), + "--disable-gpu", + electronOutputPath, + ...(process.platform === "darwin" ? ["-ApplePersistenceIgnoreState", "YES"] : []), +]; +child = spawn(electronExecutablePath, electronArgs, { + cwd: workspaceRoot, + detached: process.platform !== "win32", + env: { + ...process.env, + ELECTRON_ENABLE_LOGGING: "1", + SCIENT_BROWSER_OVERLAY_TEST_FIXTURE: fixturePath, + SCIENT_BROWSER_OVERLAY_TEST_PRELOAD: preloadOutputPath, + SCIENT_BROWSER_OVERLAY_TEST_PROFILE: profileDir, + }, + stdio: ["ignore", "pipe", "pipe"], +}); + +let output = ""; +let finished = false; +let requestedExitCode = null; +child.stdout.on("data", (chunk) => { + output += chunk.toString(); + process.stdout.write(chunk); +}); +child.stderr.on("data", (chunk) => { + output += chunk.toString(); + process.stderr.write(chunk); +}); + +function killChildTree() { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return true; + if (process.platform === "win32") { + const result = spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + encoding: "utf8", + }); + if (result.status !== 0) { + process.stderr.write( + `Could not terminate Electron process tree: ${result.stderr || result.stdout || "taskkill failed"}\n`, + ); + return false; + } + return true; + } + try { + process.kill(-child.pid, "SIGKILL"); + return true; + } catch { + const killed = child.kill("SIGKILL"); + if (!killed) { + process.stderr.write("Could not terminate Electron process tree.\n"); + } + return killed; + } +} + +function finish(code) { + if (finished) return; + finished = true; + clearTimeout(timeout); + cleanupTemporaryState(); + process.exit(code); +} + +function interrupt(signal, code) { + if (finished) return; + requestedExitCode = code; + process.stderr.write(`Electron browser overlay lifecycle test interrupted by ${signal}.\n`); + killChildTree(); + // Do not defer filesystem cleanup until child exit: interruption can also + // occur before child listeners are installed, and the wrapper may be exiting. + cleanupTemporaryState(); + setTimeout(() => finish(code), 2_000).unref(); +} + +handleInterrupt = interrupt; +process.once("uncaughtException", (error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + interrupt("uncaughtException", 1); +}); +process.once("unhandledRejection", (error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + interrupt("unhandledRejection", 1); +}); + +const timeout = setTimeout(() => { + requestedExitCode = 1; + process.stderr.write("Electron browser overlay lifecycle test timed out.\n"); + killChildTree(); + setTimeout(() => finish(1), 2_000).unref(); +}, PROCESS_TIMEOUT_MS); + +child.on("error", (error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + requestedExitCode = 1; + killChildTree(); + finish(1); +}); + +child.on("exit", (code, signal) => { + if (requestedExitCode !== null) { + finish(requestedExitCode); + return; + } + if (code === 0 && output.includes('"result":"passed"')) { + finish(0); + return; + } + process.stderr.write( + `Electron browser overlay lifecycle test failed (code=${String(code)}, signal=${String(signal)}).\n`, + ); + finish(code && code > 0 ? code : 1); +}); diff --git a/apps/desktop/src/browserManager.reliability.test.ts b/apps/desktop/src/browserManager.reliability.test.ts new file mode 100644 index 000000000..26b055ba5 --- /dev/null +++ b/apps/desktop/src/browserManager.reliability.test.ts @@ -0,0 +1,580 @@ +// FILE: browserManager.reliability.test.ts +// Purpose: Verifies browser session closure and recovery from destroyed Electron runtimes. +// Layer: Desktop unit test +// Depends on: DesktopBrowserManager with a minimal Electron session mock + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const electron = vi.hoisted(() => { + const createdWebContents: Array<{ + id: number; + loadURL: ReturnType; + reload: ReturnType; + setWebRTCIPHandlingPolicy: ReturnType; + handlers: Map void>>; + windowOpenHandler: ((details: any) => { action: string }) | null; + }> = []; + const createdWebContentsViewPreferences: Array> = []; + const createdViews: Array<{ + setBounds: ReturnType; + setVisible: ReturnType; + }> = []; + const sessions = new Map< + string, + { + setUserAgent: ReturnType; + setPermissionCheckHandler: ReturnType; + setPermissionRequestHandler: ReturnType; + on: ReturnType; + clearStorageData: ReturnType; + clearCache: ReturnType; + setProxy: ReturnType; + resolveHost: ReturnType; + webRequest: { + onBeforeSendHeaders: ReturnType; + onBeforeRequest: ReturnType; + onCompleted: ReturnType; + }; + } + >(); + let nextWebContentsId = 1; + let setProxyImplementation = async (): Promise => undefined; + + function createWebContents() { + let currentUrl = "about:blank"; + const handlers = new Map void>>(); + const webContents = { + id: nextWebContentsId++, + debugger: { + isAttached: () => false, + detach: vi.fn(), + }, + navigationHistory: { + canGoBack: () => false, + canGoForward: () => false, + }, + isDestroyed: () => false, + getURL: () => currentUrl, + getTitle: () => currentUrl, + isLoading: () => false, + getProcessId: () => 42, + loadURL: vi.fn(async (url: string) => { + currentUrl = url; + }), + setUserAgent: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), + handlers, + windowOpenHandler: null as ((details: any) => { action: string }) | null, + setWindowOpenHandler: vi.fn((handler: (details: any) => { action: string }) => { + webContents.windowOpenHandler = handler; + }), + on: vi.fn((name: string, handler: (...args: any[]) => void) => { + handlers.set(name, [...(handlers.get(name) ?? []), handler]); + }), + removeListener: vi.fn((name: string, handler: (...args: any[]) => void) => { + handlers.set( + name, + (handlers.get(name) ?? []).filter((candidate) => candidate !== handler), + ); + }), + close: vi.fn(), + reload: vi.fn(), + goBack: vi.fn(), + goForward: vi.fn(), + openDevTools: vi.fn(), + }; + createdWebContents.push(webContents); + return webContents; + } + + function sessionFor(partition: string) { + let existing = sessions.get(partition); + if (existing) return existing; + existing = { + setUserAgent: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setPermissionRequestHandler: vi.fn(), + on: vi.fn(), + clearStorageData: vi.fn(async () => undefined), + clearCache: vi.fn(async () => undefined), + setProxy: vi.fn(() => setProxyImplementation()), + resolveHost: vi.fn(async (hostname: string) => ({ + endpoints: [ + { + address: + hostname === "private.example" || hostname.endsWith(".nip.io") + ? "127.0.0.1" + : "93.184.216.34", + family: "ipv4", + }, + ], + })), + webRequest: { + onBeforeSendHeaders: vi.fn(), + onBeforeRequest: vi.fn(), + onCompleted: vi.fn(), + }, + }; + sessions.set(partition, existing); + return existing; + } + + return { + createdWebContents, + createdWebContentsViewPreferences, + createdViews, + sessions, + createWebContents, + setProxyImplementation: (implementation: () => Promise) => { + setProxyImplementation = implementation; + }, + sessionFor, + }; +}); + +vi.mock("electron", () => ({ + app: { + userAgentFallback: "Mozilla/5.0 Chrome/124.0.0.0 Electron/40.0.0 Scient/0.5.12 Safari/537.36", + getName: () => "Scient", + getPreferredSystemLanguages: () => ["en-US"], + }, + BrowserWindow: class { + readonly mocked = true; + }, + clipboard: { + writeImage: vi.fn(), + writeText: vi.fn(), + }, + nativeImage: { + createFromBuffer: vi.fn(), + }, + session: { + fromPartition: (partition: string) => electron.sessionFor(partition), + }, + shell: { + openExternal: vi.fn(), + }, + webContents: { + fromId: vi.fn(), + }, + WebContentsView: class { + readonly webContents = electron.createWebContents(); + readonly setBounds = vi.fn(); + readonly setBackgroundColor = vi.fn(); + readonly setVisible = vi.fn(); + + constructor(options: { webPreferences?: Record }) { + electron.createdWebContentsViewPreferences.push(options.webPreferences ?? {}); + electron.createdViews.push(this); + } + }, +})); + +import type { ThreadId } from "@synara/contracts"; + +import { DesktopBrowserManager } from "./browserManager"; + +const THREAD_ID = "thread-close-tab" as ThreadId; + +describe("DesktopBrowserManager reliability", () => { + beforeEach(() => { + vi.clearAllMocks(); + electron.createdWebContents.splice(0); + electron.createdWebContentsViewPreferences.splice(0); + electron.createdViews.splice(0); + electron.sessions.clear(); + electron.setProxyImplementation(async () => undefined); + }); + + it("closes the browser session when its final tab closes", () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const tabId = opened.activeTabId; + + expect(tabId).toBeTruthy(); + const closed = manager.closeTab({ + threadId: THREAD_ID, + tabId: tabId ?? "", + }); + + expect(closed.open).toBe(false); + expect(closed.tabs).toEqual([]); + expect(closed.activeTabId).toBeNull(); + manager.dispose(); + }); + + it("keeps the browser open while another tab remains", () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const firstTabId = opened.activeTabId; + const withSecondTab = manager.newTab({ + threadId: THREAD_ID, + url: "https://example.com/", + }); + + const next = manager.closeTab({ + threadId: THREAD_ID, + tabId: firstTabId ?? "", + }); + + expect(next.open).toBe(true); + expect(next.tabs).toHaveLength(1); + expect(next.activeTabId).toBe(withSecondTab.activeTabId); + manager.dispose(); + }); + + it("selects the adjacent tab when the active tab closes", () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const firstTabId = opened.activeTabId; + const withSecondTab = manager.newTab({ + threadId: THREAD_ID, + url: "https://second.example/", + }); + const secondTabId = withSecondTab.activeTabId; + const withThirdTab = manager.newTab({ + threadId: THREAD_ID, + url: "https://third.example/", + }); + + manager.selectTab({ threadId: THREAD_ID, tabId: firstTabId ?? "" }); + const afterClosingFirst = manager.closeTab({ + threadId: THREAD_ID, + tabId: firstTabId ?? "", + }); + expect(afterClosingFirst.activeTabId).toBe(secondTabId); + + manager.selectTab({ threadId: THREAD_ID, tabId: secondTabId ?? "" }); + const afterClosingSecond = manager.closeTab({ + threadId: THREAD_ID, + tabId: secondTabId ?? "", + }); + expect(afterClosingSecond.activeTabId).toBe(withThirdTab.activeTabId); + manager.dispose(); + }); + + it("replaces a destroyed tracked runtime before navigating", async () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const tabId = opened.activeTabId; + expect(tabId).toBeTruthy(); + + const runtimeKey = `${THREAD_ID}:${tabId}`; + const internals = manager as unknown as { + runtimes: Map< + string, + { + key: string; + threadId: ThreadId; + tabId: string; + webContents: { isDestroyed: () => boolean }; + view: null; + ownsWebContents: boolean; + listenerDisposers: Array<() => void>; + } + >; + }; + internals.runtimes.set(runtimeKey, { + key: runtimeKey, + threadId: THREAD_ID, + tabId: tabId ?? "", + webContents: { isDestroyed: () => true }, + view: null, + ownsWebContents: true, + listenerDisposers: [], + }); + + expect(() => + manager.navigate({ + threadId: THREAD_ID, + tabId: tabId ?? "", + url: "https://example.com/", + }), + ).not.toThrow(); + + await vi.waitFor(() => { + expect(electron.createdWebContents).toHaveLength(1); + expect(electron.createdWebContents[0]?.loadURL).toHaveBeenCalledWith("https://example.com/"); + }); + expect(manager.getState({ threadId: THREAD_ID }).lastError).toBeNull(); + manager.dispose(); + }); + + it("hides and restores a native local HTML view without suspending or resurrecting it", () => { + const manager = new DesktopBrowserManager(); + const contentView = { + addChildView: vi.fn(), + removeChildView: vi.fn(), + }; + manager.setWindow({ contentView } as never); + const previewUrl = "http://g-12345678-1234-4123-8123-123456789abc.preview.localhost:43123/"; + manager.open({ + threadId: THREAD_ID, + initialUrl: previewUrl, + kind: "local-html", + }); + const bounds = { x: 10, y: 20, width: 640, height: 480 }; + + manager.setPanelBounds({ threadId: THREAD_ID, bounds, surface: "native", occluded: false }); + const view = electron.createdViews.at(-1); + expect(view).toBeDefined(); + if (!view) throw new Error("Expected a native local HTML view."); + expect(view.setBounds).toHaveBeenLastCalledWith(bounds); + + manager.setPanelBounds({ threadId: THREAD_ID, bounds, surface: "native", occluded: true }); + expect(view.setVisible).toHaveBeenLastCalledWith(false); + expect(view.setBounds).toHaveBeenLastCalledWith({ x: 0, y: 0, width: 0, height: 0 }); + const internals = manager as unknown as { + activeThreadId: ThreadId | null; + suspendTimers: Map; + }; + expect(internals.activeThreadId).toBe(THREAD_ID); + expect(internals.suspendTimers.has(THREAD_ID)).toBe(false); + + manager.setPanelBounds({ threadId: THREAD_ID, bounds, surface: "native", occluded: false }); + expect(view.setVisible).toHaveBeenLastCalledWith(true); + expect(view.setBounds).toHaveBeenLastCalledWith(bounds); + + manager.setPanelBounds({ threadId: THREAD_ID, bounds, surface: "native", occluded: true }); + const visibleCallsBeforeClose = view.setVisible.mock.calls.filter( + ([visible]) => visible, + ).length; + manager.close({ threadId: THREAD_ID }); + manager.setPanelBounds({ threadId: THREAD_ID, bounds, surface: "native", occluded: false }); + expect(view.setVisible.mock.calls.filter(([visible]) => visible)).toHaveLength( + visibleCallsBeforeClose, + ); + manager.dispose(); + }); + + it("isolates local HTML requests in a per-tab session and clears it on close", async () => { + const manager = new DesktopBrowserManager(); + const previewUrl = "http://g-12345678-1234-4123-8123-123456789abc.preview.localhost:43123/"; + const opened = manager.open({ + threadId: THREAD_ID, + initialUrl: previewUrl, + kind: "local-html", + allowedExternalUrls: [ + "https://cdn.example/app.js", + "https://private.example/declared.js", + "https://127.0.0.1.nip.io/asset.js", + ], + }); + const tabId = opened.activeTabId; + expect(tabId).toBeTruthy(); + + const partition = `scient-local-html-preview-${THREAD_ID}-${tabId}`; + const previewSession = electron.sessions.get(partition); + expect(previewSession).toBeDefined(); + expect(partition.startsWith("persist:")).toBe(false); + + const beforeRequest = previewSession?.webRequest.onBeforeRequest.mock.calls[0]?.[0]; + expect(beforeRequest).toBeTypeOf("function"); + const exactOriginResult = vi.fn(); + beforeRequest( + { url: `${previewUrl}app.js`, method: "GET", resourceType: "script" }, + exactOriginResult, + ); + expect(exactOriginResult).toHaveBeenCalledWith({ cancel: false }); + const publicResult = vi.fn(); + beforeRequest( + { + url: "https://cdn.example/app.js", + method: "GET", + resourceType: "script", + }, + publicResult, + ); + await vi.waitFor(() => expect(publicResult).toHaveBeenCalledWith({ cancel: false })); + const dnsPrivateResult = vi.fn(); + beforeRequest( + { + url: "https://private.example/declared.js", + method: "GET", + resourceType: "script", + }, + dnsPrivateResult, + ); + await vi.waitFor(() => expect(dnsPrivateResult).toHaveBeenCalledWith({ cancel: true })); + const rebindingResult = vi.fn(); + beforeRequest( + { + url: "https://127.0.0.1.nip.io/asset.js", + method: "GET", + resourceType: "script", + }, + rebindingResult, + ); + await vi.waitFor(() => expect(rebindingResult).toHaveBeenCalledWith({ cancel: true })); + const privateResult = vi.fn(); + beforeRequest( + { + url: "http://127.0.0.1:8080/private", + method: "GET", + resourceType: "xhr", + }, + privateResult, + ); + expect(privateResult).toHaveBeenCalledWith({ cancel: true }); + const fileResult = vi.fn(); + beforeRequest({ url: "file:///etc/passwd", method: "GET", resourceType: "other" }, fileResult); + expect(fileResult).toHaveBeenCalledWith({ cancel: true }); + + const internals = manager as unknown as { + ensureLiveRuntime: (threadId: ThreadId, tabId: string) => unknown; + }; + internals.ensureLiveRuntime(THREAD_ID, tabId ?? ""); + const contents = electron.createdWebContents.at(-1); + expect(electron.createdWebContentsViewPreferences.at(-1)).toMatchObject({ + nodeIntegration: false, + sandbox: true, + }); + expect(contents?.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith("disable_non_proxied_udp"); + expect(previewSession?.setProxy).not.toHaveBeenCalled(); + const completed = previewSession?.webRequest.onCompleted.mock.calls[0]?.[0]; + expect(completed).toBeTypeOf("function"); + completed({ + resourceType: "mainFrame", + statusCode: 404, + webContentsId: contents?.id, + url: `${previewUrl}missing.html`, + }); + expect(manager.getState({ threadId: THREAD_ID }).lastError).toContain("HTTP 404"); + + manager.close({ threadId: THREAD_ID }); + await vi.waitFor(() => { + expect(previewSession?.clearStorageData).toHaveBeenCalledOnce(); + expect(previewSession?.clearCache).toHaveBeenCalledOnce(); + }); + manager.dispose(); + }); + + it("keeps same-origin local navigation and denies cross-origin navigation", () => { + const manager = new DesktopBrowserManager(); + const previewUrl = "http://g-12345678-1234-4123-8123-123456789abc.preview.localhost:43123/"; + const opened = manager.open({ + threadId: THREAD_ID, + initialUrl: previewUrl, + kind: "local-html", + }); + const tabId = opened.activeTabId; + expect(tabId).toBeTruthy(); + const partition = `scient-local-html-preview-${THREAD_ID}-${tabId}`; + expect(electron.sessions.get(partition)?.setProxy).toHaveBeenCalledWith({ + mode: "fixed_servers", + proxyRules: "http=127.0.0.1:1;https=127.0.0.1:1;socks=127.0.0.1:1", + proxyBypassRules: + "<-loopback>;g-12345678-1234-4123-8123-123456789abc.preview.localhost:43123", + }); + + const internals = manager as unknown as { + ensureLiveRuntime: (threadId: ThreadId, tabId: string) => unknown; + }; + internals.ensureLiveRuntime(THREAD_ID, tabId ?? ""); + const contents = electron.createdWebContents.at(-1); + const navigate = contents?.handlers.get("will-frame-navigate")?.[0]; + expect(navigate).toBeTypeOf("function"); + if (!navigate) throw new Error("Expected local HTML navigation policy listener."); + + const sameOriginEvent = { + url: `${previewUrl}linked.html`, + isMainFrame: true, + preventDefault: vi.fn(), + }; + navigate(sameOriginEvent); + expect(sameOriginEvent.preventDefault).not.toHaveBeenCalled(); + + const privateEvent = { + url: "http://localhost:9000/admin", + isMainFrame: true, + preventDefault: vi.fn(), + }; + navigate(privateEvent); + expect(privateEvent.preventDefault).toHaveBeenCalledOnce(); + expect(manager.getState({ threadId: THREAD_ID }).tabs).toHaveLength(1); + + const publicEvent = { + url: "https://example.com/reference", + isMainFrame: true, + preventDefault: vi.fn(), + }; + navigate(publicEvent); + expect(publicEvent.preventDefault).toHaveBeenCalledOnce(); + expect(manager.getState({ threadId: THREAD_ID }).tabs).toHaveLength(1); + manager.dispose(); + }); + + it("waits for the interactive preview network boundary before loading", async () => { + let releaseProxy: () => void = () => undefined; + const proxyReady = new Promise((resolve) => { + releaseProxy = resolve; + }); + electron.setProxyImplementation(() => proxyReady); + const manager = new DesktopBrowserManager(); + const previewUrl = "http://g-12345678-1234-4123-8123-123456789abc.preview.localhost:43123/"; + const opened = manager.open({ + threadId: THREAD_ID, + initialUrl: previewUrl, + kind: "local-html", + }); + const tabId = opened.activeTabId; + expect(tabId).toBeTruthy(); + + const internals = manager as unknown as { + loadTab: (threadId: ThreadId, tabId: string, options: { force: boolean }) => Promise; + }; + const load = internals.loadTab(THREAD_ID, tabId ?? "", { force: true }); + await Promise.resolve(); + expect(electron.createdWebContents.at(-1)?.loadURL).not.toHaveBeenCalled(); + + releaseProxy(); + await load; + expect(electron.createdWebContents.at(-1)?.loadURL).toHaveBeenCalledWith(previewUrl); + manager.dispose(); + }); + + it("deduplicates interactive preview retries before reloading the original URL", async () => { + let proxyAttempts = 0; + let releaseRetry: () => void = () => undefined; + const retryReady = new Promise((resolve) => { + releaseRetry = resolve; + }); + electron.setProxyImplementation(() => { + proxyAttempts += 1; + if (proxyAttempts === 1) { + return Promise.reject(new Error("proxy setup failed")); + } + return retryReady; + }); + const manager = new DesktopBrowserManager(); + const previewUrl = "http://g-12345678-1234-4123-8123-123456789abc.preview.localhost:43123/"; + const opened = manager.open({ + threadId: THREAD_ID, + initialUrl: previewUrl, + kind: "local-html", + }); + const tabId = opened.activeTabId; + expect(tabId).toBeTruthy(); + + const internals = manager as unknown as { + loadTab: (threadId: ThreadId, tabId: string, options: { force: boolean }) => Promise; + }; + await internals.loadTab(THREAD_ID, tabId ?? "", { force: true }); + const contents = electron.createdWebContents.at(-1); + expect(contents?.loadURL).not.toHaveBeenCalled(); + expect(manager.getState({ threadId: THREAD_ID }).lastError).toBe("Couldn't open this page."); + + manager.reload({ threadId: THREAD_ID, tabId: tabId ?? "" }); + const pending = manager.reload({ threadId: THREAD_ID, tabId: tabId ?? "" }); + expect(pending.lastError).toBeNull(); + expect(pending.tabs.find((tab) => tab.id === tabId)?.isLoading).toBe(true); + expect(proxyAttempts).toBe(2); + expect(contents?.loadURL).not.toHaveBeenCalled(); + + releaseRetry(); + await vi.waitFor(() => expect(contents?.loadURL).toHaveBeenCalledWith(previewUrl)); + expect(manager.getState({ threadId: THREAD_ID }).lastError).toBeNull(); + expect(contents?.reload).not.toHaveBeenCalled(); + manager.dispose(); + }); +}); diff --git a/apps/desktop/src/browserManager.ts b/apps/desktop/src/browserManager.ts index fb79fb306..48d3f4dc6 100644 --- a/apps/desktop/src/browserManager.ts +++ b/apps/desktop/src/browserManager.ts @@ -20,6 +20,12 @@ import { artifactPreviewNavigationAllowed, artifactPreviewRequestAllowed, } from "./artifactPreviewPolicy"; +import { loadBrowserRuntimeUrl } from "./browserRuntimeLoad"; +import { + localHtmlPreviewNavigationDisposition, + localHtmlPreviewRequestAllowed, + localHtmlPreviewResolvedAddressesAllowed, +} from "./localHtmlPreviewPolicy"; import type { BrowserAttachWebviewInput, BrowserCaptureScreenshotResult, @@ -58,6 +64,19 @@ const BROWSER_INACTIVE_TAB_SUSPEND_DELAY_PRESSURED_MS = 400; const BROWSER_MAX_WARM_INACTIVE_RUNTIMES_PER_THREAD = 1; const BROWSER_THREAD_SUSPEND_DELAY_MS = 30_000; const BROWSER_ERROR_ABORTED = -3; +const LOCAL_HTML_DEFAULT_CANVAS_SCRIPT = `(() => { + const isTransparent = (value) => + value === "transparent" || value === "rgba(0, 0, 0, 0)"; + const root = document.documentElement; + const body = document.body; + if (!root || !body) return; + if ( + isTransparent(getComputedStyle(root).backgroundColor) && + isTransparent(getComputedStyle(body).backgroundColor) + ) { + root.style.backgroundColor = "#ffffff"; + } +})()`; type BrowserStateListener = (state: ThreadBrowserState) => void; type BrowserCopyLinkListener = (event: BrowserCopyLinkEvent) => void; @@ -106,6 +125,22 @@ function safeUrlOrigin(value: string | null | undefined): string | null { } } +function normalizedLocalHtmlExternalUrls(values: readonly string[] | undefined): string[] { + const normalized = new Set(); + for (const value of values?.slice(0, 250) ?? []) { + if (value.length > 8_192) continue; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + url.hash = ""; + normalized.add(url.toString()); + } catch { + // Ignore malformed renderer input at the desktop trust boundary. + } + } + return [...normalized]; +} + interface BrowserPerformanceSnapshot { counters: { setPanelBoundsCalls: number; @@ -138,6 +173,7 @@ function createBrowserTab( url = ABOUT_BLANK_URL, kind: BrowserTabKind = "web", displayUrl?: string, + allowedExternalUrls?: readonly string[], ): BrowserTabState { return { id: Crypto.randomUUID(), @@ -152,6 +188,11 @@ function createBrowserTab( faviconUrl: null, lastCommittedUrl: null, lastError: null, + ...(kind === "local-html" && allowedExternalUrls + ? { + allowedExternalUrls: normalizedLocalHtmlExternalUrls(allowedExternalUrls), + } + : {}), }; } @@ -222,13 +263,6 @@ function normalizeBounds(bounds: BrowserPanelBounds | null): BrowserPanelBounds }; } -function isAbortedNavigationError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - return /ERR_ABORTED|\(-3\)/i.test(error.message); -} - function mapBrowserLoadError(errorCode: number): string { switch (errorCode) { case -102: @@ -285,6 +319,9 @@ export class DesktopBrowserManager { private spoofedUserAgent: string | null = null; private sessionConfigured = false; private readonly previewSessionsConfigured = new Set(); + private readonly previewSessionReady = new Map>(); + private readonly previewSessionRetries = new Set(); + private readonly occludedThreads = new Set(); private readonly tabSuspendTimers = new Map>(); private readonly suspendTimers = new Map>(); private runtimeSyncFlushScheduled = false; @@ -378,7 +415,14 @@ export class DesktopBrowserManager { } } - private ensurePreviewSessionConfigured(partition: string, artifactOrigin?: string): void { + private ensurePreviewSessionConfigured( + partition: string, + preview?: { + kind: "artifact" | "local-html"; + origin: string; + allowedExternalUrls?: readonly string[]; + }, + ): void { if (this.previewSessionsConfigured.has(partition)) { return; } @@ -388,41 +432,128 @@ export class DesktopBrowserManager { partitionSession.setPermissionRequestHandler((_webContents, _permission, callback) => { callback(false); }); - if (artifactOrigin) { + if (preview) { partitionSession.webRequest.onBeforeRequest((details, callback) => { - callback({ - cancel: !artifactPreviewRequestAllowed({ - url: details.url, - allowedOrigin: artifactOrigin, - resourceType: details.resourceType, - }), + if (preview.kind === "artifact") { + callback({ + cancel: !artifactPreviewRequestAllowed({ + url: details.url, + allowedOrigin: preview.origin, + resourceType: details.resourceType, + }), + }); + return; + } + const allowed = localHtmlPreviewRequestAllowed({ + url: details.url, + allowedOrigin: preview.origin, + ...(preview.allowedExternalUrls + ? { allowedExternalUrls: preview.allowedExternalUrls } + : {}), + method: details.method, + resourceType: details.resourceType, }); + if (!allowed) { + callback({ cancel: true }); + return; + } + const requestUrl = new URL(details.url); + if ( + requestUrl.origin === preview.origin || + requestUrl.protocol === "data:" || + requestUrl.protocol === "blob:" + ) { + callback({ cancel: false }); + return; + } + void partitionSession + .resolveHost(requestUrl.hostname.replace(/^\[|\]$/g, "")) + .then((resolved) => { + callback({ + cancel: !localHtmlPreviewResolvedAddressesAllowed( + resolved.endpoints.map((endpoint) => endpoint.address), + ), + }); + }) + .catch(() => callback({ cancel: true })); }); + if (preview.kind === "local-html") { + partitionSession.webRequest.onCompleted((details) => { + const webContentsId = details.webContentsId ?? details.webContents?.id; + if ( + details.resourceType === "mainFrame" && + details.statusCode >= 400 && + webContentsId !== undefined + ) { + this.markLocalHtmlHttpError(webContentsId, details.url, details.statusCode); + } + }); + } partitionSession.on("will-download", (event) => { event.preventDefault(); }); } + if (preview?.kind === "local-html" && preview.allowedExternalUrls === undefined) { + this.configureInteractiveLocalHtmlProxy(partition, preview.origin); + } else { + this.previewSessionReady.set(partition, Promise.resolve(null)); + } this.previewSessionsConfigured.add(partition); } + private configureInteractiveLocalHtmlProxy( + partition: string, + previewOrigin: string, + ): Promise { + const readiness = session + .fromPartition(partition) + .setProxy({ + mode: "fixed_servers", + proxyRules: "http=127.0.0.1:1;https=127.0.0.1:1;socks=127.0.0.1:1", + proxyBypassRules: `<-loopback>;${new URL(previewOrigin).host}`, + }) + .then( + () => null, + (cause: unknown) => + cause instanceof Error + ? cause + : new Error("Failed to establish the local HTML network boundary."), + ); + this.previewSessionReady.set(partition, readiness); + return readiness; + } + private configureTabSession(threadId: ThreadId, tab: BrowserTabState): void { if (tab.kind === "web") { return; } const partition = browserSessionPartition(tab.kind, threadId, tab.id); - const artifactOrigin = - tab.kind === "artifact" ? (safeUrlOrigin(tab.url) ?? undefined) : undefined; - this.ensurePreviewSessionConfigured(partition, artifactOrigin); + const previewOrigin = + tab.kind === "artifact" || tab.kind === "local-html" ? safeUrlOrigin(tab.url) : null; + this.ensurePreviewSessionConfigured( + partition, + previewOrigin && (tab.kind === "artifact" || tab.kind === "local-html") + ? { + kind: tab.kind, + origin: previewOrigin, + ...(tab.kind === "local-html" && tab.allowedExternalUrls + ? { allowedExternalUrls: tab.allowedExternalUrls } + : {}), + } + : undefined, + ); } - private clearArtifactSession(threadId: ThreadId, tab: BrowserTabState): void { - if (tab.kind !== "artifact") { + private clearPreviewSession(threadId: ThreadId, tab: BrowserTabState): void { + if (tab.kind !== "artifact" && tab.kind !== "local-html") { return; } - const partition = browserSessionPartition("artifact", threadId, tab.id); - const artifactSession = session.fromPartition(partition); + const partition = browserSessionPartition(tab.kind, threadId, tab.id); + const previewSession = session.fromPartition(partition); this.previewSessionsConfigured.delete(partition); - void Promise.all([artifactSession.clearStorageData(), artifactSession.clearCache()]).catch( + this.previewSessionReady.delete(partition); + this.previewSessionRetries.delete(partition); + void Promise.all([previewSession.clearStorageData(), previewSession.clearCache()]).catch( () => undefined, ); } @@ -630,6 +761,10 @@ export class DesktopBrowserManager { this.closeAllPopupWindows(); this.pendingRuntimeSyncs.clear(); this.runtimeLastActiveAtByKey.clear(); + this.previewSessionsConfigured.clear(); + this.previewSessionReady.clear(); + this.previewSessionRetries.clear(); + this.occludedThreads.clear(); this.listeners.clear(); this.copyLinkListeners.clear(); this.states.clear(); @@ -681,6 +816,7 @@ export class DesktopBrowserManager { input.initialUrl, requestedKind, input.displayUrl, + input.allowedExternalUrls, ); const didChange = !state.open; state.open = true; @@ -690,18 +826,23 @@ export class DesktopBrowserManager { nextInitialUrl && activeTab && (activeTab.kind !== requestedKind || - (requestedKind === "artifact" && activeTab.url !== nextInitialUrl)) + ((requestedKind === "artifact" || requestedKind === "local-html") && + activeTab.url !== nextInitialUrl)) ) { return this.newTab({ threadId: input.threadId, url: nextInitialUrl, kind: requestedKind, ...(input.displayUrl ? { displayUrl: input.displayUrl } : {}), + ...(input.allowedExternalUrls ? { allowedExternalUrls: input.allowedExternalUrls } : {}), activate: true, }); } if (nextInitialUrl && activeTab && activeTab.url !== nextInitialUrl) { activeTab.displayUrl = input.displayUrl?.trim() || null; + if (requestedKind === "local-html") { + activeTab.allowedExternalUrls = normalizedLocalHtmlExternalUrls(input.allowedExternalUrls); + } return this.navigate({ threadId: input.threadId, tabId: activeTab.id, @@ -731,6 +872,7 @@ export class DesktopBrowserManager { close(input: BrowserThreadInput): ThreadBrowserState { this.clearSuspendTimer(input.threadId); + this.occludedThreads.delete(input.threadId); if (this.activeThreadId === input.threadId) { this.detachAttachedRuntime(); @@ -742,7 +884,9 @@ export class DesktopBrowserManager { this.destroyThreadRuntimes(input.threadId); const state = this.getOrCreateState(input.threadId); - const closedArtifactTabs = state.tabs.filter((tab) => tab.kind === "artifact"); + const closedPreviewTabs = state.tabs.filter( + (tab) => tab.kind === "artifact" || tab.kind === "local-html", + ); state.open = false; state.activeTabId = null; state.tabs = []; @@ -750,13 +894,14 @@ export class DesktopBrowserManager { this.markThreadStateChanged(input.threadId); this.lastEmittedVersionByThreadId.delete(input.threadId); this.emitState(input.threadId); - for (const tab of closedArtifactTabs) { - this.clearArtifactSession(input.threadId, tab); + for (const tab of closedPreviewTabs) { + this.clearPreviewSession(input.threadId, tab); } return this.snapshotThreadState(input.threadId, state); } hide(input: BrowserThreadInput): void { + this.occludedThreads.delete(input.threadId); const state = this.states.get(input.threadId); if (this.activeThreadId === input.threadId) { this.detachAttachedRuntime(); @@ -782,9 +927,11 @@ export class DesktopBrowserManager { const activeTabId = this.getActiveTab(state)?.id ?? null; const activeRuntimeKey = activeTabId ? buildRuntimeKey(input.threadId, activeTabId) : null; const activeRuntime = activeRuntimeKey ? this.runtimes.get(activeRuntimeKey) : null; + const wasOccluded = this.occludedThreads.has(input.threadId); this.setActiveBounds(input.threadId, nextBounds); if (!state.open || nextBounds === null) { + this.occludedThreads.delete(input.threadId); if (this.activeThreadId === input.threadId) { this.detachAttachedRuntime(); this.activeThreadId = null; @@ -793,6 +940,20 @@ export class DesktopBrowserManager { return; } + if (input.surface === "native" && input.occluded === true) { + this.occludedThreads.add(input.threadId); + if ( + this.activeThreadId === input.threadId && + this.attachedRuntimeKey === activeRuntimeKey && + activeRuntime?.ownsWebContents + ) { + this.setRuntimeViewHidden(activeRuntime, true); + this.attachedBoundsSignature = null; + } + return; + } + this.occludedThreads.delete(input.threadId); + if ( input.surface === "native" && activeTabId && @@ -820,7 +981,8 @@ export class DesktopBrowserManager { if ( this.activeThreadId === input.threadId && this.attachedRuntimeKey === activeRuntimeKey && - this.attachedBoundsSignature === nextBoundsSignature + this.attachedBoundsSignature === nextBoundsSignature && + !wasOccluded ) { this.perfCounters.setPanelBoundsNoopSkips += 1; return; @@ -925,6 +1087,16 @@ export class DesktopBrowserManager { if (tab.kind === "artifact" && safeUrlOrigin(nextUrl) !== safeUrlOrigin(tab.url)) { throw new Error("Artifact previews cannot navigate outside their capability origin."); } + if ( + tab.kind === "local-html" && + localHtmlPreviewNavigationDisposition({ + url: nextUrl, + allowedOrigin: safeUrlOrigin(tab.url) ?? "", + isMainFrame: true, + }) !== "allow" + ) { + throw new Error("Local HTML previews cannot replace their capability tab with another site."); + } tab.url = nextUrl; tab.title = defaultTitleForUrl(nextUrl); tab.lastCommittedUrl = null; @@ -932,23 +1104,21 @@ export class DesktopBrowserManager { syncThreadLastError(state); this.markThreadStateChanged(input.threadId); - const runtime = this.runtimes.get(buildRuntimeKey(input.threadId, tab.id)); - if (runtime) { + const runtimeKey = buildRuntimeKey(input.threadId, tab.id); + const shouldLoadRuntime = + this.runtimes.has(runtimeKey) || this.activeThreadId === input.threadId; + if (shouldLoadRuntime) { + if (this.activeThreadId === input.threadId) { + this.clearSuspendTimer(input.threadId); + } + // Re-resolve through ensureLiveRuntime so a destroyed-but-still-tracked WebContents is + // replaced before navigation instead of being handed to the async loader. + const runtime = this.ensureLiveRuntime(input.threadId, tab.id); const bounds = this.getVisibleBoundsForThread(input.threadId); if (state.activeTabId === tab.id && bounds) { this.attachRuntime(runtime, bounds); } void this.loadTab(input.threadId, tab.id, { force: true, runtime }); - } else if (this.activeThreadId === input.threadId) { - // Load the target tab directly so we don't clobber its pending URL with a - // thread-wide runtime sync from the old live page state. - const nextRuntime = this.ensureLiveRuntime(input.threadId, tab.id); - this.clearSuspendTimer(input.threadId); - const bounds = this.getVisibleBoundsForThread(input.threadId); - if (state.activeTabId === tab.id && bounds) { - this.attachRuntime(nextRuntime, bounds); - } - void this.loadTab(input.threadId, tab.id, { force: true, runtime: nextRuntime }); } this.emitState(input.threadId); @@ -959,7 +1129,30 @@ export class DesktopBrowserManager { const state = this.ensureWorkspace(input.threadId); const tab = this.resolveTab(state, input.tabId); const runtime = this.runtimes.get(buildRuntimeKey(input.threadId, tab.id)); - if (runtime) { + if (runtime && tab.kind === "local-html") { + let retryPartition: string | null = null; + if (tab.allowedExternalUrls === undefined) { + const previewOrigin = safeUrlOrigin(tab.url); + if (previewOrigin) { + retryPartition = browserSessionPartition(tab.kind, input.threadId, tab.id); + if (this.previewSessionRetries.has(retryPartition)) { + return this.snapshotThreadState(input.threadId, state); + } + this.previewSessionRetries.add(retryPartition); + this.configureInteractiveLocalHtmlProxy(retryPartition, previewOrigin); + } + } + tab.isLoading = true; + tab.lastError = null; + syncThreadLastError(state); + this.markThreadStateChanged(input.threadId); + this.emitState(input.threadId); + void this.loadTab(input.threadId, tab.id, { force: true, runtime }).finally(() => { + if (retryPartition) { + this.previewSessionRetries.delete(retryPartition); + } + }); + } else if (runtime) { runtime.webContents.reload(); } else if (this.activeThreadId === input.threadId) { this.resumeThread(input.threadId); @@ -990,6 +1183,7 @@ export class DesktopBrowserManager { normalizeUrlInput(input.url), input.kind ?? "web", input.displayUrl, + input.allowedExternalUrls, ); this.configureTabSession(input.threadId, tab); state.tabs = [...state.tabs, tab]; @@ -1015,35 +1209,29 @@ export class DesktopBrowserManager { closeTab(input: BrowserTabInput): ThreadBrowserState { const state = this.ensureWorkspace(input.threadId); + const closedTabIndex = state.tabs.findIndex((tab) => tab.id === input.tabId); const closedTab = state.tabs.find((tab) => tab.id === input.tabId); const nextTabs = state.tabs.filter((tab) => tab.id !== input.tabId); if (nextTabs.length === state.tabs.length) { return this.snapshotThreadState(input.threadId, state); } + if (nextTabs.length === 0) { + // The tab close button is also the natural close affordance for a one-tab browser. + // Tear down the browser session so the renderer can close this dock pane and reveal + // the surface chooser instead of immediately manufacturing another blank tab. + return this.close({ threadId: input.threadId }); + } + this.closePopupWindowsForTab(input.threadId, input.tabId); this.destroyRuntime(input.threadId, input.tabId); state.tabs = nextTabs; if (closedTab) { - this.clearArtifactSession(input.threadId, closedTab); - } - - if (nextTabs.length === 0) { - // Closing the last tab keeps the browser open on a fresh blank tab (the same state - // as a brand-new browser session) so the user can type a new URL in the search box, - // instead of tearing the whole panel down. - const replacementTab = createBrowserTab(); - state.tabs = [replacementTab]; - state.activeTabId = replacementTab.id; - state.lastError = null; - - this.markThreadStateChanged(input.threadId); - this.emitState(input.threadId); - return this.snapshotThreadState(input.threadId, state); + this.clearPreviewSession(input.threadId, closedTab); } if (!state.activeTabId || state.activeTabId === input.tabId) { - state.activeTabId = nextTabs[Math.max(0, nextTabs.length - 1)]?.id ?? null; + state.activeTabId = nextTabs[Math.min(closedTabIndex, nextTabs.length - 1)]?.id ?? null; } const bounds = this.getVisibleBoundsForThread(input.threadId); @@ -1529,6 +1717,18 @@ export class DesktopBrowserManager { this.updatePopupWindowsForThread(runtime.threadId); return; } + if (this.occludedThreads.has(runtime.threadId)) { + if (this.attachedRuntimeKey && this.attachedRuntimeKey !== runtime.key) { + this.detachAttachedRuntime(); + } + this.bringRuntimeViewToFront(runtime); + runtime.view.setBounds(bounds); + this.setRuntimeViewHidden(runtime, true); + this.attachedRuntimeKey = runtime.key; + this.attachedBoundsSignature = null; + this.updatePopupWindowsForThread(runtime.threadId); + return; + } if (this.attachedRuntimeKey === runtime.key) { this.setRuntimeViewHidden(runtime, false); this.bringRuntimeViewToFront(runtime); @@ -1634,6 +1834,10 @@ export class DesktopBrowserManager { sandbox: true, }, }); + // Avoid a black native surface while a local page is still loading. Once + // loaded, a transparent document gets the same white default canvas users + // expect from a normal browser; authored page backgrounds remain intact. + view.setBackgroundColor("#ffffff"); const runtime: LiveTabRuntime = { key: buildRuntimeKey(threadId, tabId), threadId, @@ -1653,10 +1857,17 @@ export class DesktopBrowserManager { const sourceTab = this.getTab(state, tabId); const tabKind = sourceTab?.kind ?? "web"; const artifactOrigin = tabKind === "artifact" ? safeUrlOrigin(sourceTab?.url) : null; + const localHtmlOrigin = tabKind === "local-html" ? safeUrlOrigin(sourceTab?.url) : null; // Belt-and-suspenders alongside the session-level UA: also covers an adopted renderer // for any navigation after it attaches. webContents.setUserAgent(this.resolveSpoofedUserAgent()); + if (tabKind === "local-html") { + // Local HTML capabilities must not gain a UDP/STUN side channel around + // the HTTP(S) request policy. The awaited deny proxy is the primary + // boundary for interactive previews; this UDP policy is defense in depth. + webContents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp"); + } webContents.setWindowOpenHandler((details) => { const { url } = details; @@ -1673,6 +1884,18 @@ export class DesktopBrowserManager { return { action: "deny" }; } + const localHtmlDisposition = + tabKind === "local-html" && localHtmlOrigin + ? localHtmlPreviewNavigationDisposition({ + url, + allowedOrigin: localHtmlOrigin, + isMainFrame: true, + }) + : null; + if (localHtmlDisposition === "deny") { + return { action: "deny" }; + } + const windowKind = classifyBrowserWindowOpen({ url, frameName: details.frameName, @@ -1721,6 +1944,24 @@ export class DesktopBrowserManager { }); } + if (localHtmlOrigin) { + const separateLocalHtmlNavigation = ( + event: Electron.Event, + ) => { + const disposition = localHtmlPreviewNavigationDisposition({ + url: event.url, + allowedOrigin: localHtmlOrigin, + isMainFrame: event.isMainFrame, + }); + if (disposition === "allow") return; + event.preventDefault(); + }; + webContents.on("will-frame-navigate", separateLocalHtmlNavigation); + runtime.listenerDisposers.push(() => { + webContents.removeListener("will-frame-navigate", separateLocalHtmlNavigation); + }); + } + const didCreateWindow = (childWindow: BrowserWindow) => { this.registerOAuthPopupWindow(childWindow, { threadId, tabId }); }; @@ -1774,6 +2015,18 @@ export class DesktopBrowserManager { }); const didStartLoading = () => { + const state = this.states.get(threadId); + const tab = state ? this.getTab(state, tabId) : null; + if (state && tab) { + const didChange = !tab.isLoading || tab.lastError !== null; + tab.isLoading = true; + tab.lastError = null; + syncThreadLastError(state); + if (didChange) { + this.markThreadStateChanged(threadId); + this.emitState(threadId); + } + } this.queueRuntimeStateSync(threadId, tabId); }; webContents.on("did-start-loading", didStartLoading); @@ -1789,6 +2042,21 @@ export class DesktopBrowserManager { webContents.removeListener("did-stop-loading", didStopLoading); }); + const didFinishLoad = () => { + const state = this.states.get(threadId); + const tab = state ? this.getTab(state, tabId) : null; + if (tab?.kind === "local-html") { + void webContents + .executeJavaScript(LOCAL_HTML_DEFAULT_CANVAS_SCRIPT, true) + .catch(() => undefined); + } + this.queueRuntimeStateSync(threadId, tabId); + }; + webContents.on("did-finish-load", didFinishLoad); + runtime.listenerDisposers.push(() => { + webContents.removeListener("did-finish-load", didFinishLoad); + }); + const didNavigate = () => { this.queueRuntimeStateSync(threadId, tabId); }; @@ -1869,41 +2137,70 @@ export class DesktopBrowserManager { return; } - const runtime = options.runtime ?? this.ensureLiveRuntime(threadId, tabId); - const webContents = runtime.webContents; const nextUrl = normalizeUrlInput( options.force === true ? tab.url : (tab.lastCommittedUrl ?? tab.url), ); - const currentUrl = webContents.getURL(); - const shouldLoad = options.force === true || currentUrl !== nextUrl || currentUrl.length === 0; - - if (!shouldLoad) { - this.queueRuntimeStateSync(threadId, tabId); - return; - } - - tab.url = nextUrl; - tab.status = "live"; - tab.isLoading = true; - tab.lastError = null; - syncThreadLastError(state); - this.markThreadStateChanged(threadId); - this.emitState(threadId); try { - await webContents.loadURL(nextUrl); - this.queueRuntimeStateSync(threadId, tabId); - } catch (error) { - if (isAbortedNavigationError(error)) { + const runtime = options.runtime ?? this.ensureLiveRuntime(threadId, tabId); + if (tab.kind !== "web") { + const partition = browserSessionPartition(tab.kind, threadId, tab.id); + const previewSessionError = await (this.previewSessionReady.get(partition) ?? + Promise.resolve(null)); + if (previewSessionError) throw previewSessionError; + } + const outcome = await loadBrowserRuntimeUrl({ + webContents: runtime.webContents, + nextUrl, + force: options.force === true, + isCurrent: () => this.runtimes.get(runtime.key) === runtime, + onLoadStart: () => { + tab.url = nextUrl; + tab.status = "live"; + tab.isLoading = true; + tab.lastError = null; + syncThreadLastError(state); + this.markThreadStateChanged(threadId); + this.emitState(threadId); + }, + }); + + if (outcome === "loaded" || outcome === "unchanged" || outcome === "aborted") { this.queueRuntimeStateSync(threadId, tabId); return; } + if (outcome === "stale") { + // If this exact runtime died without its normal render-process-gone cleanup firing, + // remove it so the next visible activation creates a healthy replacement. + if (this.runtimes.get(runtime.key) === runtime && runtime.webContents.isDestroyed()) { + this.destroyRuntime(threadId, tabId); + const didChange = suspendTabState(tab) || syncThreadLastError(state); + if (didChange) { + this.markThreadStateChanged(threadId); + this.emitState(threadId); + } + } + return; + } + tab.isLoading = false; tab.lastError = "Couldn't open this page."; syncThreadLastError(state); this.markThreadStateChanged(threadId); this.emitState(threadId); + } catch { + // Runtime construction and bookkeeping errors must be surfaced as browser state, never as + // unhandled promises from the fire-and-forget navigation paths above. + const currentTab = this.getTab(state, tabId); + if (!currentTab) { + return; + } + currentTab.isLoading = false; + currentTab.lastError = "Couldn't open this page."; + syncThreadLastError(state); + this.markThreadStateChanged(threadId); + this.emitState(threadId); } } @@ -2016,6 +2313,27 @@ export class DesktopBrowserManager { return null; } + private markLocalHtmlHttpError(webContentsId: number, url: string, statusCode: number): void { + for (const [threadId, state] of this.states) { + const tab = state.tabs.find((candidate) => { + if (candidate.kind !== "local-html") return false; + return ( + this.runtimes.get(buildRuntimeKey(threadId, candidate.id))?.webContents.id === + webContentsId + ); + }); + if (!tab) continue; + tab.url = url || tab.url; + tab.title = defaultTitleForUrl(tab.url); + tab.isLoading = false; + tab.lastError = `This local HTML page could not be loaded (HTTP ${statusCode}).`; + syncThreadLastError(state); + this.markThreadStateChanged(threadId); + this.emitState(threadId); + return; + } + } + private getOrCreateState(threadId: ThreadId): ThreadBrowserState { const existing = this.states.get(threadId); if (existing) { @@ -2097,11 +2415,17 @@ export class DesktopBrowserManager { initialUrl?: string, kind: BrowserTabKind = "web", displayUrl?: string, + allowedExternalUrls?: readonly string[], ): ThreadBrowserState { this.ensureSessionConfigured(); const state = this.getOrCreateState(threadId); if (state.tabs.length === 0) { - const initialTab = createBrowserTab(normalizeUrlInput(initialUrl), kind, displayUrl); + const initialTab = createBrowserTab( + normalizeUrlInput(initialUrl), + kind, + displayUrl, + allowedExternalUrls, + ); this.configureTabSession(threadId, initialTab); state.tabs = [initialTab]; state.activeTabId = initialTab.id; @@ -2149,7 +2473,7 @@ export class DesktopBrowserManager { ): string | null { const state = this.states.get(threadId); const tab = state ? this.getTab(state, tabId) : null; - if (tab?.kind === "artifact") { + if (tab?.kind === "artifact" || tab?.kind === "local-html") { return null; } const liveUrl = @@ -2280,10 +2604,9 @@ function syncTabStateFromRuntime( tab.faviconUrl = value; }) || didChange; } - if (tab.lastError && !tab.isLoading) { - tab.lastError = null; - didChange = true; - } + // Keep a terminal load failure visible after Electron emits + // `did-stop-loading`. A later navigation's `did-start-loading` handler owns + // clearing the error, so retry and successful navigation still recover. didChange = syncThreadLastError(state) || didChange; return didChange; } diff --git a/apps/desktop/src/browserOverlayLifecycleRunner.test.ts b/apps/desktop/src/browserOverlayLifecycleRunner.test.ts new file mode 100644 index 000000000..42a4b60cc --- /dev/null +++ b/apps/desktop/src/browserOverlayLifecycleRunner.test.ts @@ -0,0 +1,59 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; + +const runnerPath = resolve( + dirname(fileURLToPath(import.meta.url)), + "../scripts/run-browser-overlay-lifecycle.mjs", +); + +async function waitForFile(path: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${path}`); + await delay(10); + } +} + +it.skipIf(process.platform === "win32")( + "cleans temporary state when interrupted during earliest setup", + async () => { + const probeRoot = mkdtempSync(join(tmpdir(), "scient-browser-overlay-signal-test-")); + const markerPath = join(probeRoot, "ready"); + const child = spawn(process.execPath, [runnerPath], { + env: { + ...process.env, + SCIENT_BROWSER_OVERLAY_TEST_SETUP_SIGNAL_PROBE: markerPath, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + try { + await waitForFile(markerPath, 5_000); + const harnessTempDir = readFileSync(markerPath, "utf8"); + const exit = once(child, "exit"); + expect(child.kill("SIGTERM")).toBe(true); + const [code, signal] = await exit; + + // Bun reports a handled SIGTERM as the terminating signal while Node + // reports the explicit 143 exit code used by the same handler. + expect(code === 143 || signal === "SIGTERM").toBe(true); + expect(stderr).toContain("interrupted by SIGTERM"); + expect(existsSync(harnessTempDir)).toBe(false); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + rmSync(probeRoot, { recursive: true, force: true }); + } + }, + 10_000, +); diff --git a/apps/desktop/src/browserRuntimeLoad.test.ts b/apps/desktop/src/browserRuntimeLoad.test.ts new file mode 100644 index 000000000..5e76a11af --- /dev/null +++ b/apps/desktop/src/browserRuntimeLoad.test.ts @@ -0,0 +1,100 @@ +// FILE: browserRuntimeLoad.test.ts +// Purpose: Guards browser navigation against stale and destroyed Electron runtimes. +// Layer: Desktop unit test +// Depends on: browserRuntimeLoad + +import { describe, expect, it, vi } from "vitest"; + +import { loadBrowserRuntimeUrl } from "./browserRuntimeLoad"; + +function runtime( + overrides: { + currentUrl?: string; + isDestroyed?: () => boolean; + getURL?: () => string; + loadURL?: (url: string) => Promise; + isCurrent?: () => boolean; + } = {}, +) { + const onLoadStart = vi.fn(); + const loadURL = vi.fn(overrides.loadURL ?? (async () => undefined)); + const webContents = { + isDestroyed: overrides.isDestroyed ?? (() => false), + getURL: overrides.getURL ?? (() => overrides.currentUrl ?? "about:blank"), + loadURL, + }; + + return { + input: { + webContents, + nextUrl: "https://example.com/", + force: false, + isCurrent: overrides.isCurrent ?? (() => true), + onLoadStart, + }, + loadURL, + onLoadStart, + }; +} + +describe("loadBrowserRuntimeUrl", () => { + it("skips an unchanged live runtime", async () => { + const testRuntime = runtime({ currentUrl: "https://example.com/" }); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("unchanged"); + expect(testRuntime.onLoadStart).not.toHaveBeenCalled(); + expect(testRuntime.loadURL).not.toHaveBeenCalled(); + }); + + it("loads a changed URL and reports the loading transition", async () => { + const testRuntime = runtime(); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("loaded"); + expect(testRuntime.onLoadStart).toHaveBeenCalledOnce(); + expect(testRuntime.loadURL).toHaveBeenCalledWith("https://example.com/"); + }); + + it("treats a destroyed getURL race as stale instead of rejecting", async () => { + let destroyed = false; + const testRuntime = runtime({ + isDestroyed: () => destroyed, + getURL: () => { + destroyed = true; + throw new Error("Object has been destroyed"); + }, + }); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("stale"); + expect(testRuntime.onLoadStart).not.toHaveBeenCalled(); + }); + + it("ignores a runtime invalidated while loadURL is pending", async () => { + let current = true; + const testRuntime = runtime({ + isCurrent: () => current, + loadURL: async () => { + current = false; + throw new Error("Object has been destroyed"); + }, + }); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("stale"); + expect(testRuntime.onLoadStart).toHaveBeenCalledOnce(); + }); + + it("distinguishes aborted navigation from a current-runtime load failure", async () => { + const aborted = runtime({ + loadURL: async () => { + throw new Error("net::ERR_ABORTED (-3)"); + }, + }); + const failed = runtime({ + loadURL: async () => { + throw new Error("net::ERR_CONNECTION_RESET"); + }, + }); + + await expect(loadBrowserRuntimeUrl(aborted.input)).resolves.toBe("aborted"); + await expect(loadBrowserRuntimeUrl(failed.input)).resolves.toBe("failed"); + }); +}); diff --git a/apps/desktop/src/browserRuntimeLoad.ts b/apps/desktop/src/browserRuntimeLoad.ts new file mode 100644 index 000000000..c84c4f2c2 --- /dev/null +++ b/apps/desktop/src/browserRuntimeLoad.ts @@ -0,0 +1,60 @@ +// FILE: browserRuntimeLoad.ts +// Purpose: Runs one browser runtime navigation without leaking destroyed-WebContents races. +// Layer: Desktop runtime helper +// Depends on: a minimal Electron WebContents-compatible navigation surface + +export interface BrowserRuntimeLoadTarget { + isDestroyed: () => boolean; + getURL: () => string; + loadURL: (url: string) => Promise; +} + +export type BrowserRuntimeLoadOutcome = "loaded" | "unchanged" | "aborted" | "stale" | "failed"; + +interface LoadBrowserRuntimeUrlInput { + webContents: BrowserRuntimeLoadTarget; + nextUrl: string; + force: boolean; + isCurrent: () => boolean; + onLoadStart: () => void; +} + +function isAbortedNavigationError(error: unknown): boolean { + return error instanceof Error && /ERR_ABORTED|\(-3\)/i.test(error.message); +} + +function runtimeIsStale(input: LoadBrowserRuntimeUrlInput): boolean { + try { + return !input.isCurrent() || input.webContents.isDestroyed(); + } catch { + return true; + } +} + +// Electron can destroy a WebContents between a queued navigation and the async load. Keep every +// WebContents access inside this guarded boundary so stale runtimes resolve benignly rather than +// becoming unhandled promise rejections in the main process. +export async function loadBrowserRuntimeUrl( + input: LoadBrowserRuntimeUrlInput, +): Promise { + if (runtimeIsStale(input)) { + return "stale"; + } + + try { + const currentUrl = input.webContents.getURL(); + const shouldLoad = input.force || currentUrl.length === 0 || currentUrl !== input.nextUrl; + if (!shouldLoad) { + return "unchanged"; + } + + input.onLoadStart(); + await input.webContents.loadURL(input.nextUrl); + return runtimeIsStale(input) ? "stale" : "loaded"; + } catch (error) { + if (runtimeIsStale(input)) { + return "stale"; + } + return isAbortedNavigationError(error) ? "aborted" : "failed"; + } +} diff --git a/apps/desktop/src/clipboardText.test.ts b/apps/desktop/src/clipboardText.test.ts new file mode 100644 index 000000000..d7c960946 --- /dev/null +++ b/apps/desktop/src/clipboardText.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { MAX_CLIPBOARD_TEXT_LENGTH, normalizeClipboardText } from "./clipboardText"; + +describe("normalizeClipboardText", () => { + it("preserves valid text exactly", () => { + expect(normalizeClipboardText(" feature/native-copy\n")).toBe(" feature/native-copy\n"); + }); + + it.each([null, undefined, 42, {}, ""])("rejects invalid clipboard input %#", (value) => { + expect(normalizeClipboardText(value)).toBeNull(); + }); + + it("accepts the size boundary and rejects oversized renderer input", () => { + expect(normalizeClipboardText("a".repeat(MAX_CLIPBOARD_TEXT_LENGTH))).not.toBeNull(); + expect(normalizeClipboardText("a".repeat(MAX_CLIPBOARD_TEXT_LENGTH + 1))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/clipboardText.ts b/apps/desktop/src/clipboardText.ts new file mode 100644 index 000000000..c23c45479 --- /dev/null +++ b/apps/desktop/src/clipboardText.ts @@ -0,0 +1,12 @@ +// FILE: clipboardText.ts +// Purpose: Validate bounded renderer text before it reaches Electron's native clipboard. +// Layer: Desktop main-process helper + +export const MAX_CLIPBOARD_TEXT_LENGTH = 1024 * 1024; + +export function normalizeClipboardText(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) { + return null; + } + return value.length <= MAX_CLIPBOARD_TEXT_LENGTH ? value : null; +} diff --git a/apps/desktop/src/localHtmlPreviewPolicy.test.ts b/apps/desktop/src/localHtmlPreviewPolicy.test.ts new file mode 100644 index 000000000..4a89fc61e --- /dev/null +++ b/apps/desktop/src/localHtmlPreviewPolicy.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { + isPrivateOrNonPublicIpAddress, + isLocalOrPrivateNetworkUrl, + localHtmlPreviewNavigationDisposition, + localHtmlPreviewRequestAllowed, +} from "./localHtmlPreviewPolicy"; + +const ORIGIN = "http://g-123.preview.localhost:5000"; + +describe("localHtmlPreviewPolicy", () => { + it("allows the exact preview origin and exact declared public assets", () => { + expect(localHtmlPreviewRequestAllowed({ url: `${ORIGIN}/app.js`, allowedOrigin: ORIGIN })).toBe( + true, + ); + expect( + localHtmlPreviewRequestAllowed({ + url: "https://cdn.example/app.js", + allowedOrigin: ORIGIN, + allowedExternalUrls: ["https://cdn.example/app.js"], + resourceType: "script", + }), + ).toBe(true); + expect( + localHtmlPreviewRequestAllowed({ + url: "data:text/plain,ok", + allowedOrigin: ORIGIN, + }), + ).toBe(true); + expect( + localHtmlPreviewRequestAllowed({ + url: "blob:http://g-123.preview.localhost:5000/id", + allowedOrigin: ORIGIN, + }), + ).toBe(true); + }); + + it("denies undeclared or data-bearing public egress", () => { + expect( + localHtmlPreviewRequestAllowed({ + url: "https://cdn.example/bit-one.png", + allowedOrigin: ORIGIN, + resourceType: "image", + }), + ).toBe(false); + expect( + localHtmlPreviewRequestAllowed({ + url: "https://cdn.example/app.js?secret=1", + allowedOrigin: ORIGIN, + allowedExternalUrls: ["https://cdn.example/app.js"], + resourceType: "image", + }), + ).toBe(false); + expect( + localHtmlPreviewRequestAllowed({ + url: "https://api.example/data", + allowedOrigin: ORIGIN, + allowedExternalUrls: ["https://api.example/data"], + resourceType: "xhr", + }), + ).toBe(false); + }); + + it("denies local services, private networks, privileged schemes, and malformed URLs", () => { + for (const url of [ + "http://localhost:3000/private", + "http://other.preview.localhost:5000/private", + "http://127.0.0.1:3000/private", + "http://10.0.0.2/private", + "http://172.16.0.2/private", + "http://192.168.1.2/private", + "http://169.254.169.254/latest/meta-data", + "http://[::1]/private", + "http://[::ffff:7f00:1]/private", + "http://localhost.localdomain/private", + "file:///Users/example/.ssh/id_rsa", + "scient://settings", + "not a url", + ]) { + expect(localHtmlPreviewRequestAllowed({ url, allowedOrigin: ORIGIN })).toBe(false); + } + }); + + it("keeps preview navigation local and denies cross-origin egress", () => { + expect( + localHtmlPreviewNavigationDisposition({ + url: `${ORIGIN}/linked.html`, + allowedOrigin: ORIGIN, + isMainFrame: true, + }), + ).toBe("allow"); + expect( + localHtmlPreviewNavigationDisposition({ + url: "https://example.com/", + allowedOrigin: ORIGIN, + isMainFrame: true, + }), + ).toBe("deny"); + expect( + localHtmlPreviewNavigationDisposition({ + url: "http://127.0.0.1:3000/", + allowedOrigin: ORIGIN, + isMainFrame: true, + }), + ).toBe("deny"); + expect( + localHtmlPreviewNavigationDisposition({ + url: "https://frames.example/", + allowedOrigin: ORIGIN, + isMainFrame: false, + }), + ).toBe("allow"); + }); + + it("recognizes representative private-network address forms", () => { + expect(isLocalOrPrivateNetworkUrl("http://127.1/")).toBe(true); + expect(isLocalOrPrivateNetworkUrl("http://[fd00::1]/")).toBe(true); + expect(isLocalOrPrivateNetworkUrl("https://example.com/")).toBe(false); + expect(isLocalOrPrivateNetworkUrl("https://fda.gov/")).toBe(false); + expect(isLocalOrPrivateNetworkUrl("https://fcc.gov/")).toBe(false); + expect(isPrivateOrNonPublicIpAddress("::ffff:7f00:1")).toBe(true); + expect(isPrivateOrNonPublicIpAddress("2001:4860:4860::8888")).toBe(false); + }); +}); diff --git a/apps/desktop/src/localHtmlPreviewPolicy.ts b/apps/desktop/src/localHtmlPreviewPolicy.ts new file mode 100644 index 000000000..54d8f8ca6 --- /dev/null +++ b/apps/desktop/src/localHtmlPreviewPolicy.ts @@ -0,0 +1,131 @@ +// FILE: localHtmlPreviewPolicy.ts +// Purpose: Pure request/navigation policy for executable local HTML preview tabs. + +import { BlockList, isIP } from "node:net"; + +const nonPublicIpv6 = new BlockList(); +nonPublicIpv6.addSubnet("::", 128, "ipv6"); +nonPublicIpv6.addSubnet("::1", 128, "ipv6"); +nonPublicIpv6.addSubnet("::ffff:0:0", 96, "ipv6"); +nonPublicIpv6.addSubnet("fc00::", 7, "ipv6"); +nonPublicIpv6.addSubnet("fe80::", 10, "ipv6"); +nonPublicIpv6.addSubnet("fec0::", 10, "ipv6"); +nonPublicIpv6.addSubnet("ff00::", 8, "ipv6"); +nonPublicIpv6.addSubnet("100::", 64, "ipv6"); +nonPublicIpv6.addSubnet("2001:2::", 48, "ipv6"); +nonPublicIpv6.addSubnet("2001:db8::", 32, "ipv6"); + +function normalizedHostname(url: URL): string { + return url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); +} + +function isPrivateIpv4(hostname: string): boolean { + const octets = hostname.split(".").map(Number); + if ( + octets.length !== 4 || + octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) + ) { + return false; + } + const [first = -1, second = -1] = octets; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 192 && second === 0) || + (first === 198 && (second === 18 || second === 19 || second === 51)) || + (first === 203 && second === 0 && octets[2] === 113) || + first >= 224 + ); +} + +export function isPrivateOrNonPublicIpAddress(value: string): boolean { + const address = + value + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, "") + .split("%", 1)[0] ?? ""; + const family = isIP(address); + if (family === 4) return isPrivateIpv4(address); + if (family === 6) return nonPublicIpv6.check(address, "ipv6"); + return false; +} + +export function isLocalOrPrivateNetworkUrl(value: string): boolean { + try { + const url = new URL(value); + const hostname = normalizedHostname(url); + return ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "localhost.localdomain" || + hostname.endsWith(".localhost.localdomain") || + isPrivateOrNonPublicIpAddress(hostname) + ); + } catch { + return true; + } +} + +export function localHtmlPreviewRequestAllowed(input: { + url: string; + allowedOrigin: string; + allowedExternalUrls?: readonly string[]; + method?: string; + resourceType?: string; +}): boolean { + try { + const requestUrl = new URL(input.url); + if (requestUrl.origin === input.allowedOrigin) return true; + if (requestUrl.protocol === "data:" || requestUrl.protocol === "blob:") return true; + if (requestUrl.protocol !== "http:" && requestUrl.protocol !== "https:") { + return false; + } + const method = input.method?.toUpperCase() ?? "GET"; + if (method !== "GET" && method !== "HEAD") return false; + if (input.resourceType === "mainFrame" || input.resourceType === "xhr") return false; + if (input.resourceType === "ping" || input.resourceType === "webSocket") return false; + const normalizedRequestUrl = new URL(requestUrl.toString()); + normalizedRequestUrl.hash = ""; + const explicitlyDeclared = (input.allowedExternalUrls ?? []).some((allowedUrl) => { + try { + const normalizedAllowedUrl = new URL(allowedUrl); + normalizedAllowedUrl.hash = ""; + return normalizedAllowedUrl.toString() === normalizedRequestUrl.toString(); + } catch { + return false; + } + }); + return explicitlyDeclared && !isLocalOrPrivateNetworkUrl(requestUrl.toString()); + } catch { + return false; + } +} + +export function localHtmlPreviewResolvedAddressesAllowed(addresses: readonly string[]): boolean { + return ( + addresses.length > 0 && addresses.every((address) => !isPrivateOrNonPublicIpAddress(address)) + ); +} + +export type LocalHtmlPreviewNavigationDisposition = "allow" | "deny"; + +export function localHtmlPreviewNavigationDisposition(input: { + url: string; + allowedOrigin: string; + isMainFrame: boolean; +}): LocalHtmlPreviewNavigationDisposition { + if (!input.isMainFrame) return "allow"; + try { + const target = new URL(input.url); + if (target.origin === input.allowedOrigin) return "allow"; + return "deny"; + } catch { + return "deny"; + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 519aa5039..ff680d470 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -79,6 +79,7 @@ import { import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { normalizeClipboardText } from "./clipboardText"; import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS, openDesktopLogsDirectory } from "./desktopDiagnostics"; import { LSREGISTER_PATH, @@ -210,6 +211,7 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const SHOW_IN_FOLDER_CHANNEL = "desktop:show-in-folder"; const CLIPBOARD_WRITE_IMAGE_CHANNEL = "desktop:clipboard-write-image"; +const CLIPBOARD_WRITE_TEXT_CHANNEL = "desktop:clipboard-write-text"; const MAX_CLIPBOARD_IMAGE_DATA_URL_LENGTH = 16 * 1024 * 1024; const WINDOW_MINIMIZE_CHANNEL = "desktop:window-minimize"; const WINDOW_TOGGLE_MAXIMIZE_CHANNEL = "desktop:window-toggle-maximize"; @@ -3226,6 +3228,16 @@ function registerIpcHandlers(): void { return true; }); + ipcMain.removeHandler(CLIPBOARD_WRITE_TEXT_CHANNEL); + ipcMain.handle(CLIPBOARD_WRITE_TEXT_CHANNEL, (_event, rawText: unknown) => { + const text = normalizeClipboardText(rawText); + if (text === null) { + return false; + } + clipboard.writeText(text); + return true; + }); + ipcMain.removeHandler(SHOW_IN_FOLDER_CHANNEL); ipcMain.handle(SHOW_IN_FOLDER_CHANNEL, async (_event, rawPath: unknown) => { if (typeof rawPath !== "string" || rawPath.trim().length === 0) { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7a4980282..88aa3f4b3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -20,6 +20,7 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const SHOW_IN_FOLDER_CHANNEL = "desktop:show-in-folder"; const CLIPBOARD_WRITE_IMAGE_CHANNEL = "desktop:clipboard-write-image"; +const CLIPBOARD_WRITE_TEXT_CHANNEL = "desktop:clipboard-write-text"; const WINDOW_MINIMIZE_CHANNEL = "desktop:window-minimize"; const WINDOW_TOGGLE_MAXIMIZE_CHANNEL = "desktop:window-toggle-maximize"; const WINDOW_CLOSE_CHANNEL = "desktop:window-close"; @@ -80,6 +81,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { openLogsDirectory: () => ipcRenderer.invoke(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory), }, clipboard: { + writeText: (text: string) => ipcRenderer.invoke(CLIPBOARD_WRITE_TEXT_CHANNEL, text), writeImagePngDataUrl: (dataUrl: string) => ipcRenderer.invoke(CLIPBOARD_WRITE_IMAGE_CHANNEL, dataUrl), }, diff --git a/apps/server/src/htmlPreview/HtmlArtifactPreview.platform.test.ts b/apps/server/src/htmlPreview/HtmlArtifactPreview.platform.test.ts index ed3c6ae08..168e67c4d 100644 --- a/apps/server/src/htmlPreview/HtmlArtifactPreview.platform.test.ts +++ b/apps/server/src/htmlPreview/HtmlArtifactPreview.platform.test.ts @@ -19,6 +19,7 @@ it( try { await fs.writeFile(path.join(workspace, "index.html"), "

Resolver smoke

"); await fs.writeFile(path.join(workspace, "second.html"), "

Second origin

"); + await fs.writeFile(path.join(workspace, "private.txt"), "must not cross the capability"); await Effect.runPromise( Effect.scoped( Effect.gen(function* () { @@ -40,6 +41,12 @@ it( ); expect(response.status).toBe(200); expect(yield* Effect.promise(() => response.text())).toContain("Resolver smoke"); + const denied = yield* Effect.promise(() => + fetch(new URL("/private.txt", prepared.previewUrl!), { + signal: AbortSignal.timeout(5_000), + }), + ); + expect(denied.status).toBe(404); }).pipe(Effect.provide(HtmlArtifactPreviewLive)), ), ); diff --git a/apps/server/src/htmlPreview/Inspector.test.ts b/apps/server/src/htmlPreview/Inspector.test.ts index ae8c1ac18..053a505d1 100644 --- a/apps/server/src/htmlPreview/Inspector.test.ts +++ b/apps/server/src/htmlPreview/Inspector.test.ts @@ -61,6 +61,391 @@ describe("inspectHtmlArtifact", () => { expect(inspected.result.warnings).toEqual([]); }); + it("keeps JSON-LD data blocks static and allows their declared presentation assets", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "index.html"), + [ + '', + '', + '', + ].join(""), + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result).toEqual({ mode: "static-document", warnings: [] }); + expect(inspected.allowedExternalUrls).toEqual(["https://cdn.example/photo.png"]); + }); + + it("collects literal fetch/worker assets, inline CSS, and linked HTML transitively", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "data.json"), '{"ok":true}'); + await fs.writeFile(path.join(workspace, "worker.js"), "postMessage('ready');"); + await fs.writeFile(path.join(workspace, "dynamic.png"), "png"); + await fs.writeFile(path.join(workspace, "dynamic.html"), "

Dynamic

"); + await fs.writeFile(path.join(workspace, "background.svg"), ""); + await fs.writeFile( + path.join(workspace, "app.js"), + 'fetch("data.json"); new Worker("worker.js", { type: "module" });', + ); + await fs.writeFile(path.join(workspace, "linked.css"), "body { color: navy; }"); + await fs.writeFile( + path.join(workspace, "linked.html"), + '

Linked

', + ); + await fs.writeFile( + path.join(workspace, "index.html"), + [ + '', + 'Linked', + '', + '', + ].join(""), + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(new Set(inspected.allowedResourcePaths)).toEqual( + new Set( + await Promise.all( + [ + "app.js", + "data.json", + "worker.js", + "background.svg", + "linked.html", + "linked.css", + "dynamic.png", + "dynamic.html", + ].map((file) => fs.realpath(path.join(workspace, file))), + ), + ), + ); + }); + + it("elevates a static entry when a granted child document is executable", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "runner.html"), + '', + ); + await fs.writeFile( + path.join(workspace, "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.allowedResourcePaths).toContain( + await fs.realpath(path.join(workspace, "runner.html")), + ); + expect(new Set(inspected.allowedExternalUrls)).toEqual( + new Set(["https://cdn.example/bit-zero.png", "https://cdn.example/bit-one.png"]), + ); + expect(inspected.result.warnings).toContainEqual({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + }); + + it("treats an active SVG subdocument as executable content", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "active.svg"), + '', + ); + await fs.writeFile( + path.join(workspace, "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.allowedResourcePaths).toContain( + await fs.realpath(path.join(workspace, "active.svg")), + ); + }); + + it("discovers executable SVG script href resources and blocks interactive networking", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "app.js"), + "document.documentElement.dataset.ready = 'yes';", + ); + await fs.writeFile( + path.join(workspace, "active.svg"), + [ + '', + '', + '', + "", + ].join(""), + ); + await fs.writeFile( + path.join(workspace, "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(new Set(inspected.allowedResourcePaths)).toEqual( + new Set( + await Promise.all( + ["active.svg", "app.js"].map((file) => fs.realpath(path.join(workspace, file))), + ), + ), + ); + expect(inspected.result.warnings).toContainEqual({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + }); + + it("fails closed when inline SVG animation can synthesize an executable link", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "index.html"), + [ + '', + 'Open', + '', + "", + '', + ].join(""), + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.result.warnings).toContainEqual({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + }); + + it("fails closed when a linked SVG set element can synthesize an executable link", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "active.svg"), + [ + '', + 'Open', + '', + '', + "", + ].join(""), + ); + await fs.writeFile( + path.join(workspace, "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.allowedResourcePaths).toContain( + await fs.realpath(path.join(workspace, "active.svg")), + ); + expect(inspected.result.warnings).toContainEqual({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + }); + + it("inspects dependency prefixes for large linked JavaScript and CSS", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "chunk.js"), "export const ready = true;"); + await fs.writeFile(path.join(workspace, "nested.css"), "body { color: purple; }"); + await fs.writeFile(path.join(workspace, "hero.png"), "png"); + await fs.writeFile( + path.join(workspace, "app.js"), + 'import "./chunk.js";'.padEnd(1_000_001, " "), + ); + await fs.writeFile( + path.join(workspace, "theme.css"), + '@import "nested.css"; body { background: url("hero.png") }'.padEnd(1_000_001, " "), + ); + await fs.writeFile( + path.join(workspace, "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(new Set(inspected.allowedResourcePaths)).toEqual( + new Set( + await Promise.all( + ["theme.css", "nested.css", "hero.png", "app.js", "chunk.js"].map((file) => + fs.realpath(path.join(workspace, file)), + ), + ), + ), + ); + expect(inspected.result.warnings).toContainEqual({ + code: "inspection-truncated", + message: + "Only the beginning of a large linked stylesheet or script was inspected; references after the inspected prefix may be unavailable.", + }); + }); + + it("fails closed when executable entry content may be beyond the inspection prefix", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "local.png"), "png"); + const inspectedPrefix = ''; + await fs.writeFile( + path.join(workspace, "large.html"), + `${inspectedPrefix.padEnd(1_000_001, " ")}`, + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "large.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.allowedResourcePaths).toContain( + await fs.realpath(path.join(workspace, "local.png")), + ); + expect(inspected.result.warnings).toContainEqual({ + code: "inspection-truncated", + message: + "Only the beginning of this large HTML file was inspected; the full file will still open.", + }); + }); + + it("fails closed but preserves prefix assets for a truncated active child", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "child.css"), "body { color: teal; }"); + const inspectedPrefix = + ''; + await fs.writeFile( + path.join(workspace, "child.html"), + `${inspectedPrefix.padEnd(1_000_001, " ")}`, + ); + await fs.writeFile(path.join(workspace, "index.html"), ''); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(new Set(inspected.allowedResourcePaths)).toEqual( + new Set( + await Promise.all( + ["child.html", "child.css"].map((file) => fs.realpath(path.join(workspace, file))), + ), + ), + ); + expect(inspected.result.warnings).toContainEqual({ + code: "inspection-truncated", + message: + "Only the beginning of a linked active document was inspected; it will open in interactive mode and its discovered assets remain available.", + }); + }); + + it("recognizes entity-decoded whitespace inside an entry javascript URL", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "index.html"), + 'Run', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.result.warnings).toContainEqual({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + }); + + it("recognizes entity-decoded whitespace inside a child javascript URL", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "child.html"), + '
', + ); + await fs.writeFile(path.join(workspace, "index.html"), ''); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + }); + + it("recursively classifies executable srcdoc markup", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile( + path.join(workspace, "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + }); + + it("honors document base URLs, root-relative assets, inline modules, and quoted CSS spaces", async () => { + const workspace = await makeWorkspace(); + await fs.mkdir(path.join(workspace, "assets")); + await fs.writeFile(path.join(workspace, "logo.svg"), ""); + await fs.writeFile(path.join(workspace, "assets", "hero image.png"), "png"); + await fs.writeFile( + path.join(workspace, "assets", "theme.css"), + 'body { background-image: url("hero image.png") }', + ); + await fs.writeFile(path.join(workspace, "assets", "chunk.js"), "export const ready = true;"); + await fs.writeFile( + path.join(workspace, "index.html"), + [ + '', + '', + '', + '', + '', + ].join(""), + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(new Set(inspected.allowedResourcePaths)).toEqual( + new Set( + await Promise.all( + ["assets/theme.css", "assets/hero image.png", "assets/chunk.js", "logo.svg"].map((file) => + fs.realpath(path.join(workspace, file)), + ), + ), + ), + ); + expect(inspected.allowedExternalUrls).toEqual(["https://cdn.example/declared.png"]); + expect(inspected.result.warnings).toContainEqual({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + }); + + it("does not allow a nested workspace document to nominate a parent secret", async () => { + const workspace = await makeWorkspace(); + await fs.mkdir(path.join(workspace, "site")); + await fs.writeFile(path.join(workspace, "credentials.json"), '{"secret":true}'); + await fs.writeFile( + path.join(workspace, "site", "index.html"), + '', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "site/index.html" }); + + expect(inspected.allowedResourcePaths).toEqual([]); + expect(inspected.result.warnings).toContainEqual({ + code: "local-resource-denied", + message: "Local preview resource is outside the opened file's authority: ../credentials.json", + }); + }); + it("routes Vite TSX source entrypoints to the nearest package run target", async () => { const workspace = await makeWorkspace(); const appDirectory = path.join(workspace, "packages", "app"); @@ -91,7 +476,21 @@ describe("inspectHtmlArtifact", () => { }); }); - it("reports blocked external resources without granting them network access", async () => { + it("still opens source-module HTML when no development command can be found", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "main.tsx"), "export {};"); + await fs.writeFile( + path.join(workspace, "index.html"), + '
', + ); + + const inspected = await inspectHtmlArtifact({ cwd: workspace, path: "index.html" }); + + expect(inspected.result.mode).toBe("interactive-bundle"); + expect(inspected.result.runTarget).toBeUndefined(); + }); + + it("reports external resources that safe interactive mode will block", async () => { const workspace = await makeWorkspace(); await fs.writeFile( path.join(workspace, "index.html"), @@ -104,12 +503,14 @@ describe("inspectHtmlArtifact", () => { expect(inspected.result.warnings).toEqual([ { code: "external-resource-blocked", - message: "External script blocked in preview: https://cdn.example/app.js", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", }, ]); + expect(inspected.allowedExternalUrls).toEqual(["https://cdn.example/app.js"]); }); - it("fails closed for files outside the workspace", async () => { + it("inspects absolute files outside the workspace", async () => { const workspace = await makeWorkspace(); const outside = await makeWorkspace(); const outsideFile = path.join(outside, "outside.html"); @@ -117,7 +518,33 @@ describe("inspectHtmlArtifact", () => { const inspected = await inspectHtmlArtifact({ cwd: workspace, path: outsideFile }); - expect(inspected.result.mode).toBe("unsupported"); - expect(inspected.absolutePath).toBeNull(); + expect(inspected.result.mode).toBe("static-document"); + expect(inspected.absolutePath).toBe(await fs.realpath(outsideFile)); + }); + + it("does not let an absolute file enlarge its authority with parent references", async () => { + const workspace = await makeWorkspace(); + const outside = await makeWorkspace(); + const documentDirectory = path.join(outside, "document"); + const privateFile = path.join(outside, "private.txt"); + await fs.mkdir(documentDirectory); + await fs.writeFile(privateFile, "not preview content"); + await fs.writeFile( + path.join(documentDirectory, "outside.html"), + '

Document

', + ); + + const inspected = await inspectHtmlArtifact({ + cwd: workspace, + path: path.join(documentDirectory, "outside.html"), + }); + + expect(inspected.allowedResourcePaths).not.toContain(await fs.realpath(privateFile)); + expect(inspected.result.warnings).toEqual([ + { + code: "local-resource-denied", + message: "Local preview resource is outside the opened file's authority: ../private.txt", + }, + ]); }); }); diff --git a/apps/server/src/htmlPreview/Inspector.ts b/apps/server/src/htmlPreview/Inspector.ts index 1993026e5..cfde8651f 100644 --- a/apps/server/src/htmlPreview/Inspector.ts +++ b/apps/server/src/htmlPreview/Inspector.ts @@ -24,28 +24,36 @@ const RESOURCE_GRAPH_MAX_FILES = 250; const RESOURCE_GRAPH_PARSE_MAX_BYTES = 1_000_000; const DEV_SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".jsx"]); const BROWSER_SCRIPT_EXTENSIONS = new Set([".js", ".mjs"]); -const ALLOWED_LOCAL_RESOURCE_EXTENSIONS = new Set([ - ".avif", - ".bmp", - ".css", - ".gif", - ".heic", - ".heif", - ".ico", - ".jpeg", - ".jpg", - ".js", - ".mjs", - ".otf", - ".png", - ".svg", - ".tiff", - ".ttf", - ".webp", - ".woff", - ".woff2", +const ACTIVE_DOCUMENT_EXTENSIONS = new Set([".html", ".htm", ".xhtml", ".svg"]); +const SVG_HREF_RESOURCE_ELEMENTS = new Set(["feimage", "image", "mpath", "use"]); +const SVG_ARBITRARY_ATTRIBUTE_MUTATION_ELEMENTS = new Set(["animate", "set"]); +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const JAVASCRIPT_MIME_TYPES = new Set([ + "application/ecmascript", + "application/javascript", + "application/x-ecmascript", + "application/x-javascript", + "text/ecmascript", + "text/javascript", + "text/javascript1.0", + "text/javascript1.1", + "text/javascript1.2", + "text/javascript1.3", + "text/javascript1.4", + "text/javascript1.5", + "text/jscript", + "text/livescript", + "text/x-ecmascript", + "text/x-javascript", +]); +const EXECUTABLE_URL_ATTRIBUTES = new Set([ + "action", + "data", + "formaction", + "href", + "src", + "xlink:href", ]); - type DocumentNode = DefaultTreeAdapterMap["document"]; type Node = DefaultTreeAdapterMap["node"]; type Element = DefaultTreeAdapterMap["element"]; @@ -54,7 +62,9 @@ export interface InspectedHtmlArtifact { readonly result: ProjectInspectHtmlArtifactResult; readonly absolutePath: string | null; readonly baseDirectory: string | null; + readonly siteRoot: string | null; readonly allowedResourcePaths: readonly string[]; + readonly allowedExternalUrls: readonly string[]; } function isPathInside(candidate: string, root: string): boolean { @@ -121,13 +131,6 @@ function resolveLocalResourcePath(value: string, baseDirectory: string): string return path.resolve(baseDirectory, decoded.replace(/^\/+/, "")); } -async function resourceExists(value: string, baseDirectory: string): Promise { - const resolved = resolveLocalResourcePath(value, baseDirectory); - if (!resolved) return true; - const stat = await fs.stat(resolved).catch(() => null); - return Boolean(stat?.isFile()); -} - async function nearestRunTarget( entryPath: string, workspaceRoot: string, @@ -172,46 +175,301 @@ function unsupported(reason: string): InspectedHtmlArtifact { result: { mode: "unsupported", reason, warnings: [] }, absolutePath: null, baseDirectory: null, + siteRoot: null, allowedResourcePaths: [], + allowedExternalUrls: [], }; } -const CSS_RESOURCE_PATTERN = /(?:url\(\s*|@import\s+(?:url\(\s*)?)["']?([^"')\s]+)["']?\s*\)?/gi; +const CSS_URL_PATTERN = /url\(\s*(?:(["'])(.*?)\1|([^"')]*?))\s*\)/gi; +const CSS_IMPORT_STRING_PATTERN = /@import\s+(["'])(.*?)\1/gi; +const JAVASCRIPT_RESOURCE_PATTERN = + /(?:\bfetch|new\s+(?:Shared)?Worker|navigator\.serviceWorker\.register|importScripts|new\s+URL)\s*\(\s*(["'])([^"']+)\1/g; +const JAVASCRIPT_NAVIGATION_PATTERN = + /(?:(?:window\.)?open|location\.(?:assign|replace))\s*\(\s*(["'])([^"']+)\1|(?:window\.)?location(?:\.href)?\s*=\s*(["'])([^"']+)\3/g; +const JAVASCRIPT_ATTRIBUTE_RESOURCE_PATTERN = + /(?:\.\s*(?:src|href|action|poster)\s*=|\.setAttribute\s*\(\s*["'](?:src|href|action|poster)["']\s*,)\s*(["'])([^"']+)\1/g; + +function cssResourceReferences(source: string): readonly string[] { + return [ + ...[...source.matchAll(CSS_URL_PATTERN)].flatMap((match) => { + const resource = (match[2] ?? match[3])?.trim(); + return resource ? [resource] : []; + }), + ...[...source.matchAll(CSS_IMPORT_STRING_PATTERN)].flatMap((match) => { + const resource = match[2]?.trim(); + return resource ? [resource] : []; + }), + ]; +} + +function javascriptResourceReferences(source: string): readonly string[] { + return [ + ...[...source.matchAll(JAVASCRIPT_RESOURCE_PATTERN)].flatMap((match) => + match[2] ? [match[2]] : [], + ), + ...[...source.matchAll(JAVASCRIPT_NAVIGATION_PATTERN)].flatMap((match) => { + const resource = match[2] ?? match[4]; + return resource ? [resource] : []; + }), + ...[...source.matchAll(JAVASCRIPT_ATTRIBUTE_RESOURCE_PATTERN)].flatMap((match) => + match[2] ? [match[2]] : [], + ), + ]; +} + +function normalizedExternalResourceUrl(value: string, baseHref?: string | null): string | null { + try { + const base = baseHref + ? new URL(baseHref, "http://preview.invalid/") + : new URL("http://preview.invalid/"); + const resolved = new URL(value, base); + if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null; + if (resolved.hostname === "preview.invalid") return null; + resolved.hash = ""; + return resolved.toString(); + } catch { + return null; + } +} + +function scriptElementIsExecutable(element: Element): boolean { + const rawType = attributeOf(element, "type")?.trim().toLowerCase(); + if (!rawType || rawType === "module") return true; + const typeEssence = rawType.split(";", 1)[0]?.trim() ?? ""; + return JAVASCRIPT_MIME_TYPES.has(typeEssence); +} + +function scriptSourcePath(element: Element): string | null { + const sourcePath = attributeOf(element, "src"); + if (sourcePath !== null || element.namespaceURI !== SVG_NAMESPACE) return sourcePath; + return attributeOf(element, "href") ?? attributeOf(element, "xlink:href"); +} + +function elementCanMutateSvgAtRuntime(element: Element): boolean { + return ( + element.namespaceURI === SVG_NAMESPACE && + SVG_ARBITRARY_ATTRIBUTE_MUTATION_ELEMENTS.has(element.tagName.toLowerCase()) + ); +} + +function resourceReferencesForElement(element: Element): readonly string[] { + const tagName = element.tagName.toLowerCase(); + const resources: string[] = []; + const add = (attributeName: string) => { + const value = attributeOf(element, attributeName); + if (value) resources.push(value); + }; + + if (tagName === "script") { + if (scriptElementIsExecutable(element)) { + const sourcePath = scriptSourcePath(element); + if (sourcePath) resources.push(sourcePath); + } + } else if ( + tagName === "link" || + tagName === "a" || + tagName === "area" || + SVG_HREF_RESOURCE_ELEMENTS.has(tagName) + ) { + add("href"); + add("xlink:href"); + } else if (tagName === "form") add("action"); + else if (tagName === "object") add("data"); + else add("src"); + if (tagName === "video") add("poster"); + + const inlineStyle = attributeOf(element, "style"); + if (inlineStyle) resources.push(...cssResourceReferences(inlineStyle)); + if (tagName === "style") resources.push(...cssResourceReferences(textContentOf(element))); + + const srcset = attributeOf(element, "srcset"); + if (srcset) { + for (const candidate of srcset.split(",")) { + const resource = candidate.trim().split(/\s+/, 1)[0]; + if (resource) resources.push(resource); + } + } + return resources; +} + +function htmlResourceReferences(source: string): { + readonly baseHref: string | null; + readonly resources: readonly string[]; +} { + const document = parse(source) as DocumentNode; + const resources: string[] = []; + let baseHref: string | null = null; + visit(document, (element) => { + if (element.tagName.toLowerCase() === "base" && baseHref === null) { + baseHref = attributeOf(element, "href"); + return; + } + resources.push(...resourceReferencesForElement(element)); + }); + return { baseHref, resources }; +} + +function isExecutableUrlAttribute(name: string, value: string): boolean { + if (!EXECUTABLE_URL_ATTRIBUTES.has(name.toLowerCase())) return false; + try { + return new URL(value, "http://preview.invalid/").protocol === "javascript:"; + } catch { + return true; + } +} + +function markupHasExecutableContent(source: string, srcdocDepth = 0): boolean { + const document = parse(source) as DocumentNode; + let executable = false; + visit(document, (element) => { + if (executable) return; + const tagName = element.tagName.toLowerCase(); + if ( + tagName === "script" && + scriptElementIsExecutable(element) && + (scriptSourcePath(element) !== null || textContentOf(element).trim().length > 0) + ) { + executable = true; + return; + } + if (elementCanMutateSvgAtRuntime(element)) { + executable = true; + return; + } + if ( + element.attrs.some( + (attribute) => + (attribute.name.toLowerCase().startsWith("on") && attribute.value.trim().length > 0) || + isExecutableUrlAttribute(attribute.name, attribute.value), + ) + ) { + executable = true; + return; + } + const srcdoc = attributeOf(element, "srcdoc"); + if (srcdoc && (srcdocDepth >= 8 || markupHasExecutableContent(srcdoc, srcdocDepth + 1))) { + executable = true; + } + }); + return executable; +} + +function resolveHtmlDocumentResourcePath(input: { + value: string; + documentPath: string; + siteRoot: string; + baseHref: string | null; +}): string | null { + let referenceDirectory = path.dirname(input.documentPath); + if (input.baseHref) { + if (isExternalResource(input.baseHref)) return null; + const basePath = resolveLocalResourcePath( + input.baseHref, + input.baseHref.trim().startsWith("/") ? input.siteRoot : referenceDirectory, + ); + if (!basePath) return null; + const baseWithoutQuery = input.baseHref.trim().split(/[?#]/, 1)[0] ?? ""; + referenceDirectory = baseWithoutQuery.endsWith("/") ? basePath : path.dirname(basePath); + } + return resolveLocalResourcePath( + input.value, + input.value.trim().startsWith("/") ? input.siteRoot : referenceDirectory, + ); +} async function collectAllowedResourcePaths( resources: readonly string[], - baseDirectory: string, -): Promise { + entryPath: string, + entryBaseHref: string | null, + resourceBoundary: string, +): Promise<{ + readonly paths: readonly string[]; + readonly externalUrls: readonly string[]; + readonly hasExecutableDocument: boolean; + readonly hasTruncatedActiveDocument: boolean; + readonly hasTruncatedDependency: boolean; +}> { const pending = resources - .map((resource) => resolveLocalResourcePath(resource, baseDirectory)) + .map((resource) => + resolveHtmlDocumentResourcePath({ + value: resource, + documentPath: entryPath, + siteRoot: resourceBoundary, + baseHref: entryBaseHref, + }), + ) .filter((resource): resource is string => resource !== null); const allowed = new Set(); + const externalUrls = new Set(); + let hasExecutableDocument = false; + let hasTruncatedActiveDocument = false; + let hasTruncatedDependency = false; + const addExternalUrl = (url: string) => { + if (externalUrls.size < RESOURCE_GRAPH_MAX_FILES) externalUrls.add(url); + }; while (pending.length > 0 && allowed.size < RESOURCE_GRAPH_MAX_FILES) { const candidate = pending.shift(); if (!candidate) continue; const canonical = await fs.realpath(candidate).catch(() => null); - if (!canonical || !isPathInside(canonical, baseDirectory) || allowed.has(canonical)) continue; + if (!canonical || !isPathInside(canonical, resourceBoundary) || allowed.has(canonical)) + continue; const stat = await fs.stat(canonical).catch(() => null); if (!stat?.isFile()) continue; allowed.add(canonical); const extension = path.extname(canonical).toLowerCase(); - if ( - stat.size > RESOURCE_GRAPH_PARSE_MAX_BYTES || - (extension !== ".css" && extension !== ".js" && extension !== ".mjs") - ) { + const isActiveDocument = ACTIVE_DOCUMENT_EXTENSIONS.has(extension); + const isInspectableDependency = + extension === ".css" || extension === ".js" || extension === ".mjs"; + if (!isActiveDocument && !isInspectableDependency) { + continue; + } + if (!isActiveDocument && stat.size > RESOURCE_GRAPH_PARSE_MAX_BYTES) { + hasTruncatedDependency = true; + } + const contents = + stat.size > RESOURCE_GRAPH_PARSE_MAX_BYTES + ? await readInspectionPrefix(canonical) + : await fs.readFile(canonical, "utf8"); + if (isActiveDocument) { + if (stat.size > RESOURCE_GRAPH_PARSE_MAX_BYTES) { + // The complete served document was not classified, so it must never + // inherit static-mode network access even when its inspected prefix is inert. + hasExecutableDocument = true; + hasTruncatedActiveDocument = true; + } + hasExecutableDocument ||= markupHasExecutableContent(contents); + const linkedDocument = htmlResourceReferences(contents); + for (const dependency of linkedDocument.resources) { + const externalUrl = normalizedExternalResourceUrl(dependency, linkedDocument.baseHref); + if (externalUrl) { + addExternalUrl(externalUrl); + continue; + } + const resolved = resolveHtmlDocumentResourcePath({ + value: dependency, + documentPath: canonical, + siteRoot: resourceBoundary, + baseHref: linkedDocument.baseHref, + }); + if (resolved) pending.push(resolved); + } continue; } - const contents = await fs.readFile(canonical, "utf8"); const dependencyDirectory = path.dirname(canonical); if (extension === ".css") { - for (const match of contents.matchAll(CSS_RESOURCE_PATTERN)) { - const dependency = match[1]; + for (const dependency of cssResourceReferences(contents)) { + const externalUrl = normalizedExternalResourceUrl(dependency); + if (externalUrl) { + addExternalUrl(externalUrl); + continue; + } const resolved = dependency ? resolveLocalResourcePath( dependency, - dependency.startsWith("/") ? baseDirectory : dependencyDirectory, + dependency.startsWith("/") ? resourceBoundary : dependencyDirectory, ) : null; if (resolved) pending.push(resolved); @@ -219,24 +477,77 @@ async function collectAllowedResourcePaths( continue; } - await initializeModuleLexer; - const [imports] = parseModuleImports(contents); - for (const moduleImport of imports) { - const dependency = moduleImport.n; - if (!dependency || (!dependency.startsWith(".") && !dependency.startsWith("/"))) { + for (const dependency of javascriptResourceReferences(contents)) { + const externalUrl = normalizedExternalResourceUrl(dependency); + if (externalUrl) { + addExternalUrl(externalUrl); continue; } - const resolved = dependency - ? resolveLocalResourcePath( - dependency, - dependency.startsWith("/") ? baseDirectory : dependencyDirectory, - ) - : null; + const resolved = resolveLocalResourcePath( + dependency, + dependency.startsWith("/") ? resourceBoundary : dependencyDirectory, + ); if (resolved) pending.push(resolved); } + + await initializeModuleLexer; + try { + const [imports] = parseModuleImports(contents); + for (const moduleImport of imports) { + const dependency = moduleImport.n; + if (!dependency || (!dependency.startsWith(".") && !dependency.startsWith("/"))) { + const externalUrl = dependency ? normalizedExternalResourceUrl(dependency) : null; + if (externalUrl) addExternalUrl(externalUrl); + continue; + } + const resolved = dependency + ? resolveLocalResourcePath( + dependency, + dependency.startsWith("/") ? resourceBoundary : dependencyDirectory, + ) + : null; + if (resolved) pending.push(resolved); + } + } catch { + // A bounded prefix can end in the middle of a token. Literal-reference + // discovery above still preserves dependencies found before the cutoff. + } + } + + return { + paths: [...allowed], + externalUrls: [...externalUrls], + hasExecutableDocument, + hasTruncatedActiveDocument, + hasTruncatedDependency, + }; +} + +function commonSiteRoot( + entryPath: string, + resourcePaths: readonly string[], + resourceBoundary: string, +): string { + let common = path.dirname(entryPath); + for (const resourcePath of resourcePaths) { + while (!isPathInside(resourcePath, common) && common !== resourceBoundary) { + const parent = path.dirname(common); + if (parent === common || !isPathInside(parent, resourceBoundary)) break; + common = parent; + } } + return isPathInside(common, resourceBoundary) ? common : resourceBoundary; +} - return [...allowed]; +async function readInspectionPrefix(filePath: string): Promise { + const handle = await fs.open(filePath, "r"); + try { + const buffer = Buffer.allocUnsafe(HTML_INSPECTION_MAX_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await handle.close(); + } } export async function inspectHtmlArtifact( @@ -251,8 +562,14 @@ export async function inspectHtmlArtifact( ? path.resolve(input.path) : path.resolve(canonicalWorkspaceRoot, input.path); const absolutePath = await fs.realpath(requestedPath).catch(() => null); - if (!absolutePath || !isPathInside(absolutePath, canonicalWorkspaceRoot)) { - return unsupported("The HTML file is outside the active workspace or no longer exists."); + if (!absolutePath) { + return unsupported("The HTML file no longer exists."); + } + // Relative references remain workspace-contained. Absolute file links are + // intentionally allowed: chat transcripts and tool output frequently point + // at deliverables in Downloads, temporary workspaces, or another checkout. + if (!path.isAbsolute(input.path) && !isPathInside(absolutePath, canonicalWorkspaceRoot)) { + return unsupported("The relative HTML path resolves outside the active workspace."); } if (!isSupportedLocalHtmlPath(absolutePath)) { return unsupported("Only HTML files can be inspected for browser preview."); @@ -262,15 +579,26 @@ export async function inspectHtmlArtifact( if (!stat?.isFile()) { return unsupported("The HTML artifact is not a file."); } - if (stat.size > HTML_INSPECTION_MAX_BYTES) { - return unsupported("The HTML artifact is too large to inspect safely."); - } - - const source = await fs.readFile(absolutePath, "utf8"); + const source = await readInspectionPrefix(absolutePath); const document = parse(source) as DocumentNode; const baseDirectory = path.dirname(absolutePath); + // Opening one HTML document authorizes only its containing site directory. + // Parent traversal requires a separate, explicit site-root choice; markup is + // never allowed to nominate arbitrary files elsewhere in the workspace. + const resourceBoundary = baseDirectory; const warnings: ProjectHtmlArtifactWarning[] = []; + if (stat.size > HTML_INSPECTION_MAX_BYTES) { + warnings.push({ + code: "inspection-truncated", + message: + "Only the beginning of this large HTML file was inspected; the full file will still open.", + }); + } const localResources: Array<{ value: string; executable: boolean }> = []; + const externalResources = new Set(); + const addExternalResource = (url: string) => { + if (externalResources.size < RESOURCE_GRAPH_MAX_FILES) externalResources.add(url); + }; let title: string | undefined; let hasInlineScript = false; let hasBrowserScript = false; @@ -278,6 +606,8 @@ export async function inspectHtmlArtifact( source, ); let hasUnsupportedExecutable = false; + const documentBaseHref = htmlResourceReferences(source).baseHref; + const inlineModuleSources: string[] = []; const addWarning = (warning: ProjectHtmlArtifactWarning) => { if ( @@ -290,6 +620,23 @@ export async function inspectHtmlArtifact( visit(document, (element) => { const tagName = element.tagName.toLowerCase(); + if ( + elementCanMutateSvgAtRuntime(element) || + element.attrs.some( + (attribute) => + (attribute.name.toLowerCase().startsWith("on") && attribute.value.trim().length > 0) || + isExecutableUrlAttribute(attribute.name, attribute.value), + ) + ) { + hasInlineScript = true; + } + const srcdoc = attributeOf(element, "srcdoc"); + if (srcdoc && markupHasExecutableContent(srcdoc)) { + hasInlineScript = true; + } + if (tagName === "base") { + return; + } if (tagName === "title" && !title) { const candidate = textContentOf(element).replace(/\s+/g, " ").trim(); if (candidate) title = candidate.slice(0, 500); @@ -297,17 +644,28 @@ export async function inspectHtmlArtifact( } if (tagName === "script") { - const sourcePath = attributeOf(element, "src"); + if (!scriptElementIsExecutable(element)) return; + const sourcePath = scriptSourcePath(element); if (!sourcePath) { - hasInlineScript = textContentOf(element).trim().length > 0; + const inlineScript = textContentOf(element); + hasInlineScript ||= inlineScript.trim().length > 0; + if (attributeOf(element, "type")?.trim().toLowerCase() === "module") { + inlineModuleSources.push(inlineScript); + } + for (const resource of javascriptResourceReferences(inlineScript)) { + const externalUrl = normalizedExternalResourceUrl(resource); + if (externalUrl) { + addExternalResource(externalUrl); + } else if (!isExternalResource(resource)) { + localResources.push({ value: resource, executable: false }); + } + } return; } if (isExternalResource(sourcePath)) { + const externalUrl = normalizedExternalResourceUrl(sourcePath); + if (externalUrl) addExternalResource(externalUrl); hasBrowserScript = true; - addWarning({ - code: "external-resource-blocked", - message: `External script blocked in preview: ${sourcePath.slice(0, 300)}`, - }); return; } const extension = lowerCaseExtensionOf(sourcePath.split(/[?#]/, 1)[0] ?? ""); @@ -326,60 +684,103 @@ export async function inspectHtmlArtifact( return; } - const resourceAttribute = - tagName === "link" ? attributeOf(element, "href") : attributeOf(element, "src"); - if (!resourceAttribute) return; - if (isExternalResource(resourceAttribute)) { - addWarning({ - code: "external-resource-blocked", - message: `External resource blocked in preview: ${resourceAttribute.slice(0, 300)}`, - }); - return; + for (const resource of resourceReferencesForElement(element)) { + const externalUrl = normalizedExternalResourceUrl(resource, documentBaseHref); + if (externalUrl) { + addExternalResource(externalUrl); + } else if (!isExternalResource(resource)) { + localResources.push({ value: resource, executable: false }); + } } - localResources.push({ value: resourceAttribute, executable: false }); }); + if (inlineModuleSources.length > 0) { + await initializeModuleLexer; + for (const inlineModule of inlineModuleSources) { + const [imports] = parseModuleImports(inlineModule); + for (const moduleImport of imports) { + const dependency = moduleImport.n; + if (dependency && (dependency.startsWith(".") || dependency.startsWith("/"))) { + localResources.push({ value: dependency, executable: true }); + } + } + } + } + for (const resource of localResources) { - const cleanValue = resource.value.split(/[?#]/, 1)[0] ?? ""; - const extension = lowerCaseExtensionOf(cleanValue); - if ( - extension && - !DEV_SOURCE_EXTENSIONS.has(extension) && - !ALLOWED_LOCAL_RESOURCE_EXTENSIONS.has(extension) - ) { + const resolved = resolveHtmlDocumentResourcePath({ + value: resource.value, + documentPath: absolutePath, + siteRoot: resourceBoundary, + baseHref: documentBaseHref, + }); + if (!resolved) continue; + const canonical = await fs.realpath(resolved).catch(() => null); + if (!canonical) { addWarning({ - code: "unsupported-local-resource", - message: `Unsupported local preview resource: ${resource.value.slice(0, 300)}`, + code: "missing-local-resource", + message: `Local preview resource was not found: ${resource.value.slice(0, 300)}`, }); - if (resource.executable) hasUnsupportedExecutable = true; continue; } - if (!(await resourceExists(resource.value, baseDirectory))) { + if (!isPathInside(canonical, resourceBoundary)) { addWarning({ - code: "missing-local-resource", - message: `Local preview resource was not found: ${resource.value.slice(0, 300)}`, + code: "local-resource-denied", + message: `Local preview resource is outside the opened file's authority: ${resource.value.slice(0, 300)}`, }); } } - const runTarget = hasDevSource - ? await nearestRunTarget(absolutePath, canonicalWorkspaceRoot) - : undefined; - const mode = hasDevSource - ? "dev-server-entrypoint" - : hasUnsupportedExecutable - ? "unsupported" - : hasInlineScript || hasBrowserScript + const collectedResources = await collectAllowedResourcePaths( + localResources.map((resource) => resource.value), + absolutePath, + documentBaseHref, + resourceBoundary, + ); + const allowedResourcePaths = collectedResources.paths; + for (const externalUrl of collectedResources.externalUrls) addExternalResource(externalUrl); + if (collectedResources.hasTruncatedActiveDocument) { + addWarning({ + code: "inspection-truncated", + message: + "Only the beginning of a linked active document was inspected; it will open in interactive mode and its discovered assets remain available.", + }); + } + if (collectedResources.hasTruncatedDependency) { + addWarning({ + code: "inspection-truncated", + message: + "Only the beginning of a large linked stylesheet or script was inspected; references after the inspected prefix may be unavailable.", + }); + } + + const runTarget = + hasDevSource && isPathInside(absolutePath, canonicalWorkspaceRoot) + ? await nearestRunTarget(absolutePath, canonicalWorkspaceRoot) + : undefined; + const mode = + hasDevSource && runTarget + ? "dev-server-entrypoint" + : hasDevSource || + hasInlineScript || + hasBrowserScript || + hasUnsupportedExecutable || + collectedResources.hasExecutableDocument || + stat.size > HTML_INSPECTION_MAX_BYTES ? "interactive-bundle" : "static-document"; const reason = mode === "dev-server-entrypoint" - ? runTarget - ? "This HTML file references source modules and must run through its development server." - : "This HTML file references source modules, but no dev or start script was found." - : mode === "unsupported" - ? "The HTML file references an executable resource type that the safe preview cannot run." - : undefined; + ? "This HTML file references source modules and must run through its development server." + : undefined; + + if (mode === "interactive-bundle" && externalResources.size > 0) { + addWarning({ + code: "external-resource-blocked", + message: + "External network resources are blocked for interactive local HTML; bundle them into the same site directory instead.", + }); + } return { result: { @@ -391,9 +792,8 @@ export async function inspectHtmlArtifact( }, absolutePath, baseDirectory, - allowedResourcePaths: await collectAllowedResourcePaths( - localResources.map((resource) => resource.value), - baseDirectory, - ), + siteRoot: commonSiteRoot(absolutePath, allowedResourcePaths, resourceBoundary), + allowedResourcePaths, + allowedExternalUrls: [...externalResources], }; } diff --git a/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.test.ts b/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.test.ts index 5a1d9d764..74d9ddf01 100644 --- a/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.test.ts +++ b/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.test.ts @@ -1,5 +1,5 @@ // FILE: HtmlArtifactPreview.test.ts -// Purpose: Security and rendering coverage for the isolated HTML preview listener. +// Purpose: Rendering, navigation, and lifecycle coverage for full local HTML sites. import fs from "node:fs/promises"; import http from "node:http"; @@ -13,7 +13,7 @@ import { HtmlArtifactPreview, type HtmlArtifactPreviewShape, } from "../Services/HtmlArtifactPreview"; -import { HtmlArtifactPreviewLive } from "./HtmlArtifactPreview"; +import { HtmlArtifactPreviewLive, makeHtmlArtifactPreviewLayer } from "./HtmlArtifactPreview"; const temporaryDirectories: string[] = []; @@ -39,8 +39,8 @@ interface PreviewResponse { async function requestPreview( previewUrl: string, - pathname = "/", - input: { method?: string; host?: string } = {}, + pathname?: string, + input: { method?: string; host?: string; headers?: http.OutgoingHttpHeaders } = {}, ): Promise { const url = new URL(previewUrl); return new Promise((resolve, reject) => { @@ -48,9 +48,9 @@ async function requestPreview( { host: "127.0.0.1", port: Number(url.port), - path: pathname, + path: pathname ?? url.pathname, method: input.method ?? "GET", - headers: { Host: input.host ?? url.host }, + headers: { Host: input.host ?? url.host, ...input.headers }, }, (response) => { let body = ""; @@ -68,19 +68,22 @@ async function requestPreview( }); } -function withPreviewService(use: (service: HtmlArtifactPreviewShape) => Promise): Promise { +function withPreviewService( + use: (service: HtmlArtifactPreviewShape) => Promise, + layer = HtmlArtifactPreviewLive, +): Promise { return Effect.runPromise( Effect.scoped( Effect.gen(function* () { const service = yield* HtmlArtifactPreview; return yield* Effect.promise(() => use(service)); - }).pipe(Effect.provide(HtmlArtifactPreviewLive)), + }).pipe(Effect.provide(layer)), ), ); } describe("HtmlArtifactPreviewLive", () => { - it("serves static HTML and presentation assets while refusing JavaScript", async () => { + it("serves static HTML and ordinary sibling assets without an injected CSP", async () => { const workspace = await makeWorkspace(); await fs.writeFile( path.join(workspace, "report.css"), @@ -104,8 +107,8 @@ describe("HtmlArtifactPreviewLive", () => { const document = await requestPreview(prepared.previewUrl!); expect(document.status).toBe(200); expect(document.body).toContain("

Report

"); - expect(document.headers["content-security-policy"]).toContain("script-src 'none'"); - expect(document.headers["permissions-policy"]).toContain("camera=()"); + expect(document.headers["content-security-policy"]).toBeUndefined(); + expect(document.headers["x-dns-prefetch-control"]).toBe("off"); const stylesheet = await requestPreview(prepared.previewUrl!, "/report.css"); expect(stylesheet.status).toBe(200); @@ -123,7 +126,59 @@ describe("HtmlArtifactPreviewLive", () => { }); }); - it("serves browser-ready JavaScript only under the interactive policy", async () => { + it("gives inert thumbnails a no-network, no-script content policy", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "index.html"), "

Thumbnail

"); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "index.html", thumbnail: true }), + ); + const document = await requestPreview(prepared.previewUrl!); + expect(document.status).toBe(200); + expect(document.headers["content-security-policy"]).toContain("script-src 'none'"); + expect(document.headers["content-security-policy"]).toContain("connect-src 'none'"); + expect(document.headers["content-security-policy"]).toContain("form-action 'none'"); + }); + }); + + it("does not widen a document capability to parent-directory resources", async () => { + const workspace = await makeWorkspace(); + await fs.mkdir(path.join(workspace, "reports")); + await fs.mkdir(path.join(workspace, "assets")); + await fs.writeFile(path.join(workspace, "assets", "theme.css"), "body { color: green; }"); + await fs.writeFile(path.join(workspace, "private.txt"), "must not be served"); + await fs.writeFile( + path.join(workspace, "reports", "report.html"), + '

Parent asset

', + ); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "reports/report.html" }), + ); + expect(new URL(prepared.previewUrl!).pathname).toBe("/"); + expect(prepared.warnings).toContainEqual({ + code: "local-resource-denied", + message: + "Local preview resource is outside the opened file's authority: ../assets/theme.css", + }); + await expect(requestPreview(prepared.previewUrl!)).resolves.toMatchObject({ + status: 200, + body: expect.stringContaining("Parent asset"), + }); + await expect( + requestPreview(prepared.previewUrl!, "/assets/theme.css"), + ).resolves.toMatchObject({ + status: 404, + }); + await expect(requestPreview(prepared.previewUrl!, "/private.txt")).resolves.toMatchObject({ + status: 404, + }); + }); + }); + + it("serves browser-ready JavaScript and dynamic resources without a rollout switch", async () => { const workspace = await makeWorkspace(); await fs.writeFile( path.join(workspace, "app.js"), @@ -136,35 +191,119 @@ describe("HtmlArtifactPreviewLive", () => { '', ); - const previous = process.env.SCIENT_EXECUTABLE_HTML_PREVIEW; - process.env.SCIENT_EXECUTABLE_HTML_PREVIEW = "1"; - try { - await withPreviewService(async (service) => { - const prepared = await Effect.runPromise( - service.prepare({ cwd: workspace, path: "index.html" }), - ); - const document = await requestPreview(prepared.previewUrl!); - expect(document.headers["content-security-policy"]).toContain( - "script-src 'self' 'unsafe-inline'", - ); - expect(document.headers["content-security-policy"]).not.toContain("unsafe-eval"); - await expect(requestPreview(prepared.previewUrl!, "/app.js")).resolves.toMatchObject({ - status: 200, - }); - await expect(requestPreview(prepared.previewUrl!, "/chunk.js")).resolves.toMatchObject({ - status: 200, - }); - await expect(requestPreview(prepared.previewUrl!, "/secret.js")).resolves.toMatchObject({ - status: 404, - }); + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "index.html" }), + ); + expect(prepared.mode).toBe("interactive-bundle"); + const document = await requestPreview(prepared.previewUrl!); + expect(document.headers["content-security-policy"]).toBeUndefined(); + expect(document.headers["x-dns-prefetch-control"]).toBe("off"); + await expect(requestPreview(prepared.previewUrl!, "/app.js")).resolves.toMatchObject({ + status: 200, + }); + await expect(requestPreview(prepared.previewUrl!, "/chunk.js")).resolves.toMatchObject({ + status: 200, }); - } finally { - if (previous === undefined) delete process.env.SCIENT_EXECUTABLE_HTML_PREVIEW; - else process.env.SCIENT_EXECUTABLE_HTML_PREVIEW = previous; - } + await expect(requestPreview(prepared.previewUrl!, "/secret.js")).resolves.toMatchObject({ + status: 404, + }); + }); }); - it("exposes no application routes and rejects invalid hosts, methods, dotfiles, and traversal", async () => { + it("serves exact granted filenames containing percent signs", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "100%.css"), "body { color: purple; }"); + await fs.writeFile( + path.join(workspace, "index.html"), + '

Percent

', + ); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "index.html" }), + ); + await expect(requestPreview(prepared.previewUrl!, "/100%25.css")).resolves.toMatchObject({ + status: 200, + body: expect.stringContaining("purple"), + }); + }); + }); + + it("supports byte ranges for local media and other large site assets", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "index.html"), ''); + await fs.writeFile(path.join(workspace, "clip.mp4"), "0123456789"); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "index.html" }), + ); + const response = await requestPreview(prepared.previewUrl!, "/clip.mp4", { + headers: { Range: "bytes=2-5" }, + }); + expect(response).toMatchObject({ status: 206, body: "2345" }); + expect(response.headers["accept-ranges"]).toBe("bytes"); + expect(response.headers["content-range"]).toBe("bytes 2-5/10"); + }); + }); + + it("isolates exact dependency capabilities between previews in the same directory", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "one.css"), "body { color: red; }"); + await fs.writeFile(path.join(workspace, "two.css"), "body { color: blue; }"); + await fs.writeFile( + path.join(workspace, "one.html"), + '

One

', + ); + await fs.writeFile( + path.join(workspace, "two.html"), + '

Two

', + ); + + await withPreviewService(async (service) => { + const one = await Effect.runPromise(service.prepare({ cwd: workspace, path: "one.html" })); + const two = await Effect.runPromise(service.prepare({ cwd: workspace, path: "two.html" })); + + await expect(requestPreview(one.previewUrl!, "/one.css")).resolves.toMatchObject({ + status: 200, + }); + await expect(requestPreview(one.previewUrl!, "/two.css")).resolves.toMatchObject({ + status: 404, + }); + await expect(requestPreview(two.previewUrl!, "/two.css")).resolves.toMatchObject({ + status: 200, + }); + await expect(requestPreview(two.previewUrl!, "/one.css")).resolves.toMatchObject({ + status: 404, + }); + }); + }); + + it("allows declared local navigation but denies undeclared neighboring documents", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "index.html"), '
Linked'); + await fs.writeFile( + path.join(workspace, "linked.html"), + 'Back

Linked page

', + ); + await fs.writeFile(path.join(workspace, "private.html"), "

Private neighbor

"); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "index.html" }), + ); + await expect(requestPreview(prepared.previewUrl!, "/linked.html")).resolves.toMatchObject({ + status: 200, + body: expect.stringContaining("Linked page"), + }); + await expect(requestPreview(prepared.previewUrl!, "/private.html")).resolves.toMatchObject({ + status: 404, + }); + }); + }); + + it("keeps the local-site origin separate from app routes and rejects invalid hosts, methods, and traversal", async () => { const workspace = await makeWorkspace(); await fs.writeFile(path.join(workspace, ".env"), "SECRET=value"); await fs.writeFile(path.join(workspace, "index.html"), "

Safe

"); @@ -175,7 +314,13 @@ describe("HtmlArtifactPreviewLive", () => { ); const url = prepared.previewUrl!; - for (const pathname of ["/api/auth/session", "/ws", "/.env", "/../.env", "/%2e%2e/.env"]) { + for (const pathname of [ + "/api/auth/session", + "/ws", + "/.env", + "/../outside", + "/%2e%2e/outside", + ]) { await expect(requestPreview(url, pathname)).resolves.toMatchObject({ status: 404 }); } await expect(requestPreview(url, "/", { method: "POST" })).resolves.toMatchObject({ @@ -243,44 +388,120 @@ describe("HtmlArtifactPreviewLive", () => { }); }); - it("bounds abandoned grants and evicts the oldest capability", async () => { + it("refuses excess grants without evicting an active capability", async () => { const workspace = await makeWorkspace(); await fs.writeFile(path.join(workspace, "index.html"), "

Bounded

"); await withPreviewService(async (service) => { const urls: string[] = []; - for (let index = 0; index < 129; index += 1) { + for (let index = 0; index < 512; index += 1) { const prepared = await Effect.runPromise( service.prepare({ cwd: workspace, path: "index.html" }), ); urls.push(prepared.previewUrl!); } - await expect(requestPreview(urls[0]!)).resolves.toMatchObject({ status: 404 }); + await expect( + Effect.runPromise(service.prepare({ cwd: workspace, path: "index.html" })), + ).rejects.toThrow("Failed to prepare the HTML artifact preview"); + await expect(requestPreview(urls[0]!)).resolves.toMatchObject({ status: 200 }); await expect(requestPreview(urls.at(-1)!)).resolves.toMatchObject({ status: 200 }); }); }); - it("keeps executable previews fail-closed behind an operational rollout switch", async () => { + it("reserves dedicated-listener capacity before asynchronous startup", async () => { + const workspace = await makeWorkspace(); + await fs.writeFile(path.join(workspace, "index.html"), "

Concurrent

"); + const oneGrantLayer = makeHtmlArtifactPreviewLayer({ + maxActiveGrants: 1, + useDedicatedServers: true, + }); + + await withPreviewService(async (service) => { + const results = await Promise.allSettled([ + Effect.runPromise(service.prepare({ cwd: workspace, path: "index.html" })), + Effect.runPromise(service.prepare({ cwd: workspace, path: "index.html" })), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + }, oneGrantLayer); + }); + + it("opens executable HTML by default", async () => { const workspace = await makeWorkspace(); await fs.writeFile(path.join(workspace, "app.js"), "document.body.dataset.ready = 'yes';"); await fs.writeFile( path.join(workspace, "index.html"), '', ); - const previous = process.env.SCIENT_EXECUTABLE_HTML_PREVIEW; - delete process.env.SCIENT_EXECUTABLE_HTML_PREVIEW; - try { - await withPreviewService(async (service) => { - const prepared = await Effect.runPromise( - service.prepare({ cwd: workspace, path: "index.html" }), - ); - expect(prepared.mode).toBe("unsupported"); - expect(prepared.previewUrl).toBeUndefined(); - expect(prepared.reason).toContain("rollout switch"); + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: "index.html" }), + ); + expect(prepared.mode).toBe("interactive-bundle"); + expect(prepared.previewUrl).toBeDefined(); + await expect(requestPreview(prepared.previewUrl!, "/app.js")).resolves.toMatchObject({ + status: 200, + }); + }); + }); + + it("opens an absolute HTML file outside the active workspace", async () => { + const workspace = await makeWorkspace(); + const outside = await makeWorkspace(); + const outsideFile = path.join(outside, "report.html"); + await fs.writeFile( + outsideFile, + '

External report

', + ); + await fs.writeFile(path.join(outside, "theme.css"), "body { color: rebeccapurple; }"); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: outsideFile }), + ); + expect(prepared.previewUrl).toBeDefined(); + await expect(requestPreview(prepared.previewUrl!)).resolves.toMatchObject({ + status: 200, + body: expect.stringContaining("External report"), }); - } finally { - if (previous === undefined) delete process.env.SCIENT_EXECUTABLE_HTML_PREVIEW; - else process.env.SCIENT_EXECUTABLE_HTML_PREVIEW = previous; - } + await expect(requestPreview(prepared.previewUrl!, "/theme.css")).resolves.toMatchObject({ + status: 200, + }); + }); + }); + + it("denies parent-directory assets for an absolute HTML file", async () => { + const workspace = await makeWorkspace(); + const outside = await makeWorkspace(); + await fs.mkdir(path.join(outside, "reports")); + await fs.mkdir(path.join(outside, "assets")); + await fs.writeFile(path.join(outside, "assets", "theme.css"), "body { color: teal; }"); + await fs.writeFile(path.join(outside, "unrelated.txt"), "must remain private"); + const outsideFile = path.join(outside, "reports", "report.html"); + await fs.writeFile( + outsideFile, + '

External report

', + ); + + await withPreviewService(async (service) => { + const prepared = await Effect.runPromise( + service.prepare({ cwd: workspace, path: outsideFile }), + ); + expect(new URL(prepared.previewUrl!).pathname).toBe("/"); + expect(prepared.warnings).toContainEqual({ + code: "local-resource-denied", + message: + "Local preview resource is outside the opened file's authority: ../assets/theme.css", + }); + await expect( + requestPreview(prepared.previewUrl!, "/assets/theme.css"), + ).resolves.toMatchObject({ + status: 404, + }); + await expect(requestPreview(prepared.previewUrl!, "/unrelated.txt")).resolves.toMatchObject({ + status: 404, + }); + }); }); }); diff --git a/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.ts b/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.ts index 66d054930..24f890e9d 100644 --- a/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.ts +++ b/apps/server/src/htmlPreview/Layers/HtmlArtifactPreview.ts @@ -3,17 +3,18 @@ // Layer: Server HTML-preview live implementation import crypto from "node:crypto"; +import { constants as fsConstants, type Stats } from "node:fs"; import fs from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import http from "node:http"; import path from "node:path"; +import Mime from "@effect/platform-node/Mime"; import type { - ProjectHtmlArtifactMode, ProjectInspectHtmlArtifactInput, ProjectPrepareHtmlArtifactPreviewInput, ProjectRevokeHtmlArtifactPreviewInput, } from "@synara/contracts"; -import { SUPPORTED_LOCAL_IMAGE_EXTENSIONS } from "@synara/shared/localPreviewFiles"; import { Effect, Layer } from "effect"; import { inspectHtmlArtifact } from "../Inspector"; @@ -23,67 +24,30 @@ import { type HtmlArtifactPreviewShape, } from "../Services/HtmlArtifactPreview"; -const PREVIEW_GRANT_TTL_MS = 15 * 60 * 1000; -const PREVIEW_MAX_ACTIVE_GRANTS = 128; -const PREVIEW_MAX_ASSET_BYTES = 25 * 1024 * 1024; +const PREVIEW_MAX_ACTIVE_GRANTS = 512; const PREVIEW_HOST_SUFFIX = ".preview.localhost"; -const PREVIEW_ASSET_EXTENSIONS: ReadonlySet = new Set([ - ...SUPPORTED_LOCAL_IMAGE_EXTENSIONS, - ".css", - ".js", - ".mjs", - ".otf", - ".ttf", - ".woff", - ".woff2", -]); -const STATIC_PREVIEW_ASSET_EXTENSIONS: ReadonlySet = new Set( - [...PREVIEW_ASSET_EXTENSIONS].filter((extension) => extension !== ".js" && extension !== ".mjs"), -); - -function executableHtmlPreviewEnabled(): boolean { - const value = process.env.SCIENT_EXECUTABLE_HTML_PREVIEW?.trim().toLowerCase(); - return value === "1" || value === "true"; -} interface PreviewGrant { readonly id: string; readonly entryPath: string; - readonly baseDirectory: string; - readonly mode: Extract; - readonly expiresAtMs: number; - readonly allowedFiles: ReadonlySet; + readonly siteRoot: string; + readonly filesByRoute: ReadonlyMap; readonly listenerPort: number; + readonly thumbnail: boolean; readonly dedicatedServer?: http.Server; } +interface GrantedFile { + readonly path: string; + readonly device: number; + readonly inode: number; +} + function isPathInside(candidate: string, root: string): boolean { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -function previewContentSecurityPolicy(mode: PreviewGrant["mode"]): string { - const executable = mode === "interactive-bundle"; - return [ - "default-src 'none'", - executable ? "script-src 'self' 'unsafe-inline'" : "script-src 'none'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", - "font-src 'self' data:", - "media-src 'none'", - executable ? "connect-src 'self'" : "connect-src 'none'", - "worker-src 'none'", - "manifest-src 'none'", - "object-src 'none'", - "frame-src 'none'", - "child-src 'none'", - "base-uri 'none'", - "form-action 'none'", - "frame-ancestors http://localhost:* http://127.0.0.1:* scient:", - executable ? "sandbox allow-scripts allow-same-origin" : "sandbox", - ].join("; "); -} - function contentTypeFor(filePath: string): string { switch (path.extname(filePath).toLowerCase()) { case ".html": @@ -118,7 +82,7 @@ function contentTypeFor(filePath: string): string { case ".otf": return "font/otf"; default: - return "application/octet-stream"; + return Mime.getType(filePath) ?? "application/octet-stream"; } } @@ -154,9 +118,9 @@ function decodeRequestedAssetPath(rawUrl: string | undefined): string | null { } catch { return null; } - if (decoded.includes("\0") || decoded.includes("\\") || decoded.includes("%")) return null; + if (decoded.includes("\0") || decoded.includes("\\")) return null; const relativePath = decoded.replace(/^\/+/, ""); - if (relativePath.length > 2_048) return null; + if (relativePath.length > 8_192) return null; const segments = relativePath.split("/"); if (segments.some((segment) => segment === "." || segment === ".." || segment.startsWith("."))) { return null; @@ -167,41 +131,119 @@ function decodeRequestedAssetPath(rawUrl: string | undefined): string | null { async function resolveGrantedFile( grant: PreviewGrant, rawUrl: string | undefined, -): Promise { +): Promise<{ + readonly file: FileHandle; + readonly path: string; + readonly stat: Stats; +} | null> { const relativePath = decodeRequestedAssetPath(rawUrl); if (relativePath === null) return null; - if (relativePath.length > 0) { - const extension = path.extname(relativePath).toLowerCase(); - const allowedExtensions = - grant.mode === "interactive-bundle" - ? PREVIEW_ASSET_EXTENSIONS - : STATIC_PREVIEW_ASSET_EXTENSIONS; - if (!allowedExtensions.has(extension)) return null; - } + const granted = grant.filesByRoute.get(relativePath); + if (!granted) return null; - const candidate = - relativePath.length === 0 ? grant.entryPath : path.resolve(grant.baseDirectory, relativePath); - const canonicalFile = await fs.realpath(candidate).catch(() => null); - if (!canonicalFile || !isPathInside(canonicalFile, grant.baseDirectory)) return null; - if (!grant.allowedFiles.has(canonicalFile)) return null; - const stat = await fs.stat(canonicalFile).catch(() => null); - return stat?.isFile() && stat.size <= PREVIEW_MAX_ASSET_BYTES ? canonicalFile : null; + // Re-resolve on every request so replacing a granted path with a symlink cannot + // retarget an already-issued capability. The route map is the authority; the + // directory containing the HTML is never an implicit file-server root. + const canonicalFile = await fs.realpath(granted.path).catch(() => null); + if (canonicalFile !== granted.path) return null; + const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; + const file = await fs.open(granted.path, fsConstants.O_RDONLY | noFollow).catch(() => null); + if (!file) return null; + const stat = await file.stat().catch(() => null); + if (!stat?.isFile() || stat.dev !== granted.device || stat.ino !== granted.inode) { + await file.close().catch(() => undefined); + return null; + } + return { file, path: granted.path, stat: stat as Stats }; } -function securityHeaders(grant: PreviewGrant): Record { +function browserHeaders(grant: PreviewGrant): Record { return { "Cache-Control": "no-store", - "Content-Security-Policy": previewContentSecurityPolicy(grant.mode), - "Cross-Origin-Opener-Policy": "same-origin", - "Cross-Origin-Resource-Policy": "same-origin", - "Origin-Agent-Cluster": "?1", - "Permissions-Policy": - "camera=(), microphone=(), geolocation=(), display-capture=(), fullscreen=(), payment=(), usb=(), serial=(), hid=(), clipboard-read=(), clipboard-write=()", + "Accept-Ranges": "bytes", "Referrer-Policy": "no-referrer", + "X-DNS-Prefetch-Control": "off", "X-Content-Type-Options": "nosniff", + ...(grant.thumbnail + ? { + "Content-Security-Policy": + "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'none'; connect-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'", + } + : {}), }; } +function parseSingleByteRange( + value: string | undefined, + sizeBytes: number, +): { readonly start: number; readonly end: number } | null | "invalid" { + if (!value) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim()); + if (!match || sizeBytes <= 0) return "invalid"; + const rawStart = match[1] ?? ""; + const rawEnd = match[2] ?? ""; + if (!rawStart && !rawEnd) return "invalid"; + if (!rawStart) { + const suffixLength = Number(rawEnd); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return "invalid"; + return { start: Math.max(0, sizeBytes - suffixLength), end: sizeBytes - 1 }; + } + const start = Number(rawStart); + const requestedEnd = rawEnd ? Number(rawEnd) : sizeBytes - 1; + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(requestedEnd) || + start < 0 || + requestedEnd < start || + start >= sizeBytes + ) { + return "invalid"; + } + return { start, end: Math.min(requestedEnd, sizeBytes - 1) }; +} + +function previewPathFor(entryPath: string, siteRoot: string): string { + const relativePath = path.relative(siteRoot, entryPath); + if (!relativePath || relativePath === path.basename(entryPath)) return "/"; + return `/${relativePath.split(path.sep).map(encodeURIComponent).join("/")}`; +} + +function grantedRouteFor(filePath: string, siteRoot: string): string | null { + const relativePath = path.relative(siteRoot, filePath); + if (!isPathInside(filePath, siteRoot) || path.isAbsolute(relativePath)) return null; + return relativePath.split(path.sep).join("/"); +} + +async function buildGrantedFileRoutes(input: { + entryPath: string; + siteRoot: string; + resourcePaths: readonly string[]; +}): Promise> { + const routes = new Map(); + const addRoute = async (route: string, filePath: string) => { + if (route.split("/").some((segment) => segment.startsWith("."))) return; + const stat = await fs.stat(filePath).catch(() => null); + if (!stat?.isFile()) return; + routes.set(route, { path: filePath, device: stat.dev, inode: stat.ino }); + }; + + for (const filePath of [input.entryPath, ...input.resourcePaths]) { + const route = grantedRouteFor(filePath, input.siteRoot); + if (route === null) continue; + await addRoute(route, filePath); + if (path.basename(filePath).toLowerCase() === "index.html") { + const directoryRoute = route.slice(0, -"index.html".length).replace(/\/$/, ""); + await addRoute(directoryRoute, filePath); + await addRoute(directoryRoute.length > 0 ? `${directoryRoute}/` : "", filePath); + } + } + + // The capability URL itself always opens the selected entry document. Keep + // its canonical route too so relative navigation/back-forward remain stable. + await addRoute("", input.entryPath); + return routes; +} + async function closeServer(server: http.Server): Promise { if (!server.listening) return; await new Promise((resolve) => server.close(() => resolve())); @@ -229,205 +271,231 @@ async function listenOnLoopback(server: http.Server): Promise { }); } -export const HtmlArtifactPreviewLive = Layer.effect( - HtmlArtifactPreview, - Effect.gen(function* () { - const grants = new Map(); - let listenerPort = 0; - - const removeGrant = (id: string): boolean => { - const grant = grants.get(id); - if (!grant) return false; - grants.delete(id); - if (grant.dedicatedServer) { - void closeServer(grant.dedicatedServer); - } - return true; - }; - - const pruneExpiredGrants = (nowMs: number): void => { - for (const [id, grant] of grants) { - if (grant.expiresAtMs <= nowMs) removeGrant(id); - } - }; +export function makeHtmlArtifactPreviewLayer( + options: { + readonly maxActiveGrants?: number; + readonly useDedicatedServers?: boolean; + } = {}, +) { + const maxActiveGrants = options.maxActiveGrants ?? PREVIEW_MAX_ACTIVE_GRANTS; + const useDedicatedServers = options.useDedicatedServers ?? process.platform === "win32"; + return Layer.effect( + HtmlArtifactPreview, + Effect.gen(function* () { + const grants = new Map(); + let listenerPort = 0; + let reservedGrantSlots = 0; - const reserveGrantCapacity = (nowMs: number): void => { - pruneExpiredGrants(nowMs); - while (grants.size >= PREVIEW_MAX_ACTIVE_GRANTS) { - const oldestId = grants.keys().next().value as string | undefined; - if (!oldestId) break; - removeGrant(oldestId); - } - }; + const reserveGrantCapacity = (): (() => void) => { + if (grants.size + reservedGrantSlots >= maxActiveGrants) { + throw new Error("Too many HTML previews are open. Close a preview and try again."); + } + reservedGrantSlots += 1; + let released = false; + return () => { + if (released) return; + released = true; + reservedGrantSlots -= 1; + }; + }; - const createServer = (dedicatedGrantId?: string): http.Server => - http.createServer((request, response) => { - void (async () => { - if (request.method !== "GET" && request.method !== "HEAD") { - writeNotFound(response); - return; - } - const nowMs = Date.now(); - pruneExpiredGrants(nowMs); - const grantId = dedicatedGrantId - ? normalizedHostName(request.headers.host) === "127.0.0.1" - ? dedicatedGrantId - : null - : grantIdFromHost(request.headers.host); - const grant = grantId ? grants.get(grantId) : undefined; - if (!grant || grant.expiresAtMs <= nowMs) { - writeNotFound(response); - return; - } - const filePath = await resolveGrantedFile(grant, request.url); - if (!filePath) { - writeNotFound(response); - return; - } - const stat = await fs.stat(filePath).catch(() => null); - if (!stat?.isFile() || stat.size > PREVIEW_MAX_ASSET_BYTES) { - writeNotFound(response); - return; - } - response.writeHead(200, { - ...securityHeaders(grant), - "Content-Length": String(stat.size), - "Content-Type": contentTypeFor(filePath), + const createServer = (dedicatedGrantId?: string): http.Server => + http.createServer((request, response) => { + void (async () => { + if (request.method !== "GET" && request.method !== "HEAD") { + writeNotFound(response); + return; + } + const grantId = dedicatedGrantId + ? normalizedHostName(request.headers.host) === "127.0.0.1" + ? dedicatedGrantId + : null + : grantIdFromHost(request.headers.host); + const grant = grantId ? grants.get(grantId) : undefined; + if (!grant) { + writeNotFound(response); + return; + } + const resolvedFile = await resolveGrantedFile(grant, request.url); + if (!resolvedFile) { + writeNotFound(response); + return; + } + const { file, path: filePath, stat } = resolvedFile; + const contentType = contentTypeFor(filePath); + const range = parseSingleByteRange(request.headers.range, stat.size); + if (range === "invalid") { + response.writeHead(416, { + ...browserHeaders(grant), + "Content-Range": `bytes */${stat.size}`, + }); + response.end(); + await file.close().catch(() => undefined); + return; + } + const responseSize = range ? range.end - range.start + 1 : stat.size; + response.writeHead(range ? 206 : 200, { + ...browserHeaders(grant), + "Content-Length": String(responseSize), + "Content-Type": contentType, + ...(range + ? { "Content-Range": `bytes ${range.start}-${range.end}/${stat.size}` } + : {}), + }); + if (request.method === "HEAD") { + response.end(); + await file.close().catch(() => undefined); + return; + } + const stream = file.createReadStream( + range + ? { start: range.start, end: range.end, autoClose: false } + : { autoClose: false }, + ); + let fileClosed = false; + const closeFile = () => { + if (fileClosed) return; + fileClosed = true; + void file.close().catch(() => undefined); + }; + stream.on("error", () => { + closeFile(); + response.destroy(); + }); + stream.on("end", closeFile); + response.on("close", closeFile); + stream.pipe(response); + })().catch(() => { + if (!response.headersSent) writeNotFound(response); + else response.destroy(); }); - if (request.method === "HEAD") { - response.end(); - return; - } - const file = await fs.open(filePath, "r"); - const stream = file.createReadStream(); - stream.on("error", () => response.destroy()); - stream.on("close", () => void file.close().catch(() => undefined)); - stream.pipe(response); - })().catch(() => { - if (!response.headersSent) writeNotFound(response); - else response.destroy(); }); - }); - - const server = createServer(); - - yield* Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - listenerPort = await listenOnLoopback(server); - }, - catch: (cause) => - new HtmlArtifactPreviewError({ - message: "Failed to start the isolated HTML preview listener.", - cause, - }), - }), - () => - Effect.promise(async () => { - const dedicatedServers = [...grants.values()].flatMap((grant) => - grant.dedicatedServer ? [grant.dedicatedServer] : [], - ); - grants.clear(); - await Promise.all([closeServer(server), ...dedicatedServers.map(closeServer)]); + + const server = createServer(); + + yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + listenerPort = await listenOnLoopback(server); + }, + catch: (cause) => + new HtmlArtifactPreviewError({ + message: "Failed to start the local HTML preview listener.", + cause, + }), }), - ); - - const inspect: HtmlArtifactPreviewShape["inspect"] = (input: ProjectInspectHtmlArtifactInput) => - Effect.tryPromise({ - try: async () => (await inspectHtmlArtifact(input)).result, - catch: (cause) => - new HtmlArtifactPreviewError({ message: "Failed to inspect the HTML artifact.", cause }), - }); - - const prepare: HtmlArtifactPreviewShape["prepare"] = ( - input: ProjectPrepareHtmlArtifactPreviewInput, - ) => - Effect.tryPromise({ - try: async () => { - const inspected = await inspectHtmlArtifact(input); - if ( - !executableHtmlPreviewEnabled() && - (inspected.result.mode === "interactive-bundle" || - inspected.result.mode === "dev-server-entrypoint") - ) { - const { runTarget: _runTarget, ...inspectionWithoutRunTarget } = inspected.result; - return { - ...inspectionWithoutRunTarget, - mode: "unsupported" as const, - reason: - "Executable HTML previews are disabled by the SCIENT_EXECUTABLE_HTML_PREVIEW rollout switch.", - }; - } - if ( - !inspected.absolutePath || - !inspected.baseDirectory || - (inspected.result.mode !== "static-document" && - inspected.result.mode !== "interactive-bundle") - ) { - return inspected.result; - } - const canonicalBaseDirectory = await fs.realpath(inspected.baseDirectory); - const nowMs = Date.now(); - reserveGrantCapacity(nowMs); - const expiresAtMs = nowMs + PREVIEW_GRANT_TTL_MS; - const id = crypto.randomUUID(); - const dedicatedServer = process.platform === "win32" ? createServer(id) : undefined; - const grantListenerPort = dedicatedServer - ? await listenOnLoopback(dedicatedServer) - : listenerPort; - grants.set(id, { - id, - entryPath: inspected.absolutePath, - baseDirectory: canonicalBaseDirectory, - mode: inspected.result.mode, - expiresAtMs, - allowedFiles: new Set([inspected.absolutePath, ...inspected.allowedResourcePaths]), - listenerPort: grantListenerPort, - ...(dedicatedServer ? { dedicatedServer } : {}), - }); - return { - ...inspected.result, - previewUrl: dedicatedServer - ? `http://127.0.0.1:${grantListenerPort}/` - : `http://g-${id}${PREVIEW_HOST_SUFFIX}:${grantListenerPort}/`, - expiresAt: new Date(expiresAtMs).toISOString(), - }; - }, - catch: (cause) => - new HtmlArtifactPreviewError({ - message: "Failed to prepare the HTML artifact preview.", - cause, + () => + Effect.promise(async () => { + const dedicatedServers = [...grants.values()].flatMap((grant) => + grant.dedicatedServer ? [grant.dedicatedServer] : [], + ); + grants.clear(); + await Promise.all([closeServer(server), ...dedicatedServers.map(closeServer)]); }), - }); - - const revoke: HtmlArtifactPreviewShape["revoke"] = ( - input: ProjectRevokeHtmlArtifactPreviewInput, - ) => - Effect.promise(async () => { - try { - const previewUrl = new URL(input.previewUrl); - const grantId = - grantIdFromHost(previewUrl.host) ?? - (previewUrl.hostname === "127.0.0.1" - ? ([...grants.values()].find( - (grant) => - grant.dedicatedServer && String(grant.listenerPort) === previewUrl.port, - )?.id ?? null) - : null); - const grant = grantId ? grants.get(grantId) : undefined; - if (!grant || String(grant.listenerPort) !== previewUrl.port) { + ); + + const inspect: HtmlArtifactPreviewShape["inspect"] = ( + input: ProjectInspectHtmlArtifactInput, + ) => + Effect.tryPromise({ + try: async () => (await inspectHtmlArtifact(input)).result, + catch: (cause) => + new HtmlArtifactPreviewError({ + message: "Failed to inspect the HTML artifact.", + cause, + }), + }); + + const prepare: HtmlArtifactPreviewShape["prepare"] = ( + input: ProjectPrepareHtmlArtifactPreviewInput, + ) => + Effect.tryPromise({ + try: async () => { + const inspected = await inspectHtmlArtifact(input); + if ( + !inspected.absolutePath || + !inspected.baseDirectory || + !inspected.siteRoot || + (inspected.result.mode !== "static-document" && + inspected.result.mode !== "interactive-bundle") + ) { + return inspected.result; + } + const canonicalSiteRoot = await fs.realpath(inspected.siteRoot); + const filesByRoute = await buildGrantedFileRoutes({ + entryPath: inspected.absolutePath, + siteRoot: canonicalSiteRoot, + resourcePaths: inspected.allowedResourcePaths, + }); + const releaseGrantReservation = reserveGrantCapacity(); + let dedicatedServer: http.Server | undefined; + try { + const id = crypto.randomUUID(); + dedicatedServer = useDedicatedServers ? createServer(id) : undefined; + const grantListenerPort = dedicatedServer + ? await listenOnLoopback(dedicatedServer) + : listenerPort; + grants.set(id, { + id, + entryPath: inspected.absolutePath, + siteRoot: canonicalSiteRoot, + filesByRoute, + listenerPort: grantListenerPort, + thumbnail: input.thumbnail === true, + ...(dedicatedServer ? { dedicatedServer } : {}), + }); + releaseGrantReservation(); + return { + ...inspected.result, + allowedExternalUrls: inspected.allowedExternalUrls, + previewUrl: dedicatedServer + ? `http://127.0.0.1:${grantListenerPort}${previewPathFor(inspected.absolutePath, canonicalSiteRoot)}` + : `http://g-${id}${PREVIEW_HOST_SUFFIX}:${grantListenerPort}${previewPathFor(inspected.absolutePath, canonicalSiteRoot)}`, + }; + } catch (cause) { + releaseGrantReservation(); + if (dedicatedServer) await closeServer(dedicatedServer); + throw cause; + } + }, + catch: (cause) => + new HtmlArtifactPreviewError({ + message: "Failed to prepare the HTML artifact preview.", + cause, + }), + }); + + const revoke: HtmlArtifactPreviewShape["revoke"] = ( + input: ProjectRevokeHtmlArtifactPreviewInput, + ) => + Effect.promise(async () => { + try { + const previewUrl = new URL(input.previewUrl); + const grantId = + grantIdFromHost(previewUrl.host) ?? + (previewUrl.hostname === "127.0.0.1" + ? ([...grants.values()].find( + (grant) => + grant.dedicatedServer && String(grant.listenerPort) === previewUrl.port, + )?.id ?? null) + : null); + const grant = grantId ? grants.get(grantId) : undefined; + if (!grant || String(grant.listenerPort) !== previewUrl.port) { + return { revoked: false }; + } + grants.delete(grant.id); + if (grant.dedicatedServer) { + await closeServer(grant.dedicatedServer); + } + return { revoked: true }; + } catch { return { revoked: false }; } - grants.delete(grant.id); - if (grant.dedicatedServer) { - await closeServer(grant.dedicatedServer); - } - return { revoked: true }; - } catch { - return { revoked: false }; - } - }); + }); + + return HtmlArtifactPreview.of({ inspect, prepare, revoke }); + }), + ); +} - return HtmlArtifactPreview.of({ inspect, prepare, revoke }); - }), -); +export const HtmlArtifactPreviewLive = makeHtmlArtifactPreviewLayer(); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c2ccc484c..df7a49b4c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -554,13 +554,52 @@ function streamedFileResponse(input: { readonly path: string; readonly sizeBytes: number; readonly headers: Record; + readonly offsetBytes?: number; + readonly status?: number; }): HttpServerResponse.HttpServerResponse { - return HttpServerResponse.stream(input.fileSystem.stream(input.path), { - status: 200, - contentType: Mime.getType(input.path) ?? "application/octet-stream", - contentLength: input.sizeBytes, - headers: input.headers, - }); + return HttpServerResponse.stream( + input.fileSystem.stream(input.path, { + ...(input.offsetBytes !== undefined ? { offset: input.offsetBytes } : {}), + bytesToRead: input.sizeBytes, + }), + { + status: input.status ?? 200, + contentType: Mime.getType(input.path) ?? "application/octet-stream", + contentLength: input.sizeBytes, + headers: input.headers, + }, + ); +} + +function parseSingleByteRange( + value: string | undefined, + sizeBytes: number, +): { readonly start: number; readonly end: number } | null | "invalid" { + if (!value) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim()); + if (!match || sizeBytes <= 0) return "invalid"; + const rawStart = match[1] ?? ""; + const rawEnd = match[2] ?? ""; + if (rawStart.length === 0 && rawEnd.length === 0) return "invalid"; + + if (rawStart.length === 0) { + const suffixLength = Number(rawEnd); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return "invalid"; + return { start: Math.max(0, sizeBytes - suffixLength), end: sizeBytes - 1 }; + } + + const start = Number(rawStart); + const requestedEnd = rawEnd.length > 0 ? Number(rawEnd) : sizeBytes - 1; + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(requestedEnd) || + start < 0 || + requestedEnd < start || + start >= sizeBytes + ) { + return "invalid"; + } + return { start, end: Math.min(requestedEnd, sizeBytes - 1) }; } export const localImageEffectRouteLayer = HttpRouter.add( @@ -593,12 +632,28 @@ export const localImageEffectRouteLayer = HttpRouter.add( const fileSystem = yield* FileSystem.FileSystem; const isDownload = url.searchParams.get("download") === "1"; const safeFileName = previewFile.fileName.replaceAll('"', ""); + const range = parseSingleByteRange(request.headers.range, previewFile.sizeBytes); + if (range === "invalid") { + return HttpServerResponse.text("Requested Range Not Satisfiable", { + status: 416, + headers: { + "Accept-Ranges": "bytes", + "Content-Range": `bytes */${previewFile.sizeBytes}`, + }, + }); + } + const responseSize = range ? range.end - range.start + 1 : previewFile.sizeBytes; return streamedFileResponse({ fileSystem, path: previewFile.path, - sizeBytes: previewFile.sizeBytes, + sizeBytes: responseSize, + ...(range ? { offsetBytes: range.start, status: 206 } : {}), headers: { "Cache-Control": "private, max-age=60", + "Accept-Ranges": "bytes", + ...(range + ? { "Content-Range": `bytes ${range.start}-${range.end}/${previewFile.sizeBytes}` } + : {}), // The PDF viewer fetches bytes from either the desktop app origin or // the configured Vite dev origin. Reflect only those trusted origins: // auth-token-less local servers must not expose workspace files to any diff --git a/apps/server/src/localImageFiles.test.ts b/apps/server/src/localImageFiles.test.ts index bef2fde0c..1b756129e 100644 --- a/apps/server/src/localImageFiles.test.ts +++ b/apps/server/src/localImageFiles.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { SCRATCH_WORKSPACES_DIRNAME } from "@synara/shared/threadWorkspace"; import { afterEach, describe, it } from "vitest"; -import { resolveAllowedLocalPreviewFile } from "./localImageFiles.ts"; +import { createLocalPreviewGrant, resolveAllowedLocalPreviewFile } from "./localImageFiles.ts"; const tempDirs: string[] = []; @@ -22,6 +22,18 @@ afterEach(() => { }); describe("resolveAllowedLocalPreviewFile", () => { + it("renews an active file grant without changing its media URL token", async () => { + const workspace = makeTempDir("synara-stable-media-grant-"); + const mediaPath = path.join(workspace, "long-recording.mp3"); + writeFileSync(mediaPath, Buffer.from("audio")); + + const first = await createLocalPreviewGrant({ requestedPath: mediaPath }); + const renewed = await createLocalPreviewGrant({ requestedPath: mediaPath }); + + assert.equal(renewed.grant, first.grant); + assert.ok(Date.parse(renewed.expiresAt) >= Date.parse(first.expiresAt)); + }); + it("allows images inside the current workspace", async () => { const workspace = makeTempDir("synara-image-workspace-"); writeFileSync(path.join(workspace, ".git"), "gitdir: .git"); @@ -122,6 +134,28 @@ describe("resolveAllowedLocalPreviewFile", () => { assert.equal(result?.sizeBytes, 8); }); + it("allows browser-native audio and video inside the current workspace", async () => { + const workspace = makeTempDir("synara-media-workspace-"); + writeFileSync(path.join(workspace, ".git"), "gitdir: .git"); + const audioPath = path.join(workspace, "recordings", "interview.mp3"); + const videoPath = path.join(workspace, "recordings", "demo.webm"); + mkdirSync(path.dirname(audioPath), { recursive: true }); + writeFileSync(audioPath, Buffer.from("audio")); + writeFileSync(videoPath, Buffer.from("video")); + + const audio = await resolveAllowedLocalPreviewFile({ + requestedPath: audioPath, + cwd: workspace, + }); + const video = await resolveAllowedLocalPreviewFile({ + requestedPath: videoPath, + cwd: workspace, + }); + + assert.equal(audio?.path, realpathSync(audioPath)); + assert.equal(video?.path, realpathSync(videoPath)); + }); + it("allows PDFs inside a per-thread scratch workspace without a cwd", async () => { // Sessions that start before a project workspace exists run in // //; files agents create there diff --git a/apps/server/src/localImageFiles.ts b/apps/server/src/localImageFiles.ts index 39e717040..1ce95ee59 100644 --- a/apps/server/src/localImageFiles.ts +++ b/apps/server/src/localImageFiles.ts @@ -1,5 +1,5 @@ // FILE: localImageFiles.ts -// Purpose: Resolves local preview-file (image/PDF) requests without exposing arbitrary files. +// Purpose: Resolves streamed local preview-file (image/PDF/media) requests. // Layer: Server HTTP utility // Exports: local image route constants and allowlisted path resolver // Depends on: fs realpath/stat, Codex generated image roots, safe preview extensions @@ -34,11 +34,15 @@ export interface LocalPreviewGrantResult { const LOCAL_PREVIEW_GRANT_TTL_MS = 2 * 60 * 1000; const localPreviewGrantByToken = new Map(); +const localPreviewGrantTokenByPath = new Map(); function pruneExpiredPreviewGrants(nowMs = Date.now()): void { for (const [token, grant] of localPreviewGrantByToken) { if (grant.expiresAtMs <= nowMs) { localPreviewGrantByToken.delete(token); + if (localPreviewGrantTokenByPath.get(grant.realFilePath) === token) { + localPreviewGrantTokenByPath.delete(grant.realFilePath); + } } } } @@ -216,8 +220,9 @@ export async function createLocalPreviewGrant(input: { } const expiresAtMs = Date.now() + LOCAL_PREVIEW_GRANT_TTL_MS; - const grant = crypto.randomUUID(); - localPreviewGrantByToken.set(grant, { realFilePath, expiresAtMs }); pruneExpiredPreviewGrants(); + const grant = localPreviewGrantTokenByPath.get(realFilePath) ?? crypto.randomUUID(); + localPreviewGrantByToken.set(grant, { realFilePath, expiresAtMs }); + localPreviewGrantTokenByPath.set(realFilePath, grant); return { grant, expiresAt: new Date(expiresAtMs).toISOString() }; } diff --git a/apps/server/src/localImageRoute.test.ts b/apps/server/src/localImageRoute.test.ts index a76d10fc5..dc2568765 100644 --- a/apps/server/src/localImageRoute.test.ts +++ b/apps/server/src/localImageRoute.test.ts @@ -262,6 +262,33 @@ describe("localImageEffectRouteLayer", () => { }); }); + it("streams media byte ranges for Chromium playback and seeking", async () => { + const workspace = makeTempDir("synara-effect-media-workspace-"); + writeFileSync(path.join(workspace, ".git"), "gitdir: .git"); + const mediaPath = path.join(workspace, "demo.mp4"); + writeFileSync(mediaPath, Buffer.from("0123456789")); + const config = makeServerConfig({ cwd: workspace }); + + await withEffectServer(config, localImageEffectRouteLayer, async (origin) => { + const params = new URLSearchParams({ path: mediaPath, cwd: workspace }); + const response = await fetch(`${origin}/api/local-image?${params}`, { + headers: { Range: "bytes=2-5" }, + }); + + expect(response.status).toBe(206); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe("bytes 2-5/10"); + expect(response.headers.get("content-length")).toBe("4"); + expect(await response.text()).toBe("2345"); + + const invalid = await fetch(`${origin}/api/local-image?${params}`, { + headers: { Range: "bytes=99-100" }, + }); + expect(invalid.status).toBe(416); + expect(invalid.headers.get("content-range")).toBe("bytes */10"); + }); + }); + it("does not expose local preview bytes to untrusted web origins through CORS", async () => { const workspace = makeTempDir("synara-effect-pdf-untrusted-origin-"); writeFileSync(path.join(workspace, ".git"), "gitdir: .git"); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7beb47a55..df68e3376 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -332,6 +332,52 @@ describe("ClaudeAdapterLive", () => { harness.getLastCreateQueryInput()?.options.pathToClaudeCodeExecutable, "/managed/claude", ); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.settingSources, [ + "user", + "project", + "local", + ]); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.mcpServers, {}); + assert.equal(harness.getLastCreateQueryInput()?.options.strictMcpConfig, true); + assert.equal( + harness.getLastCreateQueryInput()?.options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, + "false", + ); + assert.equal(harness.getLastCreateQueryInput()?.options.permissionMode, "plan"); + assert.equal(harness.getLastCreateQueryInput()?.options.persistSession, false); + assert.equal(harness.query.closeCalls, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("closes an isolated temporary command query after discovery failure", () => { + const harness = makeHarness(); + ( + harness.query as { + supportedCommands: () => Promise< + Array<{ name: string; description: string; argumentHint: string }> + >; + } + ).supportedCommands = async () => { + throw new Error("simulated command discovery failure"); + }; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + if (!adapter.listCommands) { + return assert.fail("Claude adapter should support command discovery."); + } + + const result = yield* Effect.exit( + adapter.listCommands({ + provider: "claudeAgent", + cwd: "/tmp/claude-command-discovery-failure", + }), + ); + + assert.ok(Exit.isFailure(result)); + assert.equal(harness.query.closeCalls, 1); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -370,6 +416,14 @@ describe("ClaudeAdapterLive", () => { harness.getLastCreateQueryInput()?.options.pathToClaudeCodeExecutable, "/managed/claude-models", ); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.mcpServers, {}); + assert.equal(harness.getLastCreateQueryInput()?.options.strictMcpConfig, true); + assert.equal( + harness.getLastCreateQueryInput()?.options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, + "false", + ); + assert.equal(harness.getLastCreateQueryInput()?.options.permissionMode, "plan"); + assert.equal(harness.getLastCreateQueryInput()?.options.persistSession, false); assert.equal(harness.query.closeCalls, 1); assert.deepEqual(result.models, [ { @@ -415,6 +469,33 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("closes an isolated temporary model query after discovery failure", () => { + const harness = makeHarness(); + (harness.query as { supportedModels: () => Promise }).supportedModels = + async () => { + throw new Error("simulated model discovery failure"); + }; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + if (!adapter.listModels) { + return assert.fail("Claude adapter should support model discovery."); + } + + const result = yield* Effect.exit( + adapter.listModels({ + provider: "claudeAgent", + cwd: "/tmp/claude-model-discovery-failure", + }), + ); + + assert.ok(Exit.isFailure(result)); + assert.equal(harness.query.closeCalls, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -464,27 +545,45 @@ describe("ClaudeAdapterLive", () => { it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "approval-required", - }); + const previousConnectorSetting = process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + const interactiveSessionSentinel = "preserve-for-interactive-session"; + process.env.ENABLE_CLAUDEAI_MCP_SERVERS = interactiveSessionSentinel; - const createInput = harness.getLastCreateQueryInput(); - assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); - assert.equal(createInput?.options.permissionMode, undefined); - assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); - assert.deepEqual(createInput?.options.systemPrompt, { - type: "preset", - preset: "claude_code", - append: [ - "You are running inside Scient, a scientific workspace that embeds the Claude Agent SDK.", - "Do not present the host app as Claude Code unless the user is explicitly asking about Claude Code.", - "Treat the current working directory as the active workspace for the task.", - "When the user asks about the current project, codebase, or repository, proactively inspect files in the current working directory before asking the user where to look.", - ].join("\n"), - }); + try { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "approval-required", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); + assert.equal(createInput?.options.permissionMode, undefined); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.equal(createInput?.options.strictMcpConfig, undefined); + assert.equal(createInput?.options.mcpServers, undefined); + assert.equal( + createInput?.options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, + interactiveSessionSentinel, + ); + assert.deepEqual(createInput?.options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: [ + "You are running inside Scient, a scientific workspace that embeds the Claude Agent SDK.", + "Do not present the host app as Claude Code unless the user is explicitly asking about Claude Code.", + "Treat the current working directory as the active workspace for the task.", + "When the user asks about the current project, codebase, or repository, proactively inspect files in the current working directory before asking the user where to look.", + ].join("\n"), + }); + } finally { + if (previousConnectorSetting === undefined) { + delete process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + } else { + process.env.ENABLE_CLAUDEAI_MCP_SERVERS = previousConnectorSetting; + } + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2050b245a..34388ea1f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -88,6 +88,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { buildIsolatedClaudeDiscoveryOptions } from "../claudeDiscoveryIsolation.ts"; import { buildFileAttachmentsPromptBlock } from "../attachmentProjection.ts"; import { readProviderPromptImage } from "../promptAttachments.ts"; import { buildClaudeProcessEnv } from "../claudeProcessEnv.ts"; @@ -4295,14 +4296,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { // subprocess handshake). We iterate in the background to unblock it. const tempQuery = createQuery({ prompt: neverResolvingUserMessageStream(), - options: { + options: buildIsolatedClaudeDiscoveryOptions({ cwd, pathToClaudeCodeExecutable: binaryPath, - settingSources: [...CLAUDE_SETTING_SOURCES], permissionMode: "plan" as PermissionMode, - persistSession: false, env: claudeSdkEnvForExecutable(env, binaryPath), - }, + }), }); try { @@ -4329,14 +4328,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ): Promise { const tempQuery = createQuery({ prompt: neverResolvingUserMessageStream(), - options: { + options: buildIsolatedClaudeDiscoveryOptions({ cwd, pathToClaudeCodeExecutable: binaryPath, - settingSources: [...CLAUDE_SETTING_SOURCES], permissionMode: "plan" as PermissionMode, - persistSession: false, env: claudeSdkEnvForExecutable(env, binaryPath), - }, + }), }); try { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index cdf71db49..f8c194fd8 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -476,6 +476,84 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("shows Codex web-search queries in canonical tool details", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-web-search-complete"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("web-search-1"), + payload: { + item: { + type: "webSearch", + id: "web-search-1", + action: { + queries: ["latest OpenAI API documentation"], + url: "https://platform.openai.com/docs", + }, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "web_search"); + assert.equal(firstEvent.value.payload.title, "Web search"); + assert.equal(firstEvent.value.payload.detail, "latest OpenAI API documentation"); + }), + ); + + it.effect( + "ignores malformed Codex web-search detail fields without breaking event ingestion", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-web-search-malformed"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("web-search-malformed"), + payload: { + item: { + type: "webSearch", + id: "web-search-malformed", + query: { text: "must not be coerced" }, + action: { + query: 42, + queries: [{ text: "must not be coerced" }, null, "safe query"], + pattern: false, + url: { href: "https://example.test" }, + }, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "web_search"); + assert.equal(firstEvent.value.payload.detail, "safe query"); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 36667675e..bace7d519 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -327,11 +327,17 @@ function reasoningSummaryDetail(item: Record): string | undefin } function itemDetail( + itemType: CanonicalItemType, item: Record, payload: Record, ): string | undefined { const nestedResult = asObject(item.result); + const action = asObject(item.action); + const actionQueries = Array.isArray(action?.queries) ? action.queries : []; const candidates = [ + ...(itemType === "web_search" + ? [item.query, action?.query, ...actionQueries, action?.pattern, action?.url].map(asString) + : []), asString(item.command), asString(item.title), asString(item.summary), @@ -806,7 +812,9 @@ function mapItemLifecycle( // Only the provider-authored summary is user-visible reasoning. Raw content // may contain model trace data and must not leak into transcript activities. const detail = - itemType === "reasoning" ? reasoningSummaryDetail(source) : itemDetail(source, payload ?? {}); + itemType === "reasoning" + ? reasoningSummaryDetail(source) + : itemDetail(itemType, source, payload ?? {}); const status = itemStatus(lifecycle, source.status); return { @@ -1172,7 +1180,7 @@ function mapToRuntimeEvents( } const itemType = source ? toCanonicalItemType(source.type ?? source.kind) : "unknown"; if (itemType === "plan") { - const detail = itemDetail(source, payload ?? {}); + const detail = itemDetail(itemType, source, payload ?? {}); if (!detail) { return []; } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3adad2ee1..6e6534cca 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,7 +1,7 @@ -import { ThreadId } from "@synara/contracts"; +import { ApprovalRequestId, ThreadId } from "@synara/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { Agent, Model, OpencodeClient, Part, Provider } from "@opencode-ai/sdk/v2"; -import { Effect, Fiber, Layer, Stream } from "effect"; +import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { describe, it, expect } from "vitest"; import { ServerConfig } from "../../config.ts"; @@ -10,13 +10,17 @@ import { OpenCodeRuntimeError, type OpenCodeInventory, type OpenCodeRuntimeShape, + type OpenCodeServerConnection, } from "../opencodeRuntime.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { KiloAdapter, type KiloAdapterShape } from "../Services/KiloAdapter.ts"; import { flattenOpenCodeCliModels, flattenOpenCodeModels, makeOpenCodeAdapterLive, + makeKiloAdapterLive, normalizeOpenCodeTokenUsage, + resolveOpenCodeTurnIdleTimeoutMs, resolvePreferredOpenCodeModelProviders, } from "./OpenCodeAdapter.ts"; @@ -112,23 +116,50 @@ function createMockOpenCodeRuntime(options?: { readonly events?: AsyncIterable; readonly prompt?: (input: Record) => Promise; readonly promptAsync?: (input: Record) => Promise; + readonly create?: (input: Record, callIndex: number) => Promise; + readonly update?: (input: Record) => Promise; readonly commandList?: () => Promise<{ data?: ReadonlyArray<{ name: string; description?: string }>; }>; readonly commandLists?: ReadonlyArray>; - readonly messages?: () => Promise<{ + readonly messages?: ( + input?: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => Promise<{ data: Array<{ info: Record; parts: Part[] }>; }>; + readonly status?: ( + input?: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => Promise<{ data: Record }>; + readonly abort?: ( + input: { sessionID: string }, + requestOptions?: { readonly signal?: AbortSignal }, + ) => Promise; + readonly permissionReply?: ( + input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => Promise; + readonly questionReply?: ( + input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => Promise; readonly session?: Record; + readonly sessionIds?: ReadonlyArray; + readonly connection?: OpenCodeServerConnection; }) { const abortCalls: Array<{ sessionID: string }> = []; + const abortRequestSignals: Array = []; const cliModelCalls: Array[0]> = []; const connectCalls: Array[0]> = []; const createCalls: Array> = []; const updateCalls: Array> = []; const forkCalls: Array<{ sessionID: string }> = []; const permissionReplyCalls: Array> = []; + const questionReplyCalls: Array> = []; const promptCalls: Array> = []; + const messageCalls: Array | undefined> = []; + let sessionCreateCallCount = 0; const emptySubscription = { async *[Symbol.asyncIterator]() { // No provider-side events needed for these adapter lifecycle tests. @@ -141,10 +172,20 @@ function createMockOpenCodeRuntime(options?: { session: { create: async (input: Record) => { createCalls.push(input); - return { data: { id: "opencode-session-1" } }; + if (options?.create) { + const result = await options.create(input, sessionCreateCallCount); + sessionCreateCallCount += 1; + return result; + } + const id = options?.sessionIds?.[sessionCreateCallCount] ?? "opencode-session-1"; + sessionCreateCallCount += 1; + return { data: { id } }; }, update: async (input: Record) => { updateCalls.push(input); + if (options?.update) { + return options.update(input); + } return { data: null }; }, promptAsync: async (promptInput: Record) => { @@ -161,11 +202,28 @@ function createMockOpenCodeRuntime(options?: { } return { data: null }; }, - abort: async (input: { sessionID: string }) => { + abort: async ( + input: { sessionID: string }, + requestOptions?: { readonly signal?: AbortSignal }, + ) => { abortCalls.push(input); + abortRequestSignals.push(requestOptions?.signal); + if (options?.abort) { + return options.abort(input, requestOptions); + } return { data: null }; }, - messages: options?.messages ?? (async () => ({ data: [] })), + messages: async ( + input?: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => { + messageCalls.push(input); + return options?.messages?.(input, requestOptions) ?? { data: [] }; + }, + status: async ( + input?: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => options?.status?.(input, requestOptions) ?? { data: {} }, get: async () => ({ data: { directory: process.cwd(), ...(options?.session ?? {}) } }), revert: async () => ({ data: null }), summarize: async () => ({ data: null }), @@ -175,13 +233,28 @@ function createMockOpenCodeRuntime(options?: { }, }, permission: { - reply: async (input: Record) => { + reply: async ( + input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => { permissionReplyCalls.push(input); + if (options?.permissionReply) { + return options.permissionReply(input, requestOptions); + } return { data: null }; }, }, question: { - reply: async () => ({ data: null }), + reply: async ( + input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => { + questionReplyCalls.push(input); + if (options?.questionReply) { + return options.questionReply(input, requestOptions); + } + return { data: null }; + }, }, command: { list: options?.commandList ?? (async () => ({ data: [] })), @@ -219,11 +292,13 @@ function createMockOpenCodeRuntime(options?: { if (options?.connectError) { return yield* options.connectError; } - return { - url: input.serverUrl ?? "http://127.0.0.1:4099", - exitCode: null, - external: Boolean(input.serverUrl), - }; + return ( + options?.connection ?? { + url: input.serverUrl ?? "http://127.0.0.1:4099", + exitCode: null, + external: Boolean(input.serverUrl), + } + ); }), runOpenCodeCommand: () => unexpectedOperation("runOpenCodeCommand"), createOpenCodeSdkClient, @@ -250,17 +325,57 @@ function createMockOpenCodeRuntime(options?: { return { abortCalls, + abortRequestSignals, cliModelCalls, connectCalls, createCalls, updateCalls, forkCalls, permissionReplyCalls, + questionReplyCalls, promptCalls, + messageCalls, runtime, }; } +function pushActivePromptEcho( + eventQueue: { readonly push: (event: unknown) => void }, + runtime: ReturnType, + promptIndex = runtime.promptCalls.length - 1, +): string { + const promptMessageId = runtime.promptCalls[promptIndex]?.messageID; + if (typeof promptMessageId !== "string") { + throw new Error(`Expected prompt call ${promptIndex} to contain a messageID.`); + } + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { id: promptMessageId, role: "user" }, + }, + }); + return promptMessageId; +} + +function pushActiveAssistantOwnership( + eventQueue: { readonly push: (event: unknown) => void }, + runtime: ReturnType, + messageId: string, +): void { + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: messageId, + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); +} + function createSubscribedEventQueue() { const pendingEvents: Array = []; let waitingResolver: ((result: IteratorResult) => void) | undefined; @@ -310,6 +425,40 @@ function createSubscribedEventQueue() { }; } +function createBroadcastSubscribedEventQueue() { + const subscribers: Array> = []; + return { + subscribe() { + const subscriber = createSubscribedEventQueue(); + subscribers.push(subscriber); + return subscriber.stream; + }, + push(event: unknown) { + for (const subscriber of subscribers) { + subscriber.push(event); + } + }, + close() { + for (const subscriber of subscribers) { + subscriber.close(); + } + }, + }; +} + +function bindSubscribedEventQueue( + runtime: ReturnType, + eventQueue: ReturnType, +): void { + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { subscribe: () => Promise<{ stream: AsyncIterable }> }; + }; + client.event.subscribe = async () => ({ stream: eventQueue.stream }); +} + function makeInventoryWithContextLimit(input: { readonly providerId?: string; readonly modelId?: string; @@ -346,6 +495,7 @@ function makeInventoryWithContextLimit(input: { function assistantMessageUpdated(input?: { readonly id?: string; + readonly parentID?: string; readonly tokens?: { readonly input: number; readonly output: number; @@ -364,6 +514,7 @@ function assistantMessageUpdated(input?: { info: { id: input?.id ?? "assistant-message-usage", role: "assistant", + ...(input?.parentID ? { parentID: input.parentID } : {}), tokens: input?.tokens ?? { input: 120, output: 80, @@ -484,6 +635,37 @@ describe("normalizeOpenCodeTokenUsage", () => { }); }); +describe("resolveOpenCodeTurnIdleTimeoutMs", () => { + it("uses separate ten-minute defaults for OpenCode and Kilo", () => { + expect(resolveOpenCodeTurnIdleTimeoutMs("opencode", {})).toBe(600_000); + expect(resolveOpenCodeTurnIdleTimeoutMs("kilo", {})).toBe(600_000); + }); + + it("uses only the selected provider's positive override", () => { + const env = { + SYNARA_OPENCODE_TURN_IDLE_TIMEOUT_MS: " 700000 ", + SYNARA_KILO_TURN_IDLE_TIMEOUT_MS: "800000", + }; + expect(resolveOpenCodeTurnIdleTimeoutMs("opencode", env)).toBe(700_000); + expect(resolveOpenCodeTurnIdleTimeoutMs("kilo", env)).toBe(800_000); + }); + + it("falls back for invalid, blank, or zero overrides", () => { + for (const value of ["", "soon", "0", "-1"]) { + expect( + resolveOpenCodeTurnIdleTimeoutMs("opencode", { + SYNARA_OPENCODE_TURN_IDLE_TIMEOUT_MS: value, + }), + ).toBe(600_000); + expect( + resolveOpenCodeTurnIdleTimeoutMs("kilo", { + SYNARA_KILO_TURN_IDLE_TIMEOUT_MS: value, + }), + ).toBe(600_000); + } + }); +}); + describe("resolvePreferredOpenCodeModelProviders", () => { it("keeps explicit credential providers and OpenCode-managed providers together", () => { const providers = resolvePreferredOpenCodeModelProviders({ @@ -1379,6 +1561,65 @@ describe("OpenCodeAdapter runtime lifecycle", () => { ]); }); + it("fails closed when approval-required rules cannot be applied to a resumed session", async () => { + const runtime = createMockOpenCodeRuntime({ + update: async () => Promise.reject(new Error("permission update unsupported")), + }); + + await expect( + Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-resume-approval-required"), + runtimeMode: "approval-required", + resumeCursor: { openCodeSessionId: "previously-permissive", cwd: "/repo/resume" }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ), + ).rejects.toThrow("permission update unsupported"); + expect(runtime.createCalls).toEqual([]); + }); + + it("retains older-server compatibility for full-access resumed sessions", async () => { + const runtime = createMockOpenCodeRuntime({ + update: async () => Promise.reject(new Error("permission update unsupported")), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + return yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-resume-full-access-compat"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "existing-session-1", cwd: "/repo/resume" }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.resumeCursor).toMatchObject({ openCodeSessionId: "existing-session-1" }); + }); + it("declines inactive OpenCode native fork when source and target cwd differ", async () => { const runtime = createMockOpenCodeRuntime(); @@ -1721,6 +1962,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { info: { id: "assistant-message-1", role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, }, }, }); @@ -1834,6 +2076,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { info: { id: "assistant-message-filtered", role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, }, }, }); @@ -1973,6 +2216,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { info: { id: "assistant-message-plan", role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, }, }, }); @@ -2252,6 +2496,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { info: { id: "assistant-message-default-plan", role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, }, }, }); @@ -2341,7 +2586,9 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }, }); - eventQueue.push(assistantMessageUpdated()); + eventQueue.push( + assistantMessageUpdated({ parentID: runtime.promptCalls.at(-1)?.messageID as string }), + ); const events = Array.from(yield* Fiber.join(eventsFiber)); eventQueue.close(); @@ -2422,8 +2669,9 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }, }); - eventQueue.push(assistantMessageUpdated()); - eventQueue.push(assistantMessageUpdated()); + const parentID = runtime.promptCalls.at(-1)?.messageID as string; + eventQueue.push(assistantMessageUpdated({ parentID })); + eventQueue.push(assistantMessageUpdated({ parentID })); const runtimeEvents = Array.from(yield* Fiber.join(eventsFiber)); eventQueue.close(); @@ -2479,7 +2727,9 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }, }); - eventQueue.push(assistantMessageUpdated()); + eventQueue.push( + assistantMessageUpdated({ parentID: runtime.promptCalls.at(-1)?.messageID as string }), + ); const runtimeEvents = Array.from(yield* Fiber.join(eventsFiber)); eventQueue.close(); @@ -2549,6 +2799,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { eventQueue.push( assistantMessageUpdated({ + parentID: runtime.promptCalls.at(-1)?.messageID as string, tokens: { input: 0, output: 0, @@ -2563,6 +2814,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { eventQueue.push( assistantMessageUpdated({ id: "assistant-message-malformed", + parentID: runtime.promptCalls.at(-1)?.messageID as string, tokens: { input: Number.NaN, output: 1, @@ -2705,6 +2957,18 @@ describe("OpenCodeAdapter runtime lifecycle", () => { model: "openai/gpt-5.4", }, }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-next-events-owner", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); eventQueue.push({ id: "evt-next-text-delta", @@ -2783,6 +3047,62 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }); }); + it("bounds provider event-id deduplication while suppressing recent replays", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 7)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-bounded-event-dedupe"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + for (const [id, content] of [ + ["dedupe-1", "one"], + ["dedupe-2", "two"], + ["dedupe-3", "three"], + ["dedupe-3", "three replay"], + ["dedupe-1", "one after eviction"], + ] as const) { + eventQueue.push({ + id, + type: "todo.updated", + properties: { + sessionID: "opencode-session-1", + todos: [{ content, status: "pending", priority: "low" }], + }, + }); + } + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return collected; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime, eventDedupeLimit: 2 }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + const taskEvents = events.filter((event) => event.type === "turn.tasks.updated"); + expect(taskEvents).toHaveLength(4); + expect(JSON.stringify(taskEvents)).not.toContain("three replay"); + expect(JSON.stringify(taskEvents)).toContain("one after eviction"); + }); + it("auto-approves OpenCode permission asks in full access without surfacing approvals", async () => { const eventQueue = createSubscribedEventQueue(); const runtime = createMockOpenCodeRuntime(); @@ -2818,6 +3138,8 @@ describe("OpenCodeAdapter runtime lifecycle", () => { model: "openai/gpt-5.4", }, }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-permission-owner"); eventQueue.push({ id: "evt-permission-asked", @@ -2904,6 +3226,109 @@ describe("OpenCodeAdapter runtime lifecycle", () => { expect(runtime.permissionReplyCalls).toEqual([{ requestID: "permission-1", reply: "always" }]); }); + it("bounds a hung full-access auto-approval without poisoning the event pump", async () => { + const eventQueue = createSubscribedEventQueue(); + let replySignalAborted = false; + const runtime = createMockOpenCodeRuntime({ + permissionReply: (_input, requestOptions) => + new Promise((_resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => { + replySignalAborted = true; + reject(new Error("auto-approval aborted")); + }, + { once: true }, + ); + }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-hung-full-access-auto-approval"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-hung-auto-approval"); + eventQueue.push({ + type: "permission.asked", + properties: { + id: "permission-hung-auto-approval", + sessionID: "opencode-session-1", + permission: "external_directory", + patterns: ["/outside/project/**"], + metadata: {}, + always: [], + }, + }); + yield* Effect.sleep(35); + const [failed] = yield* adapter.listSessions(); + const successor = yield* adapter.sendTurn({ + threadId, + input: "successor", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-after-hung-auto-approval"); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 30, + sessionID: "opencode-session-1", + text: "event pump recovered", + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 31, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(30); + const [settled] = yield* adapter.listSessions(); + eventQueue.close(); + return { abortCallCount: runtime.abortCalls.length, failed, settled, successor }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + turnStallTimeoutMs: 100, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(replySignalAborted).toBe(true); + expect(result.failed?.status).toBe("error"); + expect(result.settled).toMatchObject({ + status: "ready", + }); + expect(result.abortCallCount).toBe(1); + }); + it("suppresses a permission.replied echo that arrives after turn teardown", async () => { const eventQueue = createSubscribedEventQueue(); const runtime = createMockOpenCodeRuntime(); @@ -2920,9 +3345,12 @@ describe("OpenCodeAdapter runtime lifecycle", () => { const result = await Effect.runPromise( Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; - const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 7)).pipe( - Effect.forkChild, - ); + const observedEvents: Array<{ readonly type: string }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); yield* adapter.startSession({ provider: "opencode", @@ -2939,6 +3367,8 @@ describe("OpenCodeAdapter runtime lifecycle", () => { model: "openai/gpt-5.4", }, }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-late-permission-owner"); // Auto-approved ask at the tail of the turn; the reply echo has not arrived yet // when the turn completes and active-turn state is torn down. @@ -3002,8 +3432,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { reply: "always", }, }); - // A queued question flushes the stream: the queue is FIFO, so its - // user-input.requested must be the next event, proving the late echo emitted nothing. + // A question after teardown is stale authority too and must also be ignored. eventQueue.push({ id: "evt-question-asked", type: "question.asked", @@ -3023,9 +3452,10 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }, }); - const events = Array.from(yield* Fiber.join(eventsFiber)); + yield* Effect.sleep(20); + yield* Fiber.interrupt(eventsFiber); eventQueue.close(); - return events; + return observedEvents; }).pipe( Effect.provide( makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( @@ -3045,7 +3475,6 @@ describe("OpenCodeAdapter runtime lifecycle", () => { "content.delta", "item.completed", "turn.completed", - "user-input.requested", ]); expect(runtime.permissionReplyCalls).toEqual([ { requestID: "permission-late-1", reply: "always" }, @@ -3088,6 +3517,8 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }, }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ id: "evt-permission-asked", type: "permission.asked", @@ -3132,41 +3563,231 @@ describe("OpenCodeAdapter runtime lifecycle", () => { expect(runtime.permissionReplyCalls).toEqual([]); }); - it("keeps newer OpenCode tool-call steps attached to the active turn", async () => { - const eventQueue = createSubscribedEventQueue(); - const runtime = createMockOpenCodeRuntime(); - const client = runtime.runtime.createOpenCodeSdkClient({ - baseUrl: "http://127.0.0.1:4099", - directory: process.cwd(), - }) as unknown as { - event: { - subscribe: () => Promise<{ stream: AsyncIterable }>; - }; - }; - client.event.subscribe = async () => ({ stream: eventQueue.stream }); + for (const runtimeMode of ["approval-required", "full-access"] as const) { + for (const interaction of ["permission", "question"] as const) { + it(`fences ${runtimeMode} ${interaction} events until the exact prompt echo`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => + observedEvents.push({ type: event.type, requestId: event.requestId }), + ), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-${runtimeMode}-${interaction}-boundary`); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode }); + yield* adapter.sendTurn({ + threadId, + input: "boundary", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); - const result = await Effect.runPromise( - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe( - Effect.forkChild, + const pushInteraction = (suffix: string) => + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: `${interaction}-${suffix}`, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: `${interaction}-${suffix}`, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + + pushInteraction("before"); + yield* Effect.sleep(10); + const before = { + events: observedEvents.filter((event) => event.requestId?.endsWith("before")), + permissionCalls: runtime.permissionReplyCalls.length, + }; + pushActivePromptEcho(eventQueue, runtime); + pushInteraction("after"); + yield* Effect.sleep(20); + const after = { + events: observedEvents.filter((event) => event.requestId?.endsWith("after")), + permissionCalls: runtime.permissionReplyCalls.length, + }; + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { after, before }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), ); - yield* adapter.startSession({ - provider: "opencode", - threadId: asThreadId("thread-next-tool-call"), - runtimeMode: "full-access", - }); + expect(result.before.events).toHaveLength(0); + expect(result.before.permissionCalls).toBe(0); + if (runtimeMode === "full-access" && interaction === "permission") { + expect(result.after.events).toHaveLength(0); + expect(result.after.permissionCalls).toBe(1); + } else { + expect(result.after.events).toHaveLength(1); + expect(result.after.permissionCalls).toBe(0); + } + }); + } + } - yield* adapter.sendTurn({ - threadId: asThreadId("thread-next-tool-call"), - input: "inspect files", - attachments: [], - modelSelection: { + for (const interaction of ["permission", "question"] as const) { + it(`uses ${interaction} tool.messageID to reject stale tool authority`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const requestIds = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedRequestIds: string[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + if (event.requestId) observedRequestIds.push(event.requestId); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-${interaction}-tool-message-owner`); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "tool owner", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-current-tool-owner"); + + const pushInteraction = (requestId: string, messageID: string) => + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + tool: { messageID, callID: `call-${requestId}` }, + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + tool: { messageID, callID: `call-${requestId}` }, + }, + }, + ); + + pushInteraction(`${interaction}-stale-tool`, "msg-previous-generation"); + pushInteraction(`${interaction}-current-tool`, "msg-current-tool-owner"); + yield* Effect.sleep(20); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return observedRequestIds; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(requestIds).not.toContain(`${interaction}-stale-tool`); + expect(requestIds).toContain(`${interaction}-current-tool`); + }); + } + + it("keeps newer OpenCode tool-call steps attached to the active turn", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { + subscribe: () => Promise<{ stream: AsyncIterable }>; + }; + }; + client.event.subscribe = async () => ({ stream: eventQueue.stream }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe( + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-next-tool-call"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: asThreadId("thread-next-tool-call"), + input: "inspect files", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4", }, }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-next-tool-owner"); eventQueue.push({ id: "evt-next-step-tool-calls", @@ -3270,6 +3891,8 @@ describe("OpenCodeAdapter runtime lifecycle", () => { model: "openai/gpt-5.4", }, }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "assistant-message-1"); eventQueue.push({ type: "message.part.updated", @@ -3400,6 +4023,8 @@ describe("OpenCodeAdapter runtime lifecycle", () => { model: "openai/gpt-5.4", }, }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-next-shell-owner"); eventQueue.push({ id: "evt-next-shell-started", @@ -3581,12 +4206,13 @@ describe("OpenCodeAdapter runtime lifecycle", () => { "session.started", "thread.started", "turn.started", - "turn.aborted", + "turn.completed", ]); expect(result.events[3]).toMatchObject({ - type: "turn.aborted", + type: "turn.completed", payload: { - reason: "prompt rejected", + state: "failed", + errorMessage: "prompt rejected", }, }); }); @@ -3634,6 +4260,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { info: { id: "msg-session-idle", role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, }, }, }); @@ -3685,4 +4312,6952 @@ describe("OpenCodeAdapter runtime lifecycle", () => { "turn.completed", ]); }); + + it("completes after an early idle when final assistant events arrive without a second idle", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { subscribe: () => Promise<{ stream: AsyncIterable }> }; + }; + client.event.subscribe = async () => ({ stream: eventQueue.stream }); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-early-idle-no-repeat"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-early-idle-no-repeat", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-early-idle-no-repeat", + messageID: "msg-early-idle-no-repeat", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + }, + }, + }); + + yield* Effect.sleep(75); + const [currentSession] = yield* adapter.listSessions(); + eventQueue.close(); + return currentSession; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("ready"); + expect(session?.activeTurnId).toBeUndefined(); + }); + + it("waits for a late text part when final metadata arrives before idle", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async () => { + messageFetchCount += 1; + return { + data: + messageFetchCount === 1 + ? [] + : [ + { + info: { + id: "msg-final-before-parts", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + parts: [], + }, + ], + }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 6)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-final-before-parts"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-final-before-parts", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + + yield* Effect.sleep(30); + const [sessionBeforeLatePart] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-final-before-parts", + messageID: "msg-final-before-parts", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + }, + }, + }); + + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return { collected, sessionBeforeLatePart }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events.sessionBeforeLatePart?.status).toBe("running"); + expect(events.collected.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.completed", + "turn.completed", + ]); + expect(messageFetchCount).toBeGreaterThanOrEqual(2); + }); + + it("waits for every final-message text part before completing a plan turn", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 9)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-late-multipart-plan"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-late-multipart-plan", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 3 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-late-multipart-preamble", + messageID: "msg-late-multipart-plan", + type: "text", + text: "I prepared the implementation plan.", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-late-multipart-plan", + messageID: "msg-late-multipart-plan", + type: "text", + text: "\n# Complete plan\n\n- implement it\n", + time: { start: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + + yield* Effect.sleep(10); + const [sessionBeforePlanSettled] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-late-multipart-plan", + messageID: "msg-late-multipart-plan", + type: "text", + text: "\n# Complete plan\n\n- implement it\n", + time: { start: 2, end: 3 }, + }, + }, + }); + + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return { collected, sessionBeforePlanSettled }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events.sessionBeforePlanSettled?.status).toBe("running"); + expect(events.collected.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.completed", + "content.delta", + "turn.proposed.completed", + "item.completed", + "turn.completed", + ]); + expect(events.collected[6]).toMatchObject({ + type: "turn.proposed.completed", + payload: { planMarkdown: "# Complete plan\n\n- implement it" }, + }); + }); + + it("does not recover a snapshot until every visible assistant text part is complete", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageSnapshot: Array<{ info: Record; parts: Part[] }> = []; + const runtime = createMockOpenCodeRuntime({ + messages: async () => ({ data: messageSnapshot }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 9)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-mixed-final-snapshot"); + const preamblePart = { + id: "part-mixed-final-preamble", + messageID: "msg-mixed-final-snapshot", + type: "text", + text: "I prepared the implementation plan.", + time: { start: 1, end: 2 }, + } as Part; + const planText = "\n# Snapshot plan\n\n- implement it\n"; + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + const finalInfo = { + id: "msg-mixed-final-snapshot", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 3 }, + }; + messageSnapshot = [ + { + info: finalInfo, + parts: [ + preamblePart, + { + id: "part-mixed-final-plan", + messageID: "msg-mixed-final-snapshot", + type: "text", + text: planText, + time: { start: 2 }, + } as Part, + ], + }, + ]; + eventQueue.push({ + type: "message.updated", + properties: { sessionID: "opencode-session-1", info: finalInfo }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + + yield* Effect.sleep(30); + const [sessionWhilePlanPartOpen] = yield* adapter.listSessions(); + messageSnapshot = [ + { + info: finalInfo, + parts: [ + preamblePart, + { + id: "part-mixed-final-plan", + messageID: "msg-mixed-final-snapshot", + type: "text", + text: planText, + time: { start: 2, end: 3 }, + } as Part, + ], + }, + ]; + + const events = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return { events, sessionWhilePlanPartOpen }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.sessionWhilePlanPartOpen?.status).toBe("running"); + expect(result.events.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.completed", + "content.delta", + "turn.proposed.completed", + "item.completed", + "turn.completed", + ]); + }); + + it("does not let stale snapshot recovery complete a newer turn", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + let resolveRecovery: + | ((value: { data: Array<{ info: Record; parts: Part[] }> }) => void) + | undefined; + const runtime = createMockOpenCodeRuntime({ + messages: async () => { + messageFetchCount += 1; + if (messageFetchCount !== 2) { + return { data: [] }; + } + return await new Promise((resolve) => { + resolveRecovery = resolve; + }); + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ readonly type: string }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-stale-recovery"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-stale-recovery", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + + for (let attempt = 0; attempt < 50 && resolveRecovery === undefined; attempt += 1) { + yield* Effect.sleep(2); + } + const recoveryWasStarted = resolveRecovery !== undefined; + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* Effect.sync(() => { + resolveRecovery?.({ + data: [ + { + info: { + id: "msg-stale-recovery", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + parts: [ + { + id: "part-stale-recovery", + messageID: "msg-stale-recovery", + type: "text", + text: "obsolete", + time: { start: 1, end: 2 }, + } as Part, + ], + }, + ], + }); + }); + yield* Effect.sleep(10); + + const [session] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { events: observedEvents, recoveryWasStarted, secondTurn, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.events.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "turn.aborted", + "turn.started", + ]); + expect(result.recoveryWasStarted).toBe(true); + expect(result.session).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + }); + + it("fails closed after bounded idle recovery when no assistant response arrives", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-idle-without-assistant"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const [session] = yield* adapter.listSessions(); + eventQueue.close(); + return { events, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.events.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "turn.completed", + "runtime.error", + ]); + expect(result.events[3]).toMatchObject({ + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "OpenCode became idle before producing an assistant response.", + }, + }); + expect(result.session).toMatchObject({ + status: "error", + lastError: "OpenCode became idle before producing an assistant response.", + }); + expect(result.session?.activeTurnId).toBeUndefined(); + }); + + it("fails closed when final metadata never receives a completed visible text part", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-final-metadata-without-parts"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-final-metadata-without-parts", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return collected; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events[3]).toMatchObject({ + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "OpenCode became idle before its final assistant response finished arriving.", + }, + }); + expect(events[4]).toMatchObject({ + type: "runtime.error", + payload: { + message: "OpenCode became idle before its final assistant response finished arriving.", + class: "provider_error", + }, + }); + }); + + it("emits one completion when duplicate idle signals race", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 6)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-duplicate-idle"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-duplicate-idle", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-duplicate-idle", + messageID: "msg-duplicate-idle", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "session.status", + properties: { sessionID: "opencode-session-1", status: { type: "idle" } }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + + const collected = Array.from(yield* Fiber.join(eventsFiber)); + const extraEvent = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.timeoutOption(20), + ); + eventQueue.close(); + return { collected, extraEvent }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.collected.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect(result.extraEvent._tag).toBe("None"); + }); + + it("bounds a hung completion snapshot request and completes from settled local parts", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async () => { + messageFetchCount += 1; + if (messageFetchCount === 1) { + return { data: [] }; + } + return await new Promise<{ + data: Array<{ info: Record; parts: Part[] }>; + }>(() => undefined); + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 6)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-hung-completion-snapshot"); + + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-hung-completion-snapshot", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-hung-completion-snapshot", + messageID: "msg-hung-completion-snapshot", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + }, + }, + }); + + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return collected; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + completionSnapshotTimeoutMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.completed", + "turn.completed", + ]); + expect(messageFetchCount).toBeGreaterThanOrEqual(2); + }); + + it("restarts the quiet window when text arrives during a completion snapshot", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + let startRecovery: (() => void) | undefined; + let resolveRecovery: ((value: { data: [] }) => void) | undefined; + const recoveryStarted = new Promise((resolve) => { + startRecovery = resolve; + }); + const runtime = createMockOpenCodeRuntime({ + messages: async () => { + messageFetchCount += 1; + if (messageFetchCount === 1) { + return { data: [] }; + } + if (messageFetchCount === 2) { + startRecovery?.(); + return await new Promise<{ data: [] }>((resolve) => { + resolveRecovery = resolve; + }); + } + return { data: [] }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-activity-during-snapshot"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-activity-during-snapshot", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 3 }, + }, + }, + }); + yield* Effect.promise(() => recoveryStarted); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-activity-during-snapshot", + messageID: "msg-activity-during-snapshot", + type: "text", + text: "still arriving", + time: { start: 1 }, + }, + }, + }); + yield* Effect.sync(() => resolveRecovery?.({ data: [] })); + yield* Effect.sleep(5); + const [duringQuiet] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-activity-during-snapshot", + messageID: "msg-activity-during-snapshot", + type: "text", + text: "finished", + time: { start: 1, end: 3 }, + }, + }, + }); + yield* Effect.sleep(60); + const [settled] = yield* adapter.listSessions(); + eventQueue.close(); + return { duringQuiet, settled }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + completionSnapshotTimeoutMs: 50, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.duringQuiet?.status).toBe("running"); + expect(result.settled?.status).toBe("ready"); + }); + + it("defers idle after partial text until the same assistant message settles", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-partial-before-idle"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-partial-before-idle", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-partial-before-idle", + messageID: "msg-partial-before-idle", + type: "text", + text: "part", + time: { start: 1 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(5); + const [whilePartial] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-partial-before-idle", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-partial-before-idle", + messageID: "msg-partial-before-idle", + type: "text", + text: "partial complete", + time: { start: 1, end: 2 }, + }, + }, + }); + yield* Effect.sleep(60); + const [settled] = yield* adapter.listSessions(); + eventQueue.close(); + return { whilePartial, settled }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.whilePartial?.status).toBe("running"); + expect(result.settled?.status).toBe("ready"); + }); + + it("completes reordered metadata-light assistant output after an early idle", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-metadata-light-early-idle"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-metadata-light-early-idle", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-metadata-light-early-idle", + messageID: "msg-metadata-light-early-idle", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + }, + }, + }); + yield* Effect.sleep(60); + const [current] = yield* adapter.listSessions(); + eventQueue.close(); + return current; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("ready"); + expect(session?.activeTurnId).toBeUndefined(); + }); + + it("fails closed instead of rebinding a previous assistant message and idle", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-live-events"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-stale-live-events", + role: "assistant", + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + yield* Effect.sleep(1); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-stale-live-events", + role: "assistant", + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-stale-live-events", + messageID: "msg-stale-live-events", + type: "text", + text: "obsolete", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(50); + const [session] = yield* adapter.listSessions(); + eventQueue.close(); + return { secondTurn, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.session?.status).toBe("error"); + expect(result.session?.activeTurnId).toBeUndefined(); + expect(result.session?.lastError).toContain("before producing an assistant response"); + }); + + for (const provider of ["opencode", "kilo"] as const) { + it(`bounds an unavailable pre-turn message baseline for ${provider}`, async () => { + const runtime = createMockOpenCodeRuntime({ + messages: async () => + await new Promise<{ + data: Array<{ info: Record; parts: Part[] }>; + }>(() => undefined), + }); + + const runTurn = + provider === "kilo" + ? Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const threadId = asThreadId(`thread-hung-baseline-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const startedAt = Date.now(); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + const elapsedMs = Date.now() - startedAt; + const [session] = yield* adapter.listSessions(); + return { elapsedMs, session }; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: `${provider}-adapter-test-`, + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ) + : Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-hung-baseline-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const startedAt = Date.now(); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + const elapsedMs = Date.now() - startedAt; + const [session] = yield* adapter.listSessions(); + return { elapsedMs, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: `${provider}-adapter-test-`, + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + const result = await Effect.runPromise(runTurn); + + expect(result.elapsedMs).toBeLessThan(250); + expect(runtime.promptCalls).toHaveLength(1); + expect(runtime.promptCalls[0]?.messageID).toMatch(/^msg_/u); + expect(result.session?.status).toBe("running"); + }); + } + + it("bounds status-watchdog message snapshots and stops with the active turn", async () => { + let messageFetchCount = 0; + let messageRequestsAborted = 0; + let inFlightMessageRequests = 0; + let maxInFlightMessageRequests = 0; + let statusFetchCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async (_input, requestOptions) => { + messageFetchCount += 1; + if (messageFetchCount === 1) { + return { data: [] }; + } + inFlightMessageRequests += 1; + maxInFlightMessageRequests = Math.max(maxInFlightMessageRequests, inFlightMessageRequests); + return await new Promise<{ + data: Array<{ info: Record; parts: Part[] }>; + }>((_resolve, reject) => { + requestOptions?.signal?.addEventListener("abort", () => { + messageRequestsAborted += 1; + inFlightMessageRequests -= 1; + reject(new DOMException("aborted", "AbortError")); + }); + }); + }, + status: async () => { + statusFetchCount += 1; + return { data: { "opencode-session-1": { type: "idle" } } }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-bounded-status-watchdog"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* Effect.sleep(550); + const [beforeInterrupt] = yield* adapter.listSessions(); + yield* adapter.interruptTurn(threadId, turn.turnId); + const statusAtInterrupt = statusFetchCount; + yield* Effect.sleep(550); + const [afterInterrupt] = yield* adapter.listSessions(); + const statusAfterInterruptGrace = statusFetchCount; + yield* Effect.sleep(550); + return { beforeInterrupt, afterInterrupt, statusAtInterrupt, statusAfterInterruptGrace }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(messageFetchCount).toBeGreaterThanOrEqual(2); + expect(messageRequestsAborted).toBeGreaterThanOrEqual(1); + expect(maxInFlightMessageRequests).toBe(1); + expect(inFlightMessageRequests).toBe(0); + expect(result.beforeInterrupt?.status).toBe("running"); + expect(result.afterInterrupt?.status).toBe("ready"); + expect(result.statusAfterInterruptGrace - result.statusAtInterrupt).toBeLessThanOrEqual(1); + expect(statusFetchCount).toBe(result.statusAfterInterruptGrace); + }); + + it("ignores sessionless idle and error events when two active sessions share a server", async () => { + const events = createBroadcastSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-1", "opencode-session-2"], + }); + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { subscribe: () => Promise<{ stream: AsyncIterable }> }; + }; + client.event.subscribe = async () => ({ stream: events.subscribe() }); + + const sessions = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const firstThreadId = asThreadId("thread-sessionless-first"); + const secondThreadId = asThreadId("thread-sessionless-second"); + yield* adapter.startSession({ + provider: "opencode", + threadId: firstThreadId, + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: "opencode", + threadId: secondThreadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: firstThreadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.sendTurn({ + threadId: secondThreadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + events.push({ type: "session.idle", properties: {} }); + events.push({ + type: "session.error", + properties: { error: { data: { message: "wrong session" } } }, + }); + yield* Effect.sleep(25); + const activeSessions = yield* adapter.listSessions(); + events.close(); + return activeSessions; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(sessions).toHaveLength(2); + expect(sessions.every((session) => session.status === "running")).toBe(true); + expect(sessions.every((session) => session.activeTurnId !== undefined)).toBe(true); + }); + + it("applies dropped-idle recovery to Kilo without waiting on ignored synthetic text", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ messages: async () => ({ data: [] }) }); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 6)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-kilo-early-idle"); + + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-kilo-early-idle", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-kilo-synthetic", + messageID: "msg-kilo-early-idle", + type: "text", + text: "Creating snapshot", + synthetic: true, + ignored: true, + time: { start: 1 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-kilo-visible", + messageID: "msg-kilo-early-idle", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + }, + }, + }); + + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return collected; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.completed", + "turn.completed", + ]); + expect(events[5]).toMatchObject({ + provider: "kilo", + raw: { + source: "kilo.sdk.event", + payload: { source: "scient.kilo.deferred-idle-local-parts" }, + }, + }); + }); + + it("does not complete Kilo from a partial prompt response", async () => { + const runtime = createMockOpenCodeRuntime({ + prompt: async (input) => ({ + data: { + info: { + id: "msg-kilo-partial-prompt", + role: "assistant", + parentID: input.messageID, + finish: "stop", + time: { completed: 2 }, + }, + parts: [ + { + id: "part-kilo-partial-prompt", + messageID: "msg-kilo-partial-prompt", + type: "text", + text: "still arriving", + time: { start: 1 }, + } as Part, + ], + }, + }), + }); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const threadId = asThreadId("thread-kilo-partial-prompt"); + + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + const [currentSession] = yield* adapter.listSessions(); + return currentSession; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("running"); + expect(session?.activeTurnId).toBeDefined(); + }); + + it("tracks a partial assistant part that arrives before its message metadata", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-part-before-metadata"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-before-metadata", + messageID: "msg-before-metadata", + type: "text", + text: "partial", + time: { start: 1 }, + }, + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-before-metadata", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(5); + const [whilePartial] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-before-metadata", + messageID: "msg-before-metadata", + type: "text", + text: "partial complete", + time: { start: 1, end: 2 }, + }, + }, + }); + yield* Effect.sleep(60); + const [settled] = yield* adapter.listSessions(); + eventQueue.close(); + return { whilePartial, settled }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.whilePartial?.status).toBe("running"); + expect(result.settled?.status).toBe("ready"); + }); + + it("does not treat completed tool-call text as a final assistant response", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-tool-call-text-not-final"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-tool-call-text", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "tool-calls", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-tool-call-text", + messageID: "msg-tool-call-text", + type: "text", + text: "I will inspect that.", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(60); + const [current] = yield* adapter.listSessions(); + eventQueue.close(); + return current; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("error"); + expect(session?.lastError).toContain("after tool calls"); + }); + + it("rejects delayed parts from cached prior assistant metadata when the successor baseline times out", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async (_input, requestOptions) => { + messageFetchCount += 1; + if (messageFetchCount === 2) { + return await new Promise<{ + data: Array<{ info: Record; parts: Part[] }>; + }>((_resolve, reject) => { + requestOptions?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + } + return { data: [] }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-cached-prior-role-untrusted-baseline"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-cached-prior-assistant", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "tool-calls", + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-cached-prior-assistant", + messageID: "msg-cached-prior-assistant", + type: "text", + text: "prior turn text", + time: { start: 1 }, + }, + }, + }); + yield* Effect.sleep(5); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "message.part.delta", + properties: { + sessionID: "opencode-session-1", + messageID: "msg-cached-prior-assistant", + partID: "part-cached-prior-assistant", + field: "text", + delta: " delayed stale suffix", + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-current-after-untrusted-baseline", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 3 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-current-after-untrusted-baseline", + messageID: "msg-current-after-untrusted-baseline", + type: "text", + text: "current turn text", + time: { start: 2, end: 3 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(30); + const [session] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 5, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.session?.status).toBe("ready"); + expect(JSON.stringify(result.observedEvents)).not.toContain("delayed stale suffix"); + expect(JSON.stringify(result.observedEvents)).toContain("current turn text"); + }); + + it("requires prompt-parent ownership when the baseline times out, then recovers valid output", async () => { + let messageFetchCount = 0; + let baselineRequestAborted = false; + let exposeCurrentResponse = false; + const runtime = createMockOpenCodeRuntime({ + messages: async (_input, requestOptions) => { + messageFetchCount += 1; + if (messageFetchCount === 1) { + return await new Promise<{ + data: Array<{ info: Record; parts: Part[] }>; + }>((_resolve, reject) => { + requestOptions?.signal?.addEventListener("abort", () => { + baselineRequestAborted = true; + reject(new DOMException("aborted", "AbortError")); + }); + }); + } + const promptMessageId = runtime.promptCalls[0]?.messageID; + return { + data: [ + { + info: { + id: exposeCurrentResponse ? "msg-current-parent" : "msg-replayed-no-parent", + role: "assistant", + finish: "stop", + time: { completed: 2 }, + ...(exposeCurrentResponse ? { parentID: promptMessageId } : {}), + }, + parts: [ + { + id: exposeCurrentResponse ? "part-current-parent" : "part-replayed-no-parent", + messageID: exposeCurrentResponse + ? "msg-current-parent" + : "msg-replayed-no-parent", + type: "text", + text: exposeCurrentResponse ? "current" : "obsolete", + time: { start: 1, end: 2 }, + } as Part, + ], + }, + ], + }; + }, + status: async () => ({ data: { "opencode-session-1": { type: "idle" } } }), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-untrusted-baseline-parent"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* Effect.sleep(575); + const [afterReplay] = yield* adapter.listSessions(); + exposeCurrentResponse = true; + yield* Effect.sleep(575); + const [afterCurrent] = yield* adapter.listSessions(); + const thread = yield* adapter.readThread(threadId); + return { afterReplay, afterCurrent, thread }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(baselineRequestAborted).toBe(true); + expect(result.afterReplay?.status).toBe("running"); + expect(JSON.stringify(result.thread)).not.toContain("obsolete"); + expect(result.afterCurrent?.status).toBe("ready"); + }); + + it("completes valid output even when a stale baseline event arrives afterward", async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async () => ({ + data: + messageFetchCount++ === 0 + ? [{ info: { id: "msg-old-after-valid", role: "assistant" }, parts: [] }] + : [], + }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-after-valid"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + for (const input of [ + { id: "msg-valid-before-stale", text: "current" }, + { id: "msg-old-after-valid", text: "obsolete" }, + ]) { + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: input.id, + role: "assistant", + ...(input.id === "msg-valid-before-stale" + ? { parentID: runtime.promptCalls.at(-1)?.messageID } + : {}), + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: `part-${input.id}`, + messageID: input.id, + type: "text", + text: input.text, + time: { start: 1, end: 2 }, + }, + }, + }); + } + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(60); + const [current] = yield* adapter.listSessions(); + eventQueue.close(); + return current; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("ready"); + }); + + it("rejects a fresh assistant with a mismatched parent until exact prompt ownership is proven", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-parent-mismatch"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-parent-mismatch", + role: "assistant", + parentID: "msg-previous-prompt", + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-parent-mismatch", + messageID: "msg-parent-mismatch", + type: "text", + text: "provider-normalized parent output", + time: { start: 1, end: 2 }, + }, + }, + }); + yield* Effect.sleep(30); + const [afterMismatch] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-exact-parent", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 4 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-exact-parent", + messageID: "msg-exact-parent", + type: "text", + text: "exact-parent output", + time: { start: 3, end: 4 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(60); + const [afterExactParent] = yield* adapter.listSessions(); + eventQueue.close(); + return { afterMismatch, afterExactParent }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.afterMismatch?.status).toBe("running"); + expect(result.afterExactParent?.status).toBe("ready"); + }); + + for (const provider of ["opencode", "kilo"] as const) { + it(`fails ${provider} in bounded time after only stale output and idle`, async () => { + const eventQueue = createSubscribedEventQueue(); + let messageFetchCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async () => ({ + data: + messageFetchCount++ === 0 + ? [{ info: { id: "msg-only-stale", role: "assistant" }, parts: [] }] + : [], + }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const threadId = asThreadId(`thread-only-stale-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-only-stale", + role: "assistant", + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-only-stale", + messageID: "msg-only-stale", + type: "text", + text: "obsolete", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(60); + const [session] = yield* adapter.listSessions(); + eventQueue.close(); + return session; + }); + + const session = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: `${provider}-adapter-test-`, + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: `${provider}-adapter-test-`, + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("error"); + expect(session?.lastError).toContain("before producing an assistant response"); + }); + } + + it("keeps concurrent interrupts single-flight until provider cancellation settles", async () => { + let resolveAbort: (() => void) | undefined; + let notifyAbortStarted: (() => void) | undefined; + const abortStarted = new Promise((resolve) => { + notifyAbortStarted = resolve; + }); + let abortInvocationCount = 0; + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + abortInvocationCount += 1; + if (abortInvocationCount > 1) { + return { data: null }; + } + notifyAbortStarted?.(); + await new Promise((resolve) => { + resolveAbort = resolve; + }); + return { data: null }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-concurrent-interrupt"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + const firstInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted); + const secondInterrupt = yield* Effect.exit(adapter.interruptTurn(threadId, turn.turnId)); + const blockedSend = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "too early", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }), + ); + const abortCallsWhilePending = runtime.abortCalls.length; + resolveAbort?.(); + yield* Fiber.join(firstInterrupt); + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "after cancellation", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + return { abortCallsWhilePending, blockedSend, nextTurn, secondInterrupt }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.abortCallsWhilePending).toBe(1); + expect(Exit.isFailure(result.secondInterrupt)).toBe(true); + expect(Exit.isFailure(result.blockedSend)).toBe(true); + expect(result.nextTurn.turnId).toBeDefined(); + }); + + it("bounds a hung provider abort, aborts the request signal, and quarantines the session", async () => { + let observedSignal: AbortSignal | undefined; + let abortInvocationCount = 0; + const runtime = createMockOpenCodeRuntime({ + abort: async (_input, requestOptions) => { + abortInvocationCount += 1; + if (abortInvocationCount > 1) { + return { data: null }; + } + observedSignal = requestOptions?.signal; + await new Promise((resolve) => { + requestOptions?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return { data: null }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-hung-abort"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + const interruptExit = yield* Effect.exit(adapter.interruptTurn(threadId, turn.turnId)); + const blockedSend = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "must remain quarantined", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }), + ); + const [session] = yield* adapter.listSessions(); + return { blockedSend, interruptExit, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isFailure(result.interruptExit)).toBe(true); + expect(observedSignal?.aborted).toBe(true); + expect(Exit.isFailure(result.blockedSend)).toBe(true); + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toContain("did not settle within 10ms"); + }); + + it("cannot strand cancellation state when the interrupt caller is itself interrupted", async () => { + let observedSignal: AbortSignal | undefined; + let abortInvocationCount = 0; + const runtime = createMockOpenCodeRuntime({ + abort: async (_input, requestOptions) => { + abortInvocationCount += 1; + if (abortInvocationCount > 1) { + return { data: null }; + } + observedSignal = requestOptions?.signal; + await new Promise((resolve) => { + requestOptions?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return { data: null }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupted-interrupt-caller"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.sleep(2); + yield* Fiber.interrupt(interruptFiber); + const blockedSend = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "must remain quarantined", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }), + ); + const [session] = yield* adapter.listSessions(); + return { blockedSend, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(observedSignal?.aborted).toBe(true); + expect(Exit.isFailure(result.blockedSend)).toBe(true); + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toContain("did not settle within 10ms"); + }); + + it("suppresses abort-related session errors after cancellation", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { data: { message: "request aborted" } }, + }, + }); + await Promise.resolve(); + return { data: null }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 4)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-abort-error-race"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, turn.turnId); + yield* Effect.sleep(20); + const [session] = yield* adapter.listSessions(); + const events = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return { events, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.events.map((event) => event.type)).toEqual([ + "session.started", + "thread.started", + "turn.started", + "turn.aborted", + ]); + expect(result.session?.status).toBe("ready"); + expect(result.session?.lastError).toBeUndefined(); + }); + + it("does not hide a genuine session failure during the cancellation drain", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { data: { message: "provider disconnected" } }, + }, + }); + return { data: null }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly turnId: string | undefined; + readonly state: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push({ + type: event.type, + turnId: event.turnId, + state: "state" in event.payload ? event.payload.state : undefined, + }); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-cancel-genuine-error"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, turn.turnId); + yield* Effect.sleep(10); + const [current] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, session: current, turn }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toBe("provider disconnected"); + expect( + result.observedEvents.filter( + (event) => + event.type === "turn.completed" && + event.turnId === result.turn.turnId && + event.state === "failed", + ), + ).toHaveLength(1); + }); + + it("single-flights racing session failure and stall cancellation", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { data: null }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly turnId: string | undefined; + readonly state: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push({ + type: event.type, + turnId: event.turnId, + state: "state" in event.payload ? event.payload.state : undefined, + }); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-racing-failure-single-flight"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "fail once", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-racing-failure"); + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { data: { message: "provider failed" } }, + }, + }); + yield* Effect.sleep(50); + const [session] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { + abortCallCount: runtime.abortCalls.length, + observedEvents, + session, + turn, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + turnStallTimeoutMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.abortCallCount).toBe(1); + expect(result.session?.status).toBe("error"); + expect( + result.observedEvents.filter( + (event) => + event.type === "turn.completed" && + event.turnId === result.turn.turnId && + event.state === "failed", + ), + ).toHaveLength(1); + }); + + it("quarantines the session when provider abort fails", async () => { + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + throw new Error("abort transport failed"); + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-abort-failure-quarantine"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + const interruptExit = yield* Effect.exit(adapter.interruptTurn(threadId, turn.turnId)); + const secondTurnExit = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }), + ); + const [session] = yield* adapter.listSessions(); + return { interruptExit, secondTurnExit, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isFailure(result.interruptExit)).toBe(true); + expect(Exit.isFailure(result.secondTurnExit)).toBe(true); + expect(runtime.promptCalls).toHaveLength(1); + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toContain("abort transport failed"); + }); + + it("does not let a delayed cancellation error fail the next turn", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + setTimeout(() => { + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { data: { message: "late abort error" } }, + }, + }); + }, 20); + return { data: null }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-delayed-abort-error"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + yield* Effect.sleep(30); + const [afterLateAbortError] = yield* adapter.listSessions(); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-current-after-abort", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { name: "MessageAbortedError", data: { message: "current request cancelled" } }, + }, + }); + yield* Effect.sleep(10); + const [afterCurrentError] = yield* adapter.listSessions(); + eventQueue.close(); + return { afterCurrentError, afterLateAbortError, secondTurn }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.afterLateAbortError).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(result.afterCurrentError?.status).toBe("error"); + expect(result.afterCurrentError?.lastError).toBe("current request cancelled"); + }); + + it("keeps an ambiguous successor cancellation error quarantined until the stall watchdog aborts", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-successor-cancel-error-watchdog"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { name: "MessageAbortedError", data: { message: "current request cancelled" } }, + }, + }); + yield* Effect.sleep(50); + const [current] = yield* adapter.listSessions(); + eventQueue.close(); + return { abortCallCount: runtime.abortCalls.length, current }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + turnStallTimeoutMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session.current?.status).toBe("error"); + expect(session.current?.lastError).toContain("stopped making progress"); + expect(session.abortCallCount).toBe(2); + }); + + it("does not emit a cancellation error after genuine successor output wins completion", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ readonly type: string }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-successor-completes-before-cancel-watchdog"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { name: "MessageAbortedError", data: { message: "ambiguous cancellation" } }, + }, + }); + yield* Effect.sleep(4); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-successor-before-cancel-watchdog", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-successor-before-cancel-watchdog", + messageID: "msg-successor-before-cancel-watchdog", + type: "text", + text: "successor output", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(30); + const [session] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + prematureIdleCompletionGraceMs: 5, + promptAcceptedActivityTimeoutMs: 10, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.session?.status).toBe("ready"); + expect(result.observedEvents.filter((event) => event.type === "turn.completed")).toHaveLength( + 1, + ); + expect(result.observedEvents.filter((event) => event.type === "runtime.error")).toHaveLength(0); + }); + + it("pauses the successor stall watchdog while permission work is pending", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-successor-cancel-error-pending-permission"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-successor-permission-owner"); + eventQueue.push({ + id: "evt-successor-permission-asked", + type: "permission.asked", + properties: { + id: "successor-permission", + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }); + yield* Effect.sleep(10); + yield* Effect.sleep(70); + const [whilePending] = yield* adapter.listSessions(); + eventQueue.push({ + id: "evt-successor-permission-replied", + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: "successor-permission", + reply: "once", + }, + }); + yield* Effect.sleep(80); + const [afterResolution] = yield* adapter.listSessions(); + eventQueue.close(); + return { afterResolution, secondTurn, whilePending }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + turnStallTimeoutMs: 50, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.whilePending).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(result.afterResolution?.status).toBe("error"); + expect(result.afterResolution?.lastError).toContain("stopped making progress"); + }); + + for (const interaction of ["permission", "question"] as const) { + it(`keeps early idle paused for a pending ${interaction} and completes without a provider reply echo`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push({ type: event.type, requestId: event.requestId }); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-early-idle-${interaction}`); + const requestId = `${interaction}-early-idle`; + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: `ask ${interaction}`, + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-early-idle-${interaction}`); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(35); + const [whilePending] = yield* adapter.listSessions(); + if (interaction === "permission") { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ); + } else { + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + {}, + ); + } + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 10, + sessionID: "opencode-session-1", + text: `${interaction} completed`, + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 11, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + reply: "once", + }, + } + : { + type: "question.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + answers: [["Yes"]], + }, + }, + ); + yield* Effect.sleep(30); + const [settled] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { + abortCallCount: runtime.abortCalls.length, + observedEvents, + settled, + turn, + whilePending, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + turnStallTimeoutMs: 100, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.whilePending).toMatchObject({ + status: "running", + activeTurnId: result.turn.turnId, + }); + expect(result.settled?.status).toBe("ready"); + expect(result.abortCallCount).toBe(0); + const resolutionType = + interaction === "permission" ? "request.resolved" : "user-input.resolved"; + expect( + result.observedEvents.filter( + (event) => + event.type === resolutionType && event.requestId === `${interaction}-early-idle`, + ), + ).toHaveLength(1); + }); + } + + for (const interaction of ["permission", "question"] as const) { + it(`accepts one ${interaction} resolution when its provider echo beats the reply response`, async () => { + const eventQueue = createSubscribedEventQueue(); + const requestId = `${interaction}-early-echo`; + const replyWithEarlyEcho = async () => { + eventQueue.push( + interaction === "permission" + ? { + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + reply: "once", + }, + } + : { + type: "question.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + answers: [["Yes"]], + }, + }, + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + return { data: null }; + }; + const runtime = createMockOpenCodeRuntime( + interaction === "permission" + ? { permissionReply: replyWithEarlyEcho } + : { questionReply: replyWithEarlyEcho }, + ); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push({ type: event.type, requestId: event.requestId }); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-${interaction}-early-echo`); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: `resolve ${interaction}`, + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-${interaction}-early-echo`); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + yield* Effect.sleep(10); + const replyExit = yield* Effect.exit( + interaction === "permission" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ) + : adapter.respondToUserInput(threadId, ApprovalRequestId.makeUnsafe(requestId), {}), + ); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 10, + sessionID: "opencode-session-1", + text: `${interaction} completed`, + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 11, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(30); + const [settled] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, replyExit, settled, turn }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isSuccess(result.replyExit)).toBe(true); + expect(result.settled?.status).toBe("ready"); + const resolutionType = + interaction === "permission" ? "request.resolved" : "user-input.resolved"; + expect( + result.observedEvents.filter( + (event) => event.type === resolutionType && event.requestId === requestId, + ), + ).toHaveLength(1); + }); + } + + for (const interaction of ["permission", "question"] as const) { + it(`single-flights ${interaction} replies through provider settlement and local commit`, async () => { + const eventQueue = createSubscribedEventQueue(); + let settleReply: ((value: unknown) => void) | undefined; + const heldReply = () => + new Promise((resolve) => { + settleReply = resolve; + }); + const runtime = createMockOpenCodeRuntime( + interaction === "permission" + ? { permissionReply: heldReply } + : { questionReply: heldReply }, + ); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-${interaction}-single-flight-commit`); + const requestId = `${interaction}-single-flight-commit`; + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "single flight", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + yield* Effect.sleep(10); + const respond = () => + interaction === "permission" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ) + : adapter.respondToUserInput(threadId, ApprovalRequestId.makeUnsafe(requestId), {}); + const firstFiber = yield* respond().pipe(Effect.forkChild); + yield* Effect.sleep(10); + const simultaneousExit = yield* Effect.exit(respond()); + settleReply?.({ data: null }); + // The provider promise is settled, but the first fiber has not yet been + // rescheduled to commit its local maps/event. The marker must still reject this. + const postSettlementExit = yield* Effect.exit(respond()); + const firstExit = yield* Fiber.await(firstFiber); + eventQueue.close(); + return { firstExit, postSettlementExit, simultaneousExit }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.firstExit._tag).toBe("Success"); + expect(result.simultaneousExit._tag).toBe("Failure"); + expect(result.postSettlementExit._tag).toBe("Failure"); + expect( + interaction === "permission" ? runtime.permissionReplyCalls : runtime.questionReplyCalls, + ).toHaveLength(1); + }); + } + + for (const interaction of ["permission", "question"] as const) { + it(`fails closed when a dispatched ${interaction} reply transport rejects ambiguously`, async () => { + const eventQueue = createSubscribedEventQueue(); + const rejectingReply = async () => { + throw new Error(`${interaction} response lost after dispatch`); + }; + const runtime = createMockOpenCodeRuntime( + interaction === "permission" + ? { permissionReply: rejectingReply } + : { questionReply: rejectingReply }, + ); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => + observedEvents.push({ type: event.type, requestId: event.requestId }), + ), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-${interaction}-ambiguous-transport-failure`); + const requestId = `${interaction}-ambiguous-transport-failure`; + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "ambiguous transport", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + yield* Effect.sleep(10); + const respond = () => + interaction === "permission" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ) + : adapter.respondToUserInput(threadId, ApprovalRequestId.makeUnsafe(requestId), {}); + const replyExit = yield* Effect.exit(respond()); + const retryExit = yield* Effect.exit(respond()); + const [failedSession] = yield* adapter.listSessions(); + const successor = yield* adapter.sendTurn({ + threadId, + input: "safe successor after confirmed abort", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + reply: "once", + }, + } + : { + type: "question.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + answers: [["Yes"]], + }, + }, + ); + yield* Effect.sleep(20); + const [successorSession] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { + failedSession, + observedEvents, + replyExit, + retryExit, + successor, + successorSession, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.replyExit._tag).toBe("Failure"); + expect(result.retryExit._tag).toBe("Failure"); + expect(result.failedSession?.status).toBe("error"); + expect(result.failedSession?.lastError).toContain("outcome could not be confirmed"); + expect(result.successorSession).toMatchObject({ + status: "running", + activeTurnId: result.successor.turnId, + }); + expect( + interaction === "permission" ? runtime.permissionReplyCalls : runtime.questionReplyCalls, + ).toHaveLength(1); + const resolutionType = + interaction === "permission" ? "request.resolved" : "user-input.resolved"; + expect( + result.observedEvents.filter( + (event) => + event.type === resolutionType && + event.requestId === `${interaction}-ambiguous-transport-failure`, + ), + ).toHaveLength(1); + }); + } + + it("fails closed instead of surfacing a full-access approval after ambiguous autoapproval failure", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + permissionReply: async () => { + throw new Error("full-access response lost after dispatch"); + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ readonly type: string }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => observedEvents.push({ type: event.type })), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-full-access-ambiguous-autoapproval-failure"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "autoapproval ambiguity", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "permission.asked", + properties: { + id: "full-access-ambiguous-autoapproval-failure", + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }); + yield* Effect.sleep(30); + const [session] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.permissionReplyCalls).toHaveLength(1); + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toContain("outcome could not be confirmed"); + expect(result.observedEvents.filter((event) => event.type === "request.opened")).toHaveLength( + 0, + ); + }); + + for (const interaction of ["permission", "question"] as const) { + it(`fails and closes an ambiguous interrupted ${interaction} reply instead of retrying it`, async () => { + const eventQueue = createSubscribedEventQueue(); + const requestId = `${interaction}-interrupted-reply`; + let replyAttemptCount = 0; + let firstReplySignalAborted = false; + const interruptibleReply = ( + _input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => { + replyAttemptCount += 1; + if (replyAttemptCount > 1) { + return Promise.resolve({ data: null }); + } + return new Promise((_resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => { + firstReplySignalAborted = true; + reject(new Error("reply caller interrupted")); + }, + { once: true }, + ); + }); + }; + const runtime = createMockOpenCodeRuntime( + interaction === "permission" + ? { permissionReply: interruptibleReply } + : { questionReply: interruptibleReply }, + ); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push({ type: event.type, requestId: event.requestId }); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-${interaction}-interrupted-reply`); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: `interrupt ${interaction}`, + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-${interaction}-interrupted`); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + yield* Effect.sleep(10); + const respond = () => + interaction === "permission" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ) + : adapter.respondToUserInput(threadId, ApprovalRequestId.makeUnsafe(requestId), {}); + const interruptedReplyFiber = yield* respond().pipe(Effect.forkChild); + yield* Effect.sleep(10); + yield* Fiber.interrupt(interruptedReplyFiber); + const retryExit = yield* Effect.exit(respond()); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 10, + sessionID: "opencode-session-1", + text: `${interaction} retry completed`, + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 11, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(30); + const [settled] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, retryExit, settled }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(firstReplySignalAborted).toBe(true); + expect(replyAttemptCount).toBe(1); + expect(Exit.isFailure(result.retryExit)).toBe(true); + expect(result.settled?.status).toBe("error"); + expect(result.settled?.lastError).toContain("interrupted before its provider outcome"); + const resolutionType = + interaction === "permission" ? "request.resolved" : "user-input.resolved"; + expect( + result.observedEvents.filter( + (event) => event.type === resolutionType && event.requestId === requestId, + ), + ).toHaveLength(1); + }); + } + + it("quarantines an interrupted ambiguous reply when provider cancellation cannot be confirmed", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + permissionReply: (_input, requestOptions) => + new Promise((_resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => reject(new Error("reply interrupted")), + { once: true }, + ); + }), + abort: async () => { + throw new Error("abort confirmation unavailable"); + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-ambiguous-reply-abort-failure"); + const requestId = "permission-ambiguous-reply-abort-failure"; + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "ambiguous reply", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }); + yield* Effect.sleep(10); + const replyFiber = yield* adapter + .respondToRequest(threadId, ApprovalRequestId.makeUnsafe(requestId), "accept") + .pipe(Effect.forkChild); + yield* Effect.sleep(10); + yield* Fiber.interrupt(replyFiber); + const successorExit = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "must remain quarantined", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }), + ); + const [session] = yield* adapter.listSessions(); + eventQueue.close(); + return { session, successorExit }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.successorExit._tag).toBe("Failure"); + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toContain("cancellation could not be confirmed"); + expect(result.session?.lastError).toContain("abort confirmation unavailable"); + }); + + for (const interaction of ["permission", "question"] as const) { + for (const cancellationSource of ["interruptTurn", "session.error"] as const) { + it(`aborts a hanging ${interaction} reply before ${cancellationSource} releases its generation`, async () => { + const eventQueue = createSubscribedEventQueue(); + let replySignalAborted = false; + const hangingReply = ( + _input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => + new Promise((_resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => { + replySignalAborted = true; + reject(new Error("interaction transport aborted")); + }, + { once: true }, + ); + }); + const runtime = createMockOpenCodeRuntime( + interaction === "permission" + ? { permissionReply: hangingReply } + : { questionReply: hangingReply }, + ); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => + observedEvents.push({ type: event.type, requestId: event.requestId }), + ), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-${interaction}-${cancellationSource}-reply-abort`); + const requestId = `${interaction}-${cancellationSource}-reply-abort`; + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "reply abort", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-${requestId}`); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + yield* Effect.sleep(10); + const replyFiber = yield* ( + interaction === "permission" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ) + : adapter.respondToUserInput(threadId, ApprovalRequestId.makeUnsafe(requestId), {}) + ).pipe(Effect.forkChild); + yield* Effect.sleep(10); + if (cancellationSource === "interruptTurn") { + yield* adapter.interruptTurn(threadId, turn.turnId); + } else { + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { data: { message: "provider failed during interaction" } }, + }, + }); + yield* Effect.sleep(20); + } + const replyExit = yield* Fiber.await(replyFiber); + const successor = yield* adapter.sendTurn({ + threadId, + input: "successor", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + reply: "once", + }, + } + : { + type: "question.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + answers: [["Yes"]], + }, + }, + ); + yield* Effect.sleep(20); + const [session] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { observedEvents, replyExit, session, successor }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 0, + completionSnapshotTimeoutMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(replySignalAborted).toBe(true); + expect(result.replyExit._tag).toBe("Failure"); + expect(result.session).toMatchObject({ + status: "running", + activeTurnId: result.successor.turnId, + }); + const resolutionType = + interaction === "permission" ? "request.resolved" : "user-input.resolved"; + expect( + result.observedEvents.filter( + (event) => + event.type === resolutionType && + event.requestId === `${interaction}-${cancellationSource}-reply-abort`, + ), + ).toHaveLength(1); + }); + } + } + + it("aborts an in-flight full-access autoapproval before interrupting the session", async () => { + const eventQueue = createSubscribedEventQueue(); + let autoapprovalSignalAborted = false; + const runtime = createMockOpenCodeRuntime({ + permissionReply: (_input, requestOptions) => + new Promise((_resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => { + autoapprovalSignalAborted = true; + reject(new Error("autoapproval aborted")); + }, + { once: true }, + ); + }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-autoapproval-interrupt"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "autoapprove", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "permission.asked", + properties: { + id: "full-access-autoapproval-interrupt", + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }); + yield* Effect.sleep(10); + yield* adapter.interruptTurn(threadId, turn.turnId); + const successor = yield* adapter.sendTurn({ + threadId, + input: "successor", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* Effect.sleep(10); + const [session] = yield* adapter.listSessions(); + eventQueue.close(); + return { session, successor }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 0, + completionSnapshotTimeoutMs: 20, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(autoapprovalSignalAborted).toBe(true); + expect(runtime.permissionReplyCalls).toHaveLength(1); + expect(result.session).toMatchObject({ + status: "running", + activeTurnId: result.successor.turnId, + }); + }); + + for (const interaction of ["permission", "question"] as const) { + it(`bounds a hung ${interaction} reply and quarantines its late echo from the successor`, async () => { + const eventQueue = createSubscribedEventQueue(); + let replySignalAborted = false; + const hangingReply = ( + _input: Record, + requestOptions?: { readonly signal?: AbortSignal }, + ) => + new Promise((_resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => { + replySignalAborted = true; + reject(new Error("reply aborted")); + }, + { once: true }, + ); + }); + const runtime = createMockOpenCodeRuntime( + interaction === "permission" + ? { permissionReply: hangingReply } + : { questionReply: hangingReply }, + ); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ + readonly type: string; + readonly requestId: string | undefined; + }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push({ type: event.type, requestId: event.requestId }); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-hung-${interaction}-reply`); + const requestId = `${interaction}-hung-reply`; + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: `hang ${interaction}`, + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-hung-${interaction}`); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + } + : { + type: "question.asked", + properties: { + id: requestId, + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }, + ); + yield* Effect.sleep(10); + const replyExit = yield* Effect.exit( + interaction === "permission" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(requestId), + "accept", + ) + : adapter.respondToUserInput(threadId, ApprovalRequestId.makeUnsafe(requestId), {}), + ); + const successor = yield* adapter.sendTurn({ + threadId, + input: "successor", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push( + interaction === "permission" + ? { + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + reply: "once", + }, + } + : { + type: "question.replied", + properties: { + sessionID: "opencode-session-1", + requestID: requestId, + answers: [["Yes"]], + }, + }, + ); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-successor-hung-${interaction}`); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 20, + sessionID: "opencode-session-1", + text: "successor complete", + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 21, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(30); + const [settled] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { + abortCallCount: runtime.abortCalls.length, + observedEvents, + replyExit, + settled, + successor, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + completionSnapshotTimeoutMs: 10, + turnStallTimeoutMs: 100, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.replyExit._tag).toBe("Failure"); + expect(replySignalAborted).toBe(true); + expect(result.settled).toMatchObject({ status: "ready" }); + expect(result.abortCallCount).toBe(1); + const resolutionType = + interaction === "permission" ? "request.resolved" : "user-input.resolved"; + expect( + result.observedEvents.filter( + (event) => + event.type === resolutionType && event.requestId === `${interaction}-hung-reply`, + ), + ).toHaveLength(1); + }); + } + + it("rejects a stale interrupt without aborting the active successor", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-interrupt-generation"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-first-stale-interrupt"); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-first-stale-interrupt", + messageID: "msg-first-stale-interrupt", + type: "text", + text: "first complete", + time: { start: 1, end: 2 }, + }, + }, + }); + eventQueue.push({ type: "session.idle", properties: { sessionID: "opencode-session-1" } }); + yield* Effect.sleep(30); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + const staleInterrupt = yield* Effect.exit( + adapter.interruptTurn(threadId, firstTurn.turnId), + ); + const [afterStaleInterrupt] = yield* adapter.listSessions(); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-second-stale-interrupt"); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-second-stale-interrupt", + messageID: "msg-second-stale-interrupt", + type: "text", + text: "second complete", + time: { start: 3, end: 4 }, + }, + }, + }); + eventQueue.push({ type: "session.idle", properties: { sessionID: "opencode-session-1" } }); + yield* Effect.sleep(30); + const [settled] = yield* adapter.listSessions(); + eventQueue.close(); + return { + abortCallCount: runtime.abortCalls.length, + afterStaleInterrupt, + secondTurn, + settled, + staleInterrupt, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.staleInterrupt._tag).toBe("Failure"); + expect(result.abortCallCount).toBe(0); + expect(result.afterStaleInterrupt).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(result.settled?.status).toBe("ready"); + }); + + it("cancels pending permission and question authority with its generation", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observedEvents: Array<{ readonly type: string }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-cancel-pending-generation"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "ask then cancel", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-pending-owner"); + eventQueue.push({ + type: "permission.asked", + properties: { + id: "permission-cancelled-generation", + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }); + eventQueue.push({ + type: "question.asked", + properties: { + id: "question-cancelled-generation", + sessionID: "opencode-session-1", + questions: [ + { + question: "Continue?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }); + yield* Effect.sleep(10); + yield* adapter.interruptTurn(threadId, turn.turnId); + const permissionReply = yield* Effect.exit( + adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe("permission-cancelled-generation"), + "accept", + ), + ); + const questionReply = yield* Effect.exit( + adapter.respondToUserInput( + threadId, + ApprovalRequestId.makeUnsafe("question-cancelled-generation"), + {}, + ), + ); + yield* adapter.sendTurn({ + threadId, + input: "successor stalls", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "permission.replied", + properties: { + sessionID: "opencode-session-1", + requestID: "permission-cancelled-generation", + reply: "once", + }, + }); + eventQueue.push({ + type: "question.replied", + properties: { + sessionID: "opencode-session-1", + requestID: "question-cancelled-generation", + answers: [["Yes"]], + }, + }); + yield* Effect.sleep(60); + const [successor] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { + abortCallCount: runtime.abortCalls.length, + observedEvents, + permissionReply, + questionReply, + successor, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + turnStallTimeoutMs: 25, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.permissionReply._tag).toBe("Failure"); + expect(result.questionReply._tag).toBe("Failure"); + expect(result.observedEvents.filter((event) => event.type === "request.resolved")).toHaveLength( + 1, + ); + expect( + result.observedEvents.filter((event) => event.type === "user-input.resolved"), + ).toHaveLength(1); + expect(result.successor?.status).toBe("error"); + expect(result.successor?.lastError).toContain("stopped making progress"); + expect(result.abortCallCount).toBe(2); + }); + + for (const provider of ["opencode", "kilo"] as const) { + it(`bounds a continuously busy but unowned ${provider} turn`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const threadId = asThreadId(`thread-busy-stall-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "stay busy", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + for (let index = 0; index < 4; index += 1) { + eventQueue.push({ + type: "session.status", + properties: { + sessionID: "opencode-session-1", + status: { type: "busy" }, + }, + }); + yield* Effect.sleep(8); + } + yield* Effect.sleep(100); + const [session] = yield* adapter.listSessions(); + const abortCallCount = runtime.abortCalls.length; + eventQueue.close(); + return { abortCallCount, session }; + }); + + const session = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + turnStallTimeoutMs: 25, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + turnStallTimeoutMs: 25, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session.session?.status).toBe("error"); + expect(session.session?.lastError).toContain("stopped making progress"); + expect(session.abortCallCount).toBe(1); + }); + } + + it("does not let a delayed Kilo cancellation error fail a successor after its prompt echo", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + abort: async () => { + setTimeout(() => { + eventQueue.push({ + type: "session.error", + properties: { + sessionID: "opencode-session-1", + error: { data: { message: "late abort error" } }, + }, + }); + }, 20); + return { data: null }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const threadId = asThreadId("thread-kilo-delayed-abort-error"); + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + yield* Effect.sleep(30); + const [session] = yield* adapter.listSessions(); + eventQueue.close(); + return { secondTurn, session }; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.session).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + }); + + for (const provider of ["opencode", "kilo"] as const) { + it(`rejects stale ${provider} output after prompt echo until current-turn ownership is proven`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 6)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId(`thread-owned-tool-buffer-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + const secondPromptMessageId = runtime.promptCalls.at(-1)?.messageID; + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-stale-tool-before-metadata", + messageID: "msg-stale-tool-before-metadata", + type: "tool", + callID: "call-stale-tool", + tool: "read", + state: { + status: "completed", + input: {}, + output: "obsolete tool output", + title: "obsolete tool", + metadata: { sessionId: "stale-child-session" }, + time: { start: 1, end: 2 }, + }, + }, + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-stale-tool-before-metadata", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "tool-calls", + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-stale-metadata-light-tool-after-prompt", + messageID: "msg-stale-metadata-light-tool-after-prompt", + type: "tool", + callID: "call-stale-metadata-light-tool", + tool: "grep", + state: { + status: "completed", + input: {}, + output: "metadata-light obsolete output", + time: { start: 2, end: 3 }, + }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-current-tool-before-metadata", + messageID: "msg-current-tool-before-metadata", + type: "tool", + callID: "call-current-tool", + tool: "read", + state: { + status: "completed", + input: {}, + output: "current tool output", + title: "current tool", + metadata: {}, + time: { start: 3, end: 4 }, + }, + }, + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-current-tool-before-metadata", + role: "assistant", + parentID: secondPromptMessageId, + finish: "tool-calls", + }, + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-stale-after-current-ownership", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "stop", + time: { completed: 6 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-stale-after-current-ownership", + messageID: "msg-stale-after-current-ownership", + type: "text", + text: "obsolete output after current ownership", + time: { start: 5, end: 6 }, + }, + }, + }); + yield* Effect.sleep(10); + const thread = yield* adapter.readThread(threadId); + const events = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return { events, thread }; + }); + + const result = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(JSON.stringify(result)).not.toContain("stale-tool"); + expect(JSON.stringify(result)).not.toContain("obsolete tool output"); + expect(JSON.stringify(result)).not.toContain("metadata-light obsolete output"); + expect(JSON.stringify(result)).not.toContain("obsolete output after current ownership"); + expect(result.events.filter((event) => event.itemId === "call-current-tool")).toHaveLength(1); + }); + } + + it("restores metadata-light child-session tools after explicit post-cancel assistant ownership", async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 7)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-post-cancel-child-tools"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "post-cancel-parent-assistant", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "tool-calls", + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "post-cancel-parent-task", + messageID: "post-cancel-parent-assistant", + type: "tool", + tool: "task", + callID: "post-cancel-task-call", + state: { + status: "running", + title: "Inspect child", + input: {}, + metadata: { sessionId: "post-cancel-child-session" }, + time: { start: 1 }, + }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "post-cancel-child-session", + part: { + id: "post-cancel-child-tool", + messageID: "post-cancel-child-assistant", + type: "tool", + tool: "grep", + callID: "post-cancel-child-call", + state: { + status: "completed", + input: {}, + output: "child result", + time: { start: 2, end: 3 }, + }, + }, + }, + }); + const collected = Array.from(yield* Fiber.join(eventsFiber)); + eventQueue.close(); + return collected; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events.filter((event) => event.itemId === "post-cancel-task-call")).toHaveLength(1); + expect(events.filter((event) => event.itemId === "post-cancel-child-call")).toHaveLength(1); + }); + + it("keeps strict post-cancel ownership until a later retry establishes a boundary", async () => { + const eventQueue = createSubscribedEventQueue(); + let promptInvocationCount = 0; + const runtime = createMockOpenCodeRuntime({ + promptAsync: async () => { + promptInvocationCount += 1; + if (promptInvocationCount === 2) { + throw new Error("successor submission failed"); + } + return { data: null }; + }, + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-strict-boundary-retry"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const failedSuccessor = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "fails before boundary", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }), + ); + yield* Effect.sleep(10); + const retry = yield* adapter.sendTurn({ + threadId, + input: "retry", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-stale-before-retry-boundary", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "stop", + time: { completed: 2 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-stale-before-retry-boundary", + messageID: "msg-stale-before-retry-boundary", + type: "text", + text: "stale retry output", + time: { start: 1, end: 2 }, + }, + }, + }); + yield* Effect.sleep(10); + const [beforeBoundary] = yield* adapter.listSessions(); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-current-after-retry-boundary", + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + finish: "stop", + time: { completed: 4 }, + }, + }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-current-after-retry-boundary", + messageID: "msg-current-after-retry-boundary", + type: "text", + text: "current retry output", + time: { start: 3, end: 4 }, + }, + }, + }); + eventQueue.push({ + type: "session.idle", + properties: { sessionID: "opencode-session-1" }, + }); + yield* Effect.sleep(60); + const [settled] = yield* adapter.listSessions(); + eventQueue.close(); + return { beforeBoundary, failedSuccessor, retry, settled }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isFailure(result.failedSuccessor)).toBe(true); + expect(result.beforeBoundary).toMatchObject({ + status: "running", + activeTurnId: result.retry.turnId, + }); + expect(result.settled?.status).toBe("ready"); + }); + + for (const provider of ["opencode", "kilo"] as const) { + it(`does not attribute duplicated, re-identified, or id-less prior next events to a new ${provider} turn`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const observedEvents: Array = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-next-generation-boundary-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-first-owner-${provider}`); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + text: "first response", + }, + }); + const completedStep = { + id: "evt-first-turn-step-ended", + type: "session.next.step.ended", + properties: { + timestamp: 2, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }; + eventQueue.push(completedStep); + yield* Effect.sleep(20); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + eventQueue.push(completedStep); + yield* Effect.sleep(10); + const [beforeBoundary] = yield* adapter.listSessions(); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + ...completedStep, + id: `evt-reidentified-stale-terminal-${provider}`, + }); + const { id: _staleEventId, ...idlessCompletedStep } = completedStep; + eventQueue.push(idlessCompletedStep); + yield* Effect.sleep(10); + const [afterBoundaryBeforeCurrent] = yield* adapter.listSessions(); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-second-owner-${provider}`); + eventQueue.push({ + id: `evt-reidentified-stale-text-after-ownership-${provider}`, + type: "session.next.text.ended", + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + text: "stale response must remain quarantined", + }, + }); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + text: "stale id-less response must remain quarantined", + }, + }); + eventQueue.push({ + ...completedStep, + id: `evt-reidentified-stale-after-ownership-${provider}`, + }); + eventQueue.push(idlessCompletedStep); + yield* Effect.sleep(10); + const [afterOwnershipBeforeCurrent] = yield* adapter.listSessions(); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 3, + sessionID: "opencode-session-1", + text: "second response", + }, + }); + eventQueue.push({ + ...completedStep, + id: "evt-second-turn-step-ended", + properties: { ...completedStep.properties, timestamp: 4 }, + }); + yield* Effect.sleep(20); + const [afterBoundary] = yield* adapter.listSessions(); + yield* Fiber.interrupt(eventsFiber); + eventQueue.close(); + return { + afterBoundary, + afterBoundaryBeforeCurrent, + afterOwnershipBeforeCurrent, + beforeBoundary, + observedEvents, + secondTurn, + }; + }); + + const result = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.beforeBoundary).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(result.afterBoundaryBeforeCurrent).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(result.afterOwnershipBeforeCurrent).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(JSON.stringify(result.observedEvents)).not.toContain("stale response"); + expect(JSON.stringify(result.observedEvents)).not.toContain("stale id-less response"); + expect(result.afterBoundary?.status).toBe("ready"); + }); + } + + for (const provider of ["opencode", "kilo"] as const) { + it(`fences delayed session.next events after cancelling a ${provider} turn`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const threadId = asThreadId(`thread-next-fence-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + eventQueue.push({ + type: "message.part.updated", + properties: { + sessionID: "opencode-session-1", + part: { + id: "part-late-cancelled-tool", + messageID: "msg-late-cancelled-assistant", + type: "tool", + callID: "call-late-cancelled", + tool: "read", + state: { + status: "completed", + input: {}, + output: "obsolete tool output", + title: "obsolete tool", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: "msg-late-cancelled-assistant", + role: "assistant", + parentID: runtime.promptCalls[0]?.messageID, + finish: "tool-calls", + }, + }, + }); + eventQueue.push({ + type: "session.next.text.delta", + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + delta: "obsolete next text", + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 2, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(10); + const [afterStaleNext] = yield* adapter.listSessions(); + const threadAfterStaleNext = yield* adapter.readThread(threadId); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + id: `evt-stale-next-after-prompt-${provider}`, + type: "session.next.text.ended", + properties: { + timestamp: 3, + sessionID: "opencode-session-1", + text: "obsolete next text after prompt echo", + }, + }); + eventQueue.push({ + type: "message.updated", + properties: { + sessionID: "opencode-session-1", + info: { + id: `msg-current-after-cancel-${provider}`, + role: "assistant", + parentID: runtime.promptCalls.at(-1)?.messageID, + }, + }, + }); + eventQueue.push({ + type: "session.next.text.delta", + properties: { + timestamp: 3, + sessionID: "opencode-session-1", + delta: "second response", + }, + }); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 4, + sessionID: "opencode-session-1", + text: "second response", + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 5, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(20); + const [secondSettled] = yield* adapter.listSessions(); + const thirdTurn = yield* adapter.sendTurn({ + threadId, + input: "third", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, `msg-third-owner-${provider}`); + eventQueue.push({ + type: "session.next.text.ended", + properties: { + timestamp: 6, + sessionID: "opencode-session-1", + text: "third response", + }, + }); + eventQueue.push({ + type: "session.next.step.ended", + properties: { + timestamp: 7, + sessionID: "opencode-session-1", + finish: "stop", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }); + yield* Effect.sleep(20); + const [thirdSettled] = yield* adapter.listSessions(); + const threadAfterAll = yield* adapter.readThread(threadId); + eventQueue.close(); + return { + afterStaleNext, + secondSettled, + secondTurn, + thirdSettled, + thirdTurn, + threadAfterAll, + threadAfterStaleNext, + }; + }); + + const result = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.afterStaleNext).toMatchObject({ + status: "running", + activeTurnId: result.secondTurn.turnId, + }); + expect(JSON.stringify(result.threadAfterStaleNext)).not.toContain("obsolete next text"); + expect(JSON.stringify(result.threadAfterAll)).not.toContain( + "obsolete next text after prompt", + ); + expect(result.secondSettled?.status).toBe("ready"); + expect(result.thirdSettled?.status).toBe("ready"); + expect(result.thirdTurn.turnId).toBeDefined(); + }); + } + + for (const provider of ["opencode", "kilo"] as const) { + it(`fails boundedly when ${provider} becomes idle without post-cancel ownership`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + messages: async () => ({ data: [] }), + status: async () => ({ data: { "opencode-session-1": { type: "idle" } } }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const observedEvents: Array<{ + readonly provider: string; + readonly raw?: unknown; + readonly type: string; + }> = []; + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observedEvents.push(event); + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId(`thread-idle-without-post-cancel-ownership-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + yield* adapter.sendTurn({ + threadId, + input: "second", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + eventQueue.push({ + id: `evt-blocked-next-output-${provider}`, + type: "session.next.text.ended", + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + text: "ambiguous successor output", + }, + }); + yield* Effect.sleep(650); + const [session] = yield* adapter.listSessions(); + const thread = yield* adapter.readThread(threadId); + eventQueue.close(); + return { observedEvents, session, thread }; + }); + + const result = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + prematureIdleCompletionGraceMs: 5, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.session?.status).toBe("error"); + expect(result.session?.lastError).toBeTruthy(); + expect(JSON.stringify(result.thread)).not.toContain("ambiguous successor output"); + expect(result.observedEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider, + type: "turn.completed", + raw: { + source: `${provider}.sdk.event`, + payload: { + source: `scient.${provider}.deferred-idle-timeout`, + event: { + source: `scient.${provider}.snapshot-watchdog-idle-without-ownership`, + status: { type: "idle" }, + }, + }, + }, + }), + ]), + ); + }); + } + + it("rejects sessionless terminal events across OpenCode and Kilo URL aliases", async () => { + const events = createBroadcastSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-1", "kilo-session-1"], + }); + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { subscribe: () => Promise<{ stream: AsyncIterable }> }; + }; + client.event.subscribe = async () => ({ stream: events.subscribe() }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const openCode = yield* OpenCodeAdapter; + const kilo = yield* KiloAdapter; + const openCodeThreadId = asThreadId("thread-cross-provider-opencode"); + const kiloThreadId = asThreadId("thread-cross-provider-kilo"); + yield* openCode.startSession({ + provider: "opencode", + threadId: openCodeThreadId, + runtimeMode: "full-access", + providerOptions: { opencode: { serverUrl: "http://[::1]:4099/" } }, + }); + yield* kilo.startSession({ + provider: "kilo", + threadId: kiloThreadId, + runtimeMode: "full-access", + providerOptions: { kilo: { serverUrl: "http://127.0.0.1:4099" } }, + }); + const turn = yield* openCode.sendTurn({ + threadId: openCodeThreadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + events.push({ type: "session.idle", properties: {} }); + events.push({ + type: "session.error", + properties: { error: { data: { message: "ambiguous" } } }, + }); + yield* Effect.sleep(25); + const [openCodeSession] = yield* openCode.listSessions(); + const [kiloSession] = yield* kilo.listSessions(); + events.close(); + return { kiloSession, openCodeSession, turn }; + }).pipe( + Effect.provide( + Layer.merge( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ), + ); + + expect(result.openCodeSession).toMatchObject({ + status: "running", + activeTurnId: result.turn.turnId, + }); + expect(result.kiloSession?.status).toBe("ready"); + }); + + it("keeps a stopping context registered until provider abort finishes", async () => { + const events = createBroadcastSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-1", "opencode-session-2"], + abort: async (input) => { + if (input.sessionID === "opencode-session-2") { + events.push({ type: "session.idle", properties: {} }); + events.push({ + type: "session.error", + properties: { error: { data: { message: "stopping context" } } }, + }); + await Promise.resolve(); + } + return { data: null }; + }, + }); + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { subscribe: () => Promise<{ stream: AsyncIterable }> }; + }; + client.event.subscribe = async () => ({ stream: events.subscribe() }); + + const active = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const activeThreadId = asThreadId("thread-stop-race-active"); + const stoppingThreadId = asThreadId("thread-stop-race-stopping"); + yield* adapter.startSession({ + provider: "opencode", + threadId: activeThreadId, + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: "opencode", + threadId: stoppingThreadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: activeThreadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + yield* adapter.stopSession(stoppingThreadId); + yield* Effect.sleep(20); + const sessions = yield* adapter.listSessions(); + events.close(); + return { session: sessions.find((session) => session.threadId === activeThreadId), turn }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(active.session).toMatchObject({ + status: "running", + activeTurnId: active.turn.turnId, + }); + }); + + it("keeps an unexpectedly exiting context registered until provider abort finishes", async () => { + const activeEvents = createSubscribedEventQueue(); + let rejectFailedSubscription: ((cause: Error) => void) | undefined; + const failedSubscription = new Promise((_resolve, reject) => { + rejectFailedSubscription = reject; + }); + const failedStream = { + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => { + await failedSubscription; + return { value: undefined, done: true }; + }, + }; + }, + }; + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-1", "opencode-session-2"], + abort: async (input) => { + if (input.sessionID === "opencode-session-2") { + activeEvents.push({ type: "session.idle", properties: {} }); + activeEvents.push({ + type: "session.error", + properties: { error: { data: { message: "exiting context" } } }, + }); + } + return { data: null }; + }, + }); + const client = runtime.runtime.createOpenCodeSdkClient({ + baseUrl: "http://127.0.0.1:4099", + directory: process.cwd(), + }) as unknown as { + event: { subscribe: () => Promise<{ stream: AsyncIterable }> }; + }; + let subscriptionCount = 0; + client.event.subscribe = async () => { + subscriptionCount += 1; + return { stream: subscriptionCount === 1 ? activeEvents.stream : failedStream }; + }; + + const active = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const activeThreadId = asThreadId("thread-unexpected-exit-active"); + const failingThreadId = asThreadId("thread-unexpected-exit-failing"); + yield* adapter.startSession({ + provider: "opencode", + threadId: activeThreadId, + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: "opencode", + threadId: failingThreadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: activeThreadId, + input: "hello", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + rejectFailedSubscription?.(new Error("subscription failed")); + yield* Effect.sleep(30); + const sessions = yield* adapter.listSessions(); + activeEvents.close(); + return { session: sessions.find((session) => session.threadId === activeThreadId), turn }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(active.session).toMatchObject({ + status: "running", + activeTurnId: active.turn.turnId, + }); + }); + + it("keeps Kilo identity in prompt-response recovery provenance", async () => { + const runtime = createMockOpenCodeRuntime({ + prompt: async (input) => ({ + data: { + info: { + id: "msg-kilo-prompt-response", + role: "assistant", + parentID: input.messageID, + finish: "stop", + time: { completed: 2 }, + }, + parts: [ + { + id: "part-kilo-prompt-response", + messageID: "msg-kilo-prompt-response", + type: "text", + text: "done", + time: { start: 1, end: 2 }, + } as Part, + ], + }, + }), + }); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 6)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-kilo-prompt-response"); + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + return Array.from(yield* Fiber.join(eventsFiber)); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events[5]).toMatchObject({ + provider: "kilo", + raw: { + source: "kilo.sdk.event", + payload: { source: "scient.kilo.prompt.response" }, + }, + }); + expect(JSON.stringify(events)).not.toContain("scient.opencode"); + }); + + it("keeps Kilo identity in prompt watchdog errors", async () => { + const runtime = createMockOpenCodeRuntime(); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe( + Effect.forkChild, + ); + const threadId = asThreadId("thread-kilo-prompt-watchdog"); + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + return Array.from(yield* Fiber.join(eventsFiber)); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 10, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events[3]).toMatchObject({ + provider: "kilo", + type: "turn.completed", + raw: { + source: "kilo.sdk.event", + payload: { source: "scient.kilo.prompt.watchdog" }, + }, + payload: { state: "failed", errorMessage: expect.stringContaining("Kilo") }, + }); + expect(events[4]).toMatchObject({ + provider: "kilo", + type: "runtime.error", + payload: { message: expect.stringContaining("restart Kilo") }, + }); + expect(JSON.stringify(events)).not.toContain("scient.opencode"); + }); + + for (const eventType of ["session.error", "session.next.step.failed"] as const) { + it(`keeps Kilo identity in ${eventType} fallback errors`, async () => { + const eventQueue = createSubscribedEventQueue(); + const runtime = createMockOpenCodeRuntime(); + bindSubscribedEventQueue(runtime, eventQueue); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const threadId = asThreadId(`thread-kilo-fallback-${eventType}`); + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + if (eventType.startsWith("session.next.")) { + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-kilo-fallback-owner"); + eventQueue.push({ + type: "session.next.text.delta", + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + delta: "current generation progress", + }, + }); + } + eventQueue.push( + eventType === "session.error" + ? { + type: eventType, + properties: { sessionID: "opencode-session-1", error: {} }, + } + : { + type: eventType, + properties: { + timestamp: 1, + sessionID: "opencode-session-1", + error: { message: "" }, + }, + }, + ); + yield* Effect.sleep(30); + const [current] = yield* adapter.listSessions(); + eventQueue.close(); + return current; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + cancellationDrainQuietMs: 5, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(session?.status).toBe("error"); + expect(session?.lastError).toBe("Kilo session failed."); + }); + } + + it("keeps a session quarantined when stop cannot confirm provider cancellation", async () => { + const runtime = createMockOpenCodeRuntime({ + abort: async () => Promise.reject(new Error("abort rejected")), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stop-abort-rejected"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const stopExit = yield* Effect.exit(adapter.stopSession(threadId)); + const present = yield* adapter.hasSession(threadId); + const [session] = yield* adapter.listSessions(); + return { present, session, stopExit }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isFailure(result.stopExit)).toBe(true); + expect(result.present).toBe(true); + expect(result.session).toMatchObject({ + status: "error", + lastError: expect.stringContaining("abort rejected"), + }); + }); + + it("refuses replacement when the old provider session cannot be stopped", async () => { + const runtime = createMockOpenCodeRuntime({ + abort: async () => Promise.reject(new Error("replacement abort rejected")), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-replacement-abort-rejected"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const replacementExit = yield* Effect.exit( + adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }), + ); + return { + present: yield* adapter.hasSession(threadId), + replacementExit, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isFailure(result.replacementExit)).toBe(true); + expect(result.present).toBe(true); + expect(runtime.connectCalls).toHaveLength(1); + }); + + for (const provider of ["opencode", "kilo"] as const) { + it(`cleans up a claimed ${provider} generation when baseline capture is interrupted`, async () => { + let baselineStarted: (() => void) | undefined; + const baselineReady = new Promise((resolve) => { + baselineStarted = resolve; + }); + let firstMessageSignalAborted = false; + let messageCallCount = 0; + const runtime = createMockOpenCodeRuntime({ + messages: async (_input, requestOptions) => { + messageCallCount += 1; + if (messageCallCount > 1) { + return { data: [] }; + } + baselineStarted?.(); + return new Promise((resolve, reject) => { + requestOptions?.signal?.addEventListener( + "abort", + () => { + firstMessageSignalAborted = true; + reject(new Error("baseline interrupted")); + }, + { once: true }, + ); + }); + }, + }); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const threadId = asThreadId(`thread-interrupted-baseline-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const first = yield* adapter + .sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => baselineReady); + yield* Fiber.interrupt(first); + return yield* adapter.sendTurn({ + threadId, + input: "successor", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + }); + + const successor = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(firstMessageSignalAborted).toBe(true); + expect(runtime.abortCalls.length).toBeGreaterThanOrEqual(1); + expect(successor.turnId).toBeDefined(); + }); + + it(`cleans up a claimed ${provider} generation when prompt settlement wait is interrupted`, async () => { + let promptStarted: (() => void) | undefined; + const promptReady = new Promise((resolve) => { + promptStarted = resolve; + }); + let promptCallCount = 0; + const prompt = async () => { + promptCallCount += 1; + if (promptCallCount > 1) { + return { data: null }; + } + promptStarted?.(); + return new Promise(() => undefined); + }; + const runtime = createMockOpenCodeRuntime({ + messages: async () => ({ data: [] }), + ...(provider === "kilo" ? { prompt } : { promptAsync: prompt }), + }); + + const exercise = (adapter: OpenCodeAdapterShape | KiloAdapterShape) => + Effect.gen(function* () { + const threadId = asThreadId(`thread-interrupted-prompt-wait-${provider}`); + yield* adapter.startSession({ provider, threadId, runtimeMode: "full-access" }); + const first = yield* adapter + .sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => promptReady); + yield* Fiber.interrupt(first); + return yield* adapter.sendTurn({ + threadId, + input: "successor", + attachments: [], + modelSelection: { provider, model: "openai/gpt-5.4" }, + }); + }); + + const successor = + provider === "kilo" + ? await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* KiloAdapter); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptSubmissionInlineWaitMs: 10_000, + promptAcceptedActivityTimeoutMs: 1_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ) + : await Effect.runPromise( + Effect.gen(function* () { + return yield* exercise(yield* OpenCodeAdapter); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ + runtime: runtime.runtime, + promptSubmissionInlineWaitMs: 10_000, + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.abortCalls.length).toBeGreaterThanOrEqual(1); + expect(successor.turnId).toBeDefined(); + }); + } + + it("cancels an in-flight permission reply before replacing its session", async () => { + const eventQueue = createSubscribedEventQueue(); + let replyStarted: (() => void) | undefined; + const replyReady = new Promise((resolve) => { + replyStarted = resolve; + }); + let replySignalAborted = false; + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-1", "opencode-session-2"], + permissionReply: (_input, requestOptions) => + new Promise((resolve, reject) => { + replyStarted?.(); + requestOptions?.signal?.addEventListener( + "abort", + () => { + replySignalAborted = true; + reject(new Error("reply retired")); + }, + { once: true }, + ); + void resolve; + }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-replace-pending-permission"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-replacement-permission-owner"); + eventQueue.push({ + type: "permission.asked", + properties: { + id: "permission-replacement", + sessionID: "opencode-session-1", + permission: "bash", + patterns: ["rm file"], + metadata: {}, + always: [], + }, + }); + yield* Effect.sleep(10); + const replyFiber = yield* adapter + .respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe("permission-replacement"), + "accept", + ) + .pipe(Effect.forkChild); + yield* Effect.promise(() => replyReady); + const replacement = yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + const replyExit = yield* Fiber.await(replyFiber); + eventQueue.close(); + return { replacement, replyExit }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(replySignalAborted).toBe(true); + expect(Exit.isFailure(result.replyExit)).toBe(true); + expect(result.replacement.resumeCursor).toMatchObject({ + openCodeSessionId: "opencode-session-2", + }); + }); + + it("cancels an in-flight question reply during adapter shutdown", async () => { + const eventQueue = createSubscribedEventQueue(); + let replyStarted: (() => void) | undefined; + const replyReady = new Promise((resolve) => { + replyStarted = resolve; + }); + let replySignalAborted = false; + const runtime = createMockOpenCodeRuntime({ + questionReply: (_input, requestOptions) => + new Promise((resolve, reject) => { + replyStarted?.(); + requestOptions?.signal?.addEventListener( + "abort", + () => { + replySignalAborted = true; + reject(new Error("question retired")); + }, + { once: true }, + ); + void resolve; + }), + }); + bindSubscribedEventQueue(runtime, eventQueue); + + const replyExit = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-shutdown-pending-question"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "first", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + pushActivePromptEcho(eventQueue, runtime); + pushActiveAssistantOwnership(eventQueue, runtime, "msg-shutdown-question-owner"); + eventQueue.push({ + type: "question.asked", + properties: { + id: "question-shutdown", + sessionID: "opencode-session-1", + questions: [ + { + question: "Proceed?", + header: "Confirm", + options: [{ label: "Yes", description: "" }], + multiple: false, + custom: false, + }, + ], + }, + }); + yield* Effect.sleep(10); + const replyFiber = yield* adapter + .respondToUserInput(threadId, ApprovalRequestId.makeUnsafe("question-shutdown"), {}) + .pipe(Effect.forkChild); + yield* Effect.promise(() => replyReady); + yield* adapter.stopAll(); + eventQueue.close(); + return yield* Fiber.await(replyFiber); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(replySignalAborted).toBe(true); + expect(Exit.isFailure(replyExit)).toBe(true); + }); + + it("bounds Kilo busy-turn transcript recovery requests", async () => { + const runtime = createMockOpenCodeRuntime({ + status: async () => ({ data: { "opencode-session-1": { type: "busy" } } }), + messages: async () => ({ data: [] }), + }); + + await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const threadId = asThreadId("thread-kilo-bounded-recovery"); + yield* adapter.startSession({ provider: "kilo", threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "hello", + attachments: [], + modelSelection: { provider: "kilo", model: "openai/gpt-5.4" }, + }); + yield* Effect.sleep(550); + yield* adapter.stopSession(threadId); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ + runtime: runtime.runtime, + promptAcceptedActivityTimeoutMs: 5_000, + promptAcceptedRecoveryDelaysMs: [], + }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.messageCalls[0]).toEqual({ sessionID: "opencode-session-1" }); + expect(runtime.messageCalls.slice(1)).toEqual( + expect.arrayContaining([{ sessionID: "opencode-session-1", limit: 256 }]), + ); + expect(runtime.messageCalls.slice(1).every((call) => call?.limit === 256)).toBe(true); + }); + + it("single-flights concurrent replacements of the same provider context", async () => { + let releaseAbort: (() => void) | undefined; + let notifyAbortStarted: (() => void) | undefined; + const abortStarted = new Promise((resolve) => { + notifyAbortStarted = resolve; + }); + let abortInvocationCount = 0; + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-old", "opencode-session-successor"], + abort: async () => { + abortInvocationCount += 1; + if (abortInvocationCount === 1) { + notifyAbortStarted?.(); + await new Promise((resolve) => { + releaseAbort = resolve; + }); + } + return { data: null }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-concurrent-replacements"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const first = yield* adapter + .startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => abortStarted); + const second = yield* adapter + .startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit, Effect.forkChild); + const secondExit = yield* Fiber.join(second); + releaseAbort?.(); + const firstExit = yield* Fiber.join(first); + const sessions = yield* adapter.listSessions(); + return { + oldSessionAbortCallsBeforeFinalization: runtime.abortCalls.filter( + (call) => call.sessionID === "opencode-session-old", + ).length, + firstExit, + secondExit, + sessions, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.oldSessionAbortCallsBeforeFinalization).toBe(1); + expect([result.firstExit, result.secondExit].filter(Exit.isSuccess)).toHaveLength(1); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.resumeCursor).toMatchObject({ + openCodeSessionId: "opencode-session-successor", + }); + }); + + it("does not emit an unexpected exit while a requested stop owns retirement", async () => { + let rejectSubscription: ((cause: Error) => void) | undefined; + const failedStream = { + [Symbol.asyncIterator]() { + return { + next: () => + new Promise>((_resolve, reject) => { + rejectSubscription = reject; + }), + }; + }, + }; + let releaseAbort: (() => void) | undefined; + let notifyAbortStarted: (() => void) | undefined; + const abortStarted = new Promise((resolve) => { + notifyAbortStarted = resolve; + }); + const runtime = createMockOpenCodeRuntime({ + events: failedStream, + abort: async () => { + notifyAbortStarted?.(); + await new Promise((resolve) => { + releaseAbort = resolve; + }); + return { data: null }; + }, + }); + + const events = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observed: Array<{ readonly type: string; readonly payload: unknown }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => observed.push(event)), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-stop-vs-unexpected-exit"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted); + rejectSubscription?.(new Error("subscription failed during stop")); + yield* Effect.sleep(5); + releaseAbort?.(); + yield* Fiber.join(stopFiber); + yield* Effect.sleep(10); + yield* Fiber.interrupt(eventsFiber); + return observed; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(events.filter((event) => event.type === "session.exited")).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ exitKind: "graceful" }), + }), + ]); + expect(events.some((event) => event.type === "runtime.error")).toBe(false); + }); + + it("retains an external session when unexpected-exit cancellation fails and allows stop retry", async () => { + let rejectSubscription: ((cause: Error) => void) | undefined; + const failedStream = { + [Symbol.asyncIterator]() { + return { + next: () => + new Promise>((_resolve, reject) => { + rejectSubscription = reject; + }), + }; + }, + }; + let abortCount = 0; + const runtime = createMockOpenCodeRuntime({ + events: failedStream, + abort: async () => { + abortCount += 1; + if (abortCount === 1) { + throw new Error("unexpected-exit abort rejected"); + } + return { data: null }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observed: Array<{ readonly type: string }> = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => observed.push(event)), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-unexpected-exit-abort-retry"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + providerOptions: { opencode: { serverUrl: "http://127.0.0.1:4099" } }, + }); + yield* adapter.sendTurn({ + threadId, + input: "keep authority", + attachments: [], + modelSelection: { provider: "opencode", model: "openai/gpt-5.4" }, + }); + rejectSubscription?.(new Error("external event stream failed")); + yield* Effect.sleep(20); + const [retained] = yield* adapter.listSessions(); + const retainedBeforeRetry = yield* adapter.hasSession(threadId); + const terminalEventsBeforeRetry = observed.filter( + (event) => event.type === "session.exited", + ).length; + yield* adapter.stopSession(threadId); + const retainedAfterRetry = yield* adapter.hasSession(threadId); + yield* Fiber.interrupt(eventsFiber); + return { + retained, + retainedAfterRetry, + retainedBeforeRetry, + terminalEventsBeforeRetry, + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.retainedBeforeRetry).toBe(true); + expect(result.retained).toMatchObject({ + status: "error", + lastError: expect.stringContaining("could not be confirmed"), + }); + expect(result.terminalEventsBeforeRetry).toBe(0); + expect(result.retainedAfterRetry).toBe(false); + expect(abortCount).toBe(2); + }); + + it("finalizes a managed session after confirmed local process exit without requiring abort", async () => { + let resolveExit: ((code: number) => void) | undefined; + const exitCode = new Promise((resolve) => { + resolveExit = resolve; + }); + let abortCount = 0; + const runtime = createMockOpenCodeRuntime({ + connection: { + url: "http://127.0.0.1:4099", + external: false, + exitCode: Effect.promise(() => exitCode), + }, + abort: async () => { + abortCount += 1; + throw new Error("server is already unavailable"); + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const observed: Array<{ readonly type: string; readonly payload: unknown }> = []; + const terminalObserved = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + observed.push(event); + if (event.type === "session.exited") { + Effect.runSync(Deferred.succeed(terminalObserved, undefined).pipe(Effect.ignore)); + } + }), + ).pipe(Effect.forkChild); + const threadId = asThreadId("thread-managed-server-exit"); + yield* adapter.startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }); + resolveExit?.(17); + yield* Deferred.await(terminalObserved); + const retained = yield* adapter.hasSession(threadId); + yield* Fiber.interrupt(eventsFiber); + return { observed, retained }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.retained).toBe(false); + expect(abortCount).toBe(0); + expect(result.observed.filter((event) => event.type === "runtime.error")).toHaveLength(1); + expect(result.observed.filter((event) => event.type === "session.exited")).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ exitKind: "error", recoverable: false }), + }), + ]); + }); + + for (const operation of ["stop", "replace"] as const) { + it(`finishes ${operation} retirement coherently when its caller is interrupted`, async () => { + let releaseAbort: (() => void) | undefined; + let notifyAbortStarted: (() => void) | undefined; + const abortStarted = new Promise((resolve) => { + notifyAbortStarted = resolve; + }); + let abortCount = 0; + const runtime = createMockOpenCodeRuntime({ + sessionIds: ["opencode-session-old", "opencode-session-retry"], + abort: async () => { + abortCount += 1; + if (abortCount === 1) { + notifyAbortStarted?.(); + await new Promise((resolve) => { + releaseAbort = resolve; + }); + } + return { data: null }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-interrupted-${operation}-retirement`); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + }); + const lifecycleFiber = yield* ( + operation === "stop" + ? adapter.stopSession(threadId) + : adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + }) + ).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted); + const interruptFiber = yield* Fiber.interrupt(lifecycleFiber).pipe(Effect.forkChild); + yield* Effect.sleep(1); + releaseAbort?.(); + yield* Fiber.join(interruptFiber); + const retry = yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + }); + return { retry, sessions: yield* adapter.listSessions() }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.sessions).toHaveLength(1); + expect(result.retry.threadId).toBe(asThreadId(`thread-interrupted-${operation}-retirement`)); + }); + } + + it("forcibly closes local adapter resources when final abort is rejected", async () => { + const runtime = createMockOpenCodeRuntime({ + abort: async () => Promise.reject(new Error("final abort rejected")), + }); + + await expect( + Promise.race([ + Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-finalizer-abort-rejected"), + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("adapter finalizer did not terminate")), 250), + ), + ]), + ).resolves.toBeUndefined(); + expect(runtime.abortCalls).toHaveLength(1); + }); + + it("reports failure when a concurrent-start loser cannot abort its provider session", async () => { + let createCount = 0; + let releaseCreates: (() => void) | undefined; + const createsReleased = new Promise((resolve) => { + releaseCreates = resolve; + }); + const runtime = createMockOpenCodeRuntime({ + create: async () => { + const index = createCount++; + if (createCount === 2) { + releaseCreates?.(); + } + await createsReleased; + return { data: { id: `opencode-session-${index + 1}` } }; + }, + abort: async () => Promise.reject(new Error("loser abort rejected")), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-concurrent-start-loser-abort"); + const first = yield* adapter + .startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit, Effect.forkChild); + const second = yield* adapter + .startSession({ provider: "opencode", threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit, Effect.forkChild); + const exits = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + return { exits, sessions: yield* adapter.listSessions() }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(result.exits.filter(Exit.isSuccess)).toHaveLength(1); + expect(result.exits.filter(Exit.isFailure)).toHaveLength(1); + expect(result.sessions).toHaveLength(1); + }); }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index f55ef4c70..482be50bc 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -64,6 +64,7 @@ import { import { appendFileAttachmentsPromptBlock } from "../attachmentProjection.ts"; import { extractProposedPlanMarkdown, withProviderPlanModePrompt } from "../planMode.ts"; import { makeRuntimeTaskListItem, nonEmptyRuntimeTaskListPayload } from "../runtimeTaskList.ts"; +import { resolveAcpTurnIdleTimeoutMs } from "../acp/AcpTurnIdleWatchdog.ts"; type OpenCodeCompatibleProvider = Extract; @@ -112,6 +113,26 @@ const KILO_ADAPTER_CONFIG: OpenCodeCompatibleAdapterConfig = { const OPENCODE_PROMPT_ACCEPTED_ACTIVITY_TIMEOUT_MS = 60_000; const OPENCODE_PROMPT_ACCEPTED_RECOVERY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000] as const; const OPENCODE_PROMPT_SUBMISSION_INLINE_WAIT_MS = 500; +const OPENCODE_PREMATURE_IDLE_COMPLETION_GRACE_MS = 10_000; +const OPENCODE_COMPLETION_SNAPSHOT_TIMEOUT_MS = 5_000; +const OPENCODE_CANCELLATION_DRAIN_QUIET_MS = 100; +const OPENCODE_EVENT_DEDUPE_LIMIT = 2_048; +const OPENCODE_TURN_IDLE_TIMEOUT_MS = 600_000; +const KILO_TURN_IDLE_TIMEOUT_MS = 600_000; + +export function resolveOpenCodeTurnIdleTimeoutMs( + provider: OpenCodeCompatibleProvider, + env: NodeJS.ProcessEnv = process.env, +): number { + return resolveAcpTurnIdleTimeoutMs({ + envVar: + provider === "opencode" + ? "SYNARA_OPENCODE_TURN_IDLE_TIMEOUT_MS" + : "SYNARA_KILO_TURN_IDLE_TIMEOUT_MS", + defaultMs: provider === "opencode" ? OPENCODE_TURN_IDLE_TIMEOUT_MS : KILO_TURN_IDLE_TIMEOUT_MS, + env, + }); +} type OpenCodeSubscribedEvent = Awaited> extends { @@ -125,6 +146,27 @@ interface OpenCodeTurnSnapshot { readonly items: Array; } +interface OpenCodeTurnGeneration { + readonly id: number; + readonly turnId: TurnId; + readonly promptMessageId: string; + state: "active" | "cancelling"; + cancellationFinalizerStarted: boolean; + interactionOwned: boolean; + sessionNextOwned: boolean; + sessionNextProgressObserved: boolean; + sessionNextTimestampFloor: number | undefined; + sessionNextLatestAcceptedTimestamp: number | undefined; + lastOwnedProgressAtMs: number; +} + +interface OpenCodeInteractionReplyState { + readonly generationId: number; + readonly abortController: AbortController; + acknowledged: boolean; + cancelled: boolean; +} + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -132,9 +174,14 @@ interface OpenCodeSessionContext { readonly directory: string; readonly openCodeSessionId: string; readonly pendingPermissions: Map; + readonly pendingPermissionGenerationById: Map; + readonly permissionReplyStateById: Map; /** Permission request ids auto-approved server-side in full-access mode (never surfaced to the UI). */ readonly autoApprovedPermissionIds: Set; + readonly autoApprovedPermissionGenerationById: Map; readonly pendingQuestions: Map; + readonly pendingQuestionGenerationById: Map; + readonly questionReplyStateById: Map; readonly pendingTextDeltasByPartId: Map; readonly messageRoleById: Map; readonly messageSnapshotKeyById: Map; @@ -142,24 +189,91 @@ interface OpenCodeSessionContext { readonly partSnapshotKeyById: Map; readonly emittedTextByPartId: Map; readonly completedAssistantPartIds: Set; + readonly projectedOwnedPartSnapshotKeyById: Map; + readonly seenSubscribedEventIds: Set; + readonly subscribedEventDedupeLimit: number; readonly relatedSessionIds: Set; readonly turns: Array; readonly modelContextLimitBySlug: Map; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastEmittedTokenUsageKey: string | undefined; latestTurnCostUsd: number | undefined; + turnGenerationCounter: number; + activeTurnGeneration: OpenCodeTurnGeneration | undefined; activeTurnId: TurnId | undefined; activeTurnEventSerial: number; activeTurnProviderActivitySerial: number; activeTurnCompletionActivitySerial: number; activeTurnSawToolCallFinish: boolean; activeTurnSawFinalAssistant: boolean; + latestObservedSessionNextTimestamp: number | undefined; + activeTurnFinalAssistantMessageId: string | undefined; + activeTurnPromptMessageId: string | undefined; + activeTurnBaselineTrusted: boolean; + readonly activeTurnBaselineMessageIds: Set; + readonly activeTurnAssistantMessageIds: Set; + readonly activeTurnRejectedAssistantMessageIds: Set; + readonly activeTurnToolCallAssistantMessageIds: Set; + readonly activeTurnUnsettledAssistantTextIds: Set; activeTurnToolCallIdleWatchdogStarted: boolean; + requiresStrictOwnershipUntilBoundary: boolean; + cancellationDrainPending: boolean; + cancellationInFlight: boolean; + quarantinedAfterAbortFailure: boolean; + activeTurnAcceptsSessionNextEvents: boolean; activeInteractionMode: "default" | "plan" | undefined; activeAgent: string | undefined; activeVariant: string | undefined; readonly stopped: Ref.Ref; readonly sessionScope: Scope.Closeable; + retirementAttempt: Deferred.Deferred | undefined; + replacementClaimed: boolean; +} + +const openCodeContextsByServerIdentity = new Map>(); + +function canonicalOpenCodeServerIdentity(value: string): string { + try { + const url = new URL(value); + const protocol = url.protocol.toLowerCase(); + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + const canonicalHostname = ["localhost", "127.0.0.1", "::1"].includes(hostname) + ? "loopback" + : hostname; + const defaultPort = + (protocol === "http:" && url.port === "80") || (protocol === "https:" && url.port === "443"); + const port = defaultPort || url.port.length === 0 ? "" : `:${url.port}`; + const pathname = url.pathname.replace(/\/+$/u, ""); + return `${protocol}//${canonicalHostname}${port}${pathname}`; + } catch { + return value.trim().toLowerCase().replace(/\/+$/u, ""); + } +} + +function registerOpenCodeContext(context: OpenCodeSessionContext): void { + const identity = canonicalOpenCodeServerIdentity(context.server.url); + const contexts = openCodeContextsByServerIdentity.get(identity) ?? new Set(); + contexts.add(context); + openCodeContextsByServerIdentity.set(identity, contexts); +} + +function unregisterOpenCodeContext(context: OpenCodeSessionContext): void { + const identity = canonicalOpenCodeServerIdentity(context.server.url); + const contexts = openCodeContextsByServerIdentity.get(identity); + if (!contexts) { + return; + } + contexts.delete(context); + if (contexts.size === 0) { + openCodeContextsByServerIdentity.delete(identity); + } +} + +function isOnlyContextAttachedToOpenCodeServer(context: OpenCodeSessionContext): boolean { + const contexts = openCodeContextsByServerIdentity.get( + canonicalOpenCodeServerIdentity(context.server.url), + ); + return contexts?.size === 1 && contexts.has(context); } interface OpenCodeMessageSnapshot { @@ -170,6 +284,7 @@ interface OpenCodeMessageSnapshot { readonly completed?: number; }; readonly finish?: string; + readonly parentID?: string; }; readonly parts: ReadonlyArray; } @@ -182,6 +297,11 @@ export interface OpenCodeAdapterLiveOptions { readonly promptAcceptedActivityTimeoutMs?: number; readonly promptAcceptedRecoveryDelaysMs?: ReadonlyArray; readonly promptSubmissionInlineWaitMs?: number; + readonly prematureIdleCompletionGraceMs?: number; + readonly completionSnapshotTimeoutMs?: number; + readonly cancellationDrainQuietMs?: number; + readonly eventDedupeLimit?: number; + readonly turnStallTimeoutMs?: number; } function nowIso(): string { @@ -563,13 +683,9 @@ function isoFromEpochMs(value: number | undefined): string | undefined { function messageRoleForPart( context: OpenCodeSessionContext, - part: Pick, + part: Pick, ): "assistant" | "user" | undefined { - const known = context.messageRoleById.get(part.messageID); - if (known) { - return known; - } - return part.type === "tool" ? "assistant" : undefined; + return context.messageRoleById.get(part.messageID); } function detailFromToolPart(part: Extract): string | undefined { @@ -597,9 +713,9 @@ function toolStateCreatedAt(part: Extract): string | und } } -function sessionErrorMessage(error: unknown): string { +function sessionErrorMessage(error: unknown, fallback: string): string { if (!error || typeof error !== "object") { - return "OpenCode session failed."; + return fallback; } const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; const message = @@ -610,9 +726,7 @@ function sessionErrorMessage(error: unknown): string { : "error" in error ? error.error : null; - return typeof message === "string" && message.trim().length > 0 - ? message - : "OpenCode session failed."; + return typeof message === "string" && message.trim().length > 0 ? message : fallback; } function isOpenCodeContextOverflowError(error: unknown): boolean { @@ -631,6 +745,21 @@ function isOpenCodeContextOverflowError(error: unknown): boolean { ); } +function isOpenCodeCancellationError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const name = "name" in error ? trimNonEmptyString(error.name) : undefined; + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; + const message = + data && "message" in data + ? trimNonEmptyString(data.message) + : "message" in error + ? trimNonEmptyString(error.message) + : undefined; + return name === "MessageAbortedError" || Boolean(message && /abort|cancel/iu.test(message)); +} + function updateProviderSession( context: OpenCodeSessionContext, patch: Partial, @@ -655,14 +784,81 @@ function updateProviderSession( return nextSession; } -function clearActiveTurnState(context: OpenCodeSessionContext): void { +function isOpenCodeTurnGenerationActive( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration | undefined, +): generation is OpenCodeTurnGeneration { + return ( + generation !== undefined && + context.activeTurnGeneration === generation && + generation.state === "active" && + context.activeTurnId === generation.turnId + ); +} + +function isCurrentOpenCodeTurnGeneration( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration | undefined, +): generation is OpenCodeTurnGeneration { + return ( + generation !== undefined && + context.activeTurnGeneration === generation && + context.activeTurnId === generation.turnId + ); +} + +function claimOpenCodeTurnCancellation( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration, +): boolean { + if (!isOpenCodeTurnGenerationActive(context, generation)) { + return false; + } + generation.state = "cancelling"; + generation.interactionOwned = false; + generation.sessionNextOwned = false; + generation.sessionNextProgressObserved = false; + context.activeTurnAcceptsSessionNextEvents = false; + context.requiresStrictOwnershipUntilBoundary = true; + context.cancellationDrainPending = true; + context.cancellationInFlight = true; + return true; +} + +function markOpenCodeTurnOwnedProgress( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration | undefined, +): void { + if (!isOpenCodeTurnGenerationActive(context, generation)) { + return; + } + generation.lastOwnedProgressAtMs = Date.now(); +} + +function clearActiveTurnState( + context: OpenCodeSessionContext, + generation?: OpenCodeTurnGeneration, +): boolean { + if (generation !== undefined && context.activeTurnGeneration !== generation) { + return false; + } + context.activeTurnGeneration = undefined; context.activeTurnId = undefined; context.activeTurnEventSerial = 0; context.activeTurnProviderActivitySerial = 0; context.activeTurnCompletionActivitySerial = 0; context.activeTurnSawToolCallFinish = false; context.activeTurnSawFinalAssistant = false; + context.activeTurnFinalAssistantMessageId = undefined; + context.activeTurnPromptMessageId = undefined; + context.activeTurnBaselineTrusted = false; + context.activeTurnBaselineMessageIds.clear(); + context.activeTurnAssistantMessageIds.clear(); + context.activeTurnRejectedAssistantMessageIds.clear(); + context.activeTurnToolCallAssistantMessageIds.clear(); + context.activeTurnUnsettledAssistantTextIds.clear(); context.activeTurnToolCallIdleWatchdogStarted = false; + context.activeTurnAcceptsSessionNextEvents = false; context.activeInteractionMode = undefined; context.activeAgent = undefined; context.activeVariant = undefined; @@ -671,8 +867,89 @@ function clearActiveTurnState(context: OpenCodeSessionContext): void { // Deliberately NOT cleared here: a permission auto-approved at the tail of a turn can // have its permission.replied echo arrive after turn teardown, and dropping the id first // would misclassify that echo as a real resolution (orphaned "Approval resolved" in the - // UI). Ids are unique per request so stale entries are inert; the set is freed with the - // session context when the session is removed. + // UI). The insertion path bounds these tombstones when a provider omits reply echoes. + return true; +} + +function establishOpenCodeTurnEventBoundary( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, +): void { + const generation = context.activeTurnGeneration; + if ( + !turnId || + !isOpenCodeTurnGenerationActive(context, generation) || + generation.turnId !== turnId + ) { + return; + } + if (!generation.sessionNextOwned) { + const observedTimestamp = context.latestObservedSessionNextTimestamp; + if ( + observedTimestamp !== undefined && + (generation.sessionNextTimestampFloor === undefined || + observedTimestamp > generation.sessionNextTimestampFloor) + ) { + generation.sessionNextTimestampFloor = observedTimestamp; + generation.sessionNextLatestAcceptedTimestamp = observedTimestamp; + } + } + generation.sessionNextOwned = true; + context.activeTurnAcceptsSessionNextEvents = true; +} + +function establishOpenCodeInteractionOwnership( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, +): void { + const generation = context.activeTurnGeneration; + if (!turnId || !isOpenCodeTurnGenerationActive(context, generation)) { + return; + } + generation.interactionOwned = true; +} + +function establishOpenCodePromptOwnership( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, +): void { + // The exact user-message echo is sufficient to fence permission/question + // ownership, but it deliberately does not open the identity-free session.next + // lane or clear post-cancellation output quarantine. Those still require an + // assistant explicitly parented to this generation's prompt. + establishOpenCodeInteractionOwnership(context, turnId); + markOpenCodeTurnOwnedProgress(context, context.activeTurnGeneration); +} + +function establishOpenCodeAssistantOwnership( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, +): void { + if (!turnId || context.activeTurnId !== turnId) { + return; + } + context.requiresStrictOwnershipUntilBoundary = false; + context.cancellationDrainPending = false; + establishOpenCodeInteractionOwnership(context, turnId); + establishOpenCodeTurnEventBoundary(context, turnId); + markOpenCodeTurnOwnedProgress(context, context.activeTurnGeneration); +} + +function openCodeInteractionBelongsToActiveGeneration( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration | undefined, + tool: { readonly messageID: string } | undefined, +): generation is OpenCodeTurnGeneration { + if (!isOpenCodeTurnGenerationActive(context, generation)) { + return false; + } + if (tool !== undefined) { + if (!context.activeTurnAssistantMessageIds.has(tool.messageID)) { + return false; + } + establishOpenCodeInteractionOwnership(context, generation.turnId); + } + return generation.interactionOwned; } function markOpenCodeTurnProviderActivity( @@ -763,15 +1040,114 @@ function trackActiveTurnAssistantFinish( if (!turnId || context.activeTurnId !== turnId || entry.info.role !== "assistant") { return; } + const parentId = trimNonEmptyString(entry.info.parentID); + const promptMessageId = context.activeTurnPromptMessageId; + const alreadyAccepted = context.activeTurnAssistantMessageIds.has(entry.info.id); + if ( + !alreadyAccepted && + (context.activeTurnBaselineMessageIds.has(entry.info.id) || + context.activeTurnRejectedAssistantMessageIds.has(entry.info.id) || + promptMessageId === undefined || + parentId !== promptMessageId) + ) { + context.activeTurnRejectedAssistantMessageIds.add(entry.info.id); + return; + } + context.activeTurnAssistantMessageIds.add(entry.info.id); + establishOpenCodeAssistantOwnership(context, turnId); + markOpenCodeTurnOwnedProgress(context, context.activeTurnGeneration); if (isOpenCodeToolCallFinish(entry.info.finish)) { context.activeTurnSawToolCallFinish = true; + context.activeTurnToolCallAssistantMessageIds.add(entry.info.id); } if (isOpenCodeCompletedAssistantMessage(entry)) { context.activeTurnSawFinalAssistant = true; + context.activeTurnFinalAssistantMessageId = entry.info.id; markOpenCodeTurnCompletionActivity(context, turnId); } } +function trackActiveTurnAssistantTextPart( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + part: Part, +): boolean { + if (!turnId || context.activeTurnId !== turnId) { + return false; + } + if ( + (context.requiresStrictOwnershipUntilBoundary || !context.activeTurnBaselineTrusted) && + !context.activeTurnAssistantMessageIds.has(part.messageID) + ) { + return false; + } + if ( + context.activeTurnBaselineMessageIds.has(part.messageID) || + context.activeTurnRejectedAssistantMessageIds.has(part.messageID) + ) { + return false; + } + context.activeTurnAssistantMessageIds.add(part.messageID); + markOpenCodeTurnOwnedProgress(context, context.activeTurnGeneration); + if (part.type === "text" && shouldProjectOpenCodeTextPart(part)) { + if (part.time?.end === undefined) { + context.activeTurnUnsettledAssistantTextIds.add(part.id); + } else { + context.activeTurnUnsettledAssistantTextIds.delete(part.id); + } + } + return true; +} + +function activeTurnSettledAssistantMessageId(context: OpenCodeSessionContext): string | undefined { + const settledIds = Array.from(context.activeTurnAssistantMessageIds).filter( + (messageId) => + !context.activeTurnBaselineMessageIds.has(messageId) && + !context.activeTurnRejectedAssistantMessageIds.has(messageId) && + !context.activeTurnToolCallAssistantMessageIds.has(messageId) && + isOpenCodeAssistantMessageTextSettled(context, messageId), + ); + return settledIds.length === 1 ? settledIds[0] : undefined; +} + +function isCompletedOpenCodeAssistantTextPart(part: Part): boolean { + return ( + part.type === "text" && shouldProjectOpenCodeTextPart(part) && part.time?.end !== undefined + ); +} + +function areOpenCodeAssistantTextPartsSettled(parts: ReadonlyArray): boolean { + let sawVisibleTextPart = false; + for (const part of parts) { + if (part.type !== "text" || !shouldProjectOpenCodeTextPart(part)) { + continue; + } + sawVisibleTextPart = true; + if (!isCompletedOpenCodeAssistantTextPart(part)) { + return false; + } + } + return sawVisibleTextPart; +} + +function isOpenCodeAssistantMessageTextSettled( + context: OpenCodeSessionContext, + messageId: string, +): boolean { + const messageParts = Array.from(context.partById.values()).filter( + (part) => part.messageID === messageId, + ); + return ( + areOpenCodeAssistantTextPartsSettled(messageParts) && + messageParts.every( + (part) => + part.type !== "text" || + !shouldProjectOpenCodeTextPart(part) || + context.completedAssistantPartIds.has(part.id), + ) + ); +} + function extractResumeSessionId(resumeCursor: unknown): string | undefined { if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { return resumeCursor.trim(); @@ -890,6 +1266,10 @@ function subscribedEventSessionId(event: OpenCodeSubscribedEvent): string | unde return typeof sessionId === "string" ? sessionId : undefined; } +function subscribedEventId(event: OpenCodeSubscribedEvent): string | undefined { + return "id" in event ? trimNonEmptyString(event.id) : undefined; +} + function shouldHandleRelatedOpenCodeSessionEvent(event: OpenCodeSubscribedEvent): boolean { if (event.type.startsWith("session.next.tool.") || event.type.startsWith("session.next.shell.")) { return true; @@ -910,6 +1290,7 @@ function shouldHandleRelatedOpenCodeSessionEvent(event: OpenCodeSubscribedEvent) function shouldHandleSubscribedEvent( context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, + sessionlessTerminalEventIsUnambiguous: boolean, ): boolean { const sessionId = subscribedEventSessionId(event); if (sessionId !== undefined) { @@ -921,27 +1302,22 @@ function shouldHandleSubscribedEvent( return ( context.activeTurnId !== undefined && + sessionlessTerminalEventIsUnambiguous && (event.type === "session.error" || event.type === "session.idle") ); } -function isOpenCodeTurnProviderActivityEvent( - context: OpenCodeSessionContext, - event: OpenCodeSubscribedEvent, -): boolean { +function isOpenCodeTurnProviderActivityEvent(event: OpenCodeSubscribedEvent): boolean { switch (event.type) { case "message.updated": - return event.properties.info.role === "assistant"; - case "message.part.delta": { - const part = context.partById.get(event.properties.partID); - return part ? messageRoleForPart(context, part) === "assistant" : false; - } + case "message.part.delta": case "message.part.updated": - return messageRoleForPart(context, event.properties.part) === "assistant"; + // Assistant events are counted only after turn ownership is accepted in + // the event-specific handlers below. Replayed stale output must not silence + // the prompt watchdog for the current turn. + return false; case "session.status": return event.properties.status.type === "busy" || event.properties.status.type === "retry"; - case "permission.asked": - case "question.asked": case "todo.updated": case "session.compacted": case "session.error": @@ -1696,20 +2072,94 @@ function buildOpenCodeThreadSnapshot(input: { }; } +const abortOpenCodeClientSessionBounded = Effect.fn("abortOpenCodeClientSessionBounded")(function* ( + client: OpencodeClient, + openCodeSessionId: string, + timeoutMs = OPENCODE_COMPLETION_SNAPSHOT_TIMEOUT_MS, +) { + const result = yield* Effect.acquireUseRelease( + Effect.sync(() => new AbortController()), + (controller) => + runOpenCodeSdk("session.abort", () => + client.session.abort({ sessionID: openCodeSessionId }, { signal: controller.signal }), + ).pipe(Effect.timeoutOption(timeoutMs)), + (controller) => Effect.sync(() => controller.abort()), + ); + if (result._tag === "None") { + return yield* new OpenCodeRuntimeError({ + operation: "session.abort", + detail: `session.abort did not settle within ${timeoutMs}ms.`, + }); + } +}); + +const abortOpenCodeSessionBounded = Effect.fn("abortOpenCodeSessionBounded")(function* ( + context: OpenCodeSessionContext, + timeoutMs = OPENCODE_COMPLETION_SNAPSHOT_TIMEOUT_MS, +) { + yield* abortOpenCodeClientSessionBounded(context.client, context.openCodeSessionId, timeoutMs); +}); + const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( context: OpenCodeSessionContext, ) { - if (yield* Ref.getAndSet(context.stopped, true)) { + const observedAttempt = context.retirementAttempt; + if (observedAttempt !== undefined) { + return yield* Deferred.await(observedAttempt); + } + if (yield* Ref.get(context.stopped)) { return; } - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); - - yield* Scope.close(context.sessionScope, Exit.void); + const proposedAttempt = yield* Deferred.make(); + const racedAttempt = context.retirementAttempt; + if (racedAttempt !== undefined) { + return yield* Deferred.await(racedAttempt); + } + if (yield* Ref.get(context.stopped)) { + return; + } + context.retirementAttempt = proposedAttempt; + + // Do not discard observation or report a graceful stop until the provider has + // confirmed cancellation. External/shared servers can otherwise keep running + // full-access work after Scient has forgotten the session. + const stopExit = yield* Effect.exit( + Effect.uninterruptible( + Effect.gen(function* () { + yield* abortOpenCodeSessionBounded(context); + yield* Ref.set(context.stopped, true); + yield* Scope.close(context.sessionScope, Exit.void).pipe( + Effect.ensuring(Effect.sync(() => unregisterOpenCodeContext(context))), + ); + }), + ), + ); + if (Exit.isFailure(stopExit)) { + context.retirementAttempt = undefined; + const cause = Cause.squash(stopExit.cause); + const runtimeError = OpenCodeRuntimeError.is(cause) + ? cause + : new OpenCodeRuntimeError({ + operation: "session.stop", + detail: openCodeRuntimeErrorDetail(cause), + cause, + }); + yield* Deferred.fail(proposedAttempt, runtimeError); + return yield* runtimeError; + } + yield* Deferred.succeed(proposedAttempt, undefined); }); +const forceDisposeOpenCodeContextLocally = Effect.fn("forceDisposeOpenCodeContextLocally")( + function* (context: OpenCodeSessionContext) { + yield* Ref.set(context.stopped, true); + yield* Scope.close(context.sessionScope, Exit.void).pipe( + Effect.ensuring(Effect.sync(() => unregisterOpenCodeContext(context))), + ); + }, +); + export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const adapterConfig = options?.adapterConfig ?? OPENCODE_ADAPTER_CONFIG; return Layer.effect( @@ -1743,6 +2193,36 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ) ?? OPENCODE_PROMPT_ACCEPTED_RECOVERY_DELAYS_MS; const promptSubmissionInlineWaitMs = options?.promptSubmissionInlineWaitMs ?? OPENCODE_PROMPT_SUBMISSION_INLINE_WAIT_MS; + const prematureIdleCompletionGraceMs = + options?.prematureIdleCompletionGraceMs !== undefined && + Number.isFinite(options.prematureIdleCompletionGraceMs) && + options.prematureIdleCompletionGraceMs >= 0 + ? options.prematureIdleCompletionGraceMs + : OPENCODE_PREMATURE_IDLE_COMPLETION_GRACE_MS; + const completionSnapshotTimeoutMs = + options?.completionSnapshotTimeoutMs !== undefined && + Number.isFinite(options.completionSnapshotTimeoutMs) && + options.completionSnapshotTimeoutMs > 0 + ? options.completionSnapshotTimeoutMs + : OPENCODE_COMPLETION_SNAPSHOT_TIMEOUT_MS; + const cancellationDrainQuietMs = + options?.cancellationDrainQuietMs !== undefined && + Number.isFinite(options.cancellationDrainQuietMs) && + options.cancellationDrainQuietMs >= 0 + ? options.cancellationDrainQuietMs + : OPENCODE_CANCELLATION_DRAIN_QUIET_MS; + const eventDedupeLimit = + options?.eventDedupeLimit !== undefined && + Number.isInteger(options.eventDedupeLimit) && + options.eventDedupeLimit > 0 + ? options.eventDedupeLimit + : OPENCODE_EVENT_DEDUPE_LIMIT; + const turnStallTimeoutMs = + options?.turnStallTimeoutMs !== undefined && + Number.isFinite(options.turnStallTimeoutMs) && + options.turnStallTimeoutMs > 0 + ? options.turnStallTimeoutMs + : resolveOpenCodeTurnIdleTimeoutMs(provider); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1755,20 +2235,28 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); - if (managedNativeEventLogger !== undefined) { - yield* managedNativeEventLogger.close(); - } - }), - ); + const runBoundedOpenCodeSdk = ( + operation: string, + fn: (signal: AbortSignal) => Promise, + ) => + Effect.acquireUseRelease( + Effect.sync(() => new AbortController()), + (controller) => + runOpenCodeSdk(operation, () => fn(controller.signal)).pipe( + Effect.timeoutOption(completionSnapshotTimeoutMs), + ), + (controller) => Effect.sync(() => controller.abort()), + ); + + const runBoundedOpenCodeInteractionSdk = ( + operation: string, + replyState: OpenCodeInteractionReplyState, + fn: (signal: AbortSignal) => Promise, + ) => + runOpenCodeSdk(operation, () => fn(replyState.abortController.signal)).pipe( + Effect.timeoutOption(completionSnapshotTimeoutMs), + Effect.ensuring(Effect.sync(() => replyState.abortController.abort())), + ); const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); @@ -1787,6 +2275,162 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }, ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + const hasPendingInteractionForGeneration = ( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration, + ): boolean => + [...context.pendingPermissionGenerationById.values()].some( + (generationId) => generationId === generation.id, + ) || + [...context.pendingQuestionGenerationById.values()].some( + (generationId) => generationId === generation.id, + ); + + const cancelPendingInteractionsForGeneration = Effect.fn( + "cancelPendingInteractionsForGeneration", + )(function* (context: OpenCodeSessionContext, generation: OpenCodeTurnGeneration) { + // Abort every generation-owned interaction transport before releasing pending + // authority or asking the provider to abort the wider session. The reply owner + // retains its single-flight marker until it observes the abort and commits the + // cancellation outcome. + for (const replyState of context.permissionReplyStateById.values()) { + if (replyState.generationId === generation.id) { + replyState.cancelled = true; + replyState.abortController.abort(); + } + } + for (const replyState of context.questionReplyStateById.values()) { + if (replyState.generationId === generation.id) { + replyState.cancelled = true; + replyState.abortController.abort(); + } + } + for (const [requestId, generationId] of context.pendingPermissionGenerationById) { + if (generationId !== generation.id) { + continue; + } + const request = context.pendingPermissions.get(requestId); + context.pendingPermissionGenerationById.delete(requestId); + context.pendingPermissions.delete(requestId); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: generation.turnId, + requestId, + }), + type: "request.resolved", + payload: { + requestType: request ? mapPermissionToRequestType(request.permission) : "unknown", + decision: "cancel", + }, + }); + } + for (const [requestId, generationId] of context.pendingQuestionGenerationById) { + if (generationId !== generation.id) { + continue; + } + context.pendingQuestionGenerationById.delete(requestId); + context.pendingQuestions.delete(requestId); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: generation.turnId, + requestId, + }), + type: "user-input.resolved", + payload: { answers: {} }, + }); + } + for (const [requestId, generationId] of context.autoApprovedPermissionGenerationById) { + if (generationId !== generation.id) { + continue; + } + context.autoApprovedPermissionGenerationById.delete(requestId); + context.autoApprovedPermissionIds.delete(requestId); + } + }); + + const retireOpenCodeContext = Effect.fn("retireOpenCodeContext")(function* ( + context: OpenCodeSessionContext, + ) { + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const generation = context.activeTurnGeneration; + if (generation !== undefined) { + generation.state = "cancelling"; + generation.interactionOwned = false; + generation.sessionNextOwned = false; + generation.sessionNextProgressObserved = false; + yield* cancelPendingInteractionsForGeneration(context, generation); + } + const stopExit = yield* Effect.exit(stopOpenCodeContext(context)); + if (Exit.isFailure(stopExit)) { + context.quarantinedAfterAbortFailure = true; + const cause = Cause.squash(stopExit.cause); + updateProviderSession(context, { + status: "error", + lastError: `Provider cancellation could not be confirmed: ${openCodeRuntimeErrorDetail(cause)}`, + }); + return yield* Effect.failCause(stopExit.cause); + } + }), + ); + }); + + const retireOpenCodeContextForReplacement = Effect.fn("retireOpenCodeContextForReplacement")( + function* (context: OpenCodeSessionContext) { + return yield* Effect.uninterruptible( + Effect.gen(function* () { + if (context.replacementClaimed) { + return yield* new OpenCodeRuntimeError({ + operation: "session.replace", + detail: `${adapterConfig.displayName} session replacement is already in progress.`, + }); + } + context.replacementClaimed = true; + const retireExit = yield* Effect.exit(retireOpenCodeContext(context)); + if (Exit.isFailure(retireExit)) { + context.replacementClaimed = false; + return yield* Effect.failCause(retireExit.cause); + } + if (sessions.get(context.session.threadId) !== context) { + return yield* new OpenCodeRuntimeError({ + operation: "session.replace", + detail: `${adapterConfig.displayName} session changed during replacement; retry against the current session.`, + }); + } + sessions.delete(context.session.threadId); + }), + ); + }, + ); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + yield* Effect.forEach( + contexts, + (context) => + Effect.uninterruptible( + retireOpenCodeContext(context).pipe( + Effect.catchCause(() => forceDisposeOpenCodeContextLocally(context)), + Effect.tap(() => + Effect.sync(() => { + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + }), + ), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + if (managedNativeEventLogger !== undefined) { + yield* managedNativeEventLogger.close(); + } + }), + ); + const emitContextCompactionProgress = Effect.fn("emitContextCompactionProgress")(function* ( context: OpenCodeSessionContext, input: { @@ -1843,35 +2487,90 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( context: OpenCodeSessionContext, message: string, + providerTerminationConfirmed = false, ) { - if (yield* Ref.getAndSet(context.stopped, true)) { - return; - } - const turnId = context.activeTurnId; - sessions.delete(context.session.threadId); - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId }), - type: "runtime.error", - payload: { - message, - class: "transport_error", - }, - }).pipe(Effect.ignore); - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId }), - type: "session.exited", - payload: { - reason: message, - recoverable: false, - exitKind: "error", - }, - }).pipe(Effect.ignore); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ - sessionID: context.openCodeSessionId, + return yield* Effect.uninterruptible( + Effect.gen(function* () { + let proposedAttempt: Deferred.Deferred; + while (true) { + if (yield* Ref.get(context.stopped)) { + return; + } + const observedAttempt = context.retirementAttempt; + if (observedAttempt !== undefined) { + if (!providerTerminationConfirmed) { + return; + } + yield* Effect.exit(Deferred.await(observedAttempt)); + continue; + } + proposedAttempt = yield* Deferred.make(); + if (context.retirementAttempt === undefined) { + context.retirementAttempt = proposedAttempt; + break; + } + } + const generation = context.activeTurnGeneration; + const turnId = generation?.turnId; + if (generation !== undefined) { + generation.state = "cancelling"; + generation.interactionOwned = false; + generation.sessionNextOwned = false; + generation.sessionNextProgressObserved = false; + yield* cancelPendingInteractionsForGeneration(context, generation); + } + + const abortExit = providerTerminationConfirmed + ? Exit.succeed(undefined) + : yield* Effect.exit( + abortOpenCodeSessionBounded(context, completionSnapshotTimeoutMs), + ); + if (Exit.isFailure(abortExit)) { + context.retirementAttempt = undefined; + context.quarantinedAfterAbortFailure = true; + const cause = Cause.squash(abortExit.cause); + const detail = `Provider cancellation could not be confirmed after ${message}: ${openCodeRuntimeErrorDetail(cause)}`; + updateProviderSession(context, { status: "error", lastError: detail }); + yield* emit({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "runtime.error", + payload: { message: detail, class: "transport_error" }, + }).pipe(Effect.ignore); + const runtimeError = OpenCodeRuntimeError.is(cause) + ? cause + : new OpenCodeRuntimeError({ + operation: "session.abort", + detail: openCodeRuntimeErrorDetail(cause), + cause, + }); + yield* Deferred.fail(proposedAttempt, runtimeError); + return; + } + + yield* Ref.set(context.stopped, true); + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + yield* emit({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "runtime.error", + payload: { message, class: "transport_error" }, + }).pipe(Effect.ignore); + yield* emit({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }).pipe(Effect.ignore); + yield* Scope.close(context.sessionScope, Exit.void).pipe( + Effect.ensuring(Effect.sync(() => unregisterOpenCodeContext(context))), + ); + yield* Deferred.succeed(proposedAttempt, undefined); }), - ).pipe(Effect.ignore({ log: true })); - yield* Scope.close(context.sessionScope, Exit.void); + ); }); const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( @@ -1972,6 +2671,88 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } }); + const projectOwnedOpenCodePart = Effect.fn("projectOwnedOpenCodePart")(function* ( + context: OpenCodeSessionContext, + part: Part, + turnId: TurnId | undefined, + raw: unknown, + ) { + if ( + !turnId || + context.activeTurnId !== turnId || + !context.activeTurnAssistantMessageIds.has(part.messageID) || + context.activeTurnBaselineMessageIds.has(part.messageID) || + context.activeTurnRejectedAssistantMessageIds.has(part.messageID) + ) { + return; + } + if (part.type !== "tool" && part.type !== "compaction") { + return; + } + const snapshotKey = openCodeSnapshotKey(part); + if (context.projectedOwnedPartSnapshotKeyById.get(part.id) === snapshotKey) { + return; + } + context.projectedOwnedPartSnapshotKeyById.set(part.id, snapshotKey); + + if (part.type === "compaction") { + yield* emitContextCompactionProgress(context, { + turnId, + raw, + detail: part.overflow + ? "Compacting context after provider context overflow" + : "Compacting context", + data: { + auto: part.auto, + ...(part.overflow !== undefined ? { overflow: part.overflow } : {}), + ...(part.tail_start_id ? { tailStartId: part.tail_start_id } : {}), + }, + }); + return; + } + + rememberRelatedOpenCodeSession(context, part); + const itemType = toToolLifecycleItemType(part.tool); + const title = part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + toolName: part.tool, + toolCallId: part.callID, + callID: part.callID, + ...("input" in part.state ? { input: part.state.input } : {}), + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw, + }), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + yield* emit(runtimeEvent); + }); + const emitRecoveredAssistantTextDelta = Effect.fn("emitRecoveredAssistantTextDelta")( function* (context: OpenCodeSessionContext, part: Part, turnId: TurnId, raw: unknown) { const text = textFromPart(part); @@ -2058,13 +2839,32 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { context: OpenCodeSessionContext, input: { readonly turnId: TurnId; + readonly generation: OpenCodeTurnGeneration; readonly raw: unknown; readonly totalCostUsd?: number | undefined; readonly errorMessage?: string | undefined; }, ) { - clearActiveTurnState(context); - updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, input.generation) || + input.generation.turnId !== input.turnId || + hasPendingInteractionForGeneration(context, input.generation) + ) { + return false; + } + context.cancellationDrainPending = false; + yield* cancelPendingInteractionsForGeneration(context, input.generation); + if (!clearActiveTurnState(context, input.generation)) { + return false; + } + updateProviderSession( + context, + input.errorMessage + ? { status: "error", lastError: input.errorMessage } + : { status: "ready" }, + { clearActiveTurnId: true }, + ); yield* emit({ ...buildEventBase({ threadId: context.session.threadId, @@ -2082,65 +2882,244 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ...(input.totalCostUsd !== undefined ? { totalCostUsd: input.totalCostUsd } : {}), }, }); + return true; }); - const deferPrematureIdleCompletion = Effect.fn("deferPrematureIdleCompletion")(function* ( + const failOpenCodeTurnWithAbort = Effect.fn("failOpenCodeTurnWithAbort")(function* ( context: OpenCodeSessionContext, - turnId: TurnId, - raw: unknown, + input: { + readonly generation: OpenCodeTurnGeneration; + readonly message: string; + readonly raw: unknown; + readonly errorClass: "provider_error" | "transport_error"; + }, ) { - const idleBeforeAssistantActivity = context.activeTurnCompletionActivitySerial === 0; - const idleAfterToolCalls = - context.activeTurnSawToolCallFinish && !context.activeTurnSawFinalAssistant; - if (!idleBeforeAssistantActivity && !idleAfterToolCalls) { - return false; - } - if (!context.activeTurnToolCallIdleWatchdogStarted) { - context.activeTurnToolCallIdleWatchdogStarted = true; - yield* Effect.gen(function* () { - yield* Effect.sleep(10_000); - if ( - (yield* Ref.get(context.stopped)) || - context.activeTurnId !== turnId || - context.activeTurnSawFinalAssistant - ) { - return; + return yield* Effect.uninterruptible( + Effect.gen(function* () { + if (isOpenCodeTurnGenerationActive(context, input.generation)) { + claimOpenCodeTurnCancellation(context, input.generation); + } else if (!isCurrentOpenCodeTurnGeneration(context, input.generation)) { + return false; } + if (input.generation.cancellationFinalizerStarted) { + return false; + } + input.generation.cancellationFinalizerStarted = true; + yield* cancelPendingInteractionsForGeneration(context, input.generation); - const message = idleAfterToolCalls - ? `${adapterConfig.displayName} became idle after tool calls without producing a final assistant response.` - : `${adapterConfig.displayName} became idle before producing an assistant response.`; - yield* completeOpenCodeTurn(context, { - turnId, - raw: { - source: "scient.opencode.idle-after-tool-calls", - event: raw, - }, - errorMessage: message, - }); + const abortExit = yield* Effect.exit( + abortOpenCodeSessionBounded(context, completionSnapshotTimeoutMs), + ); + if (context.activeTurnGeneration !== input.generation) { + context.cancellationInFlight = false; + return false; + } + + const abortFailure = Exit.isFailure(abortExit) + ? openCodeRuntimeErrorDetail(Cause.squash(abortExit.cause)) + : undefined; + if (abortFailure !== undefined) { + context.quarantinedAfterAbortFailure = true; + context.cancellationDrainPending = false; + } + context.cancellationInFlight = false; + clearActiveTurnState(context, input.generation); + const message = abortFailure + ? `${input.message} Provider cancellation could not be confirmed: ${abortFailure}` + : input.message; updateProviderSession( context, - { - status: "error", - lastError: message, - }, + { status: "error", lastError: message }, { clearActiveTurnId: true }, ); yield* emit({ ...buildEventBase({ threadId: context.session.threadId, - turnId, - raw: { - source: "scient.opencode.idle-after-tool-calls", - event: raw, - }, + turnId: input.generation.turnId, + raw: input.raw, + }), + type: "turn.completed", + payload: { state: "failed", errorMessage: message }, + }); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: input.generation.turnId, + raw: input.raw, }), type: "runtime.error", payload: { message, - class: "provider_error", + class: abortFailure ? "transport_error" : input.errorClass, + ...(abortFailure ? { detail: { abortFailure } } : {}), }, }); + return true; + }), + ); + }); + + const waitForOpenCodeTurnCompletionQuiet = Effect.fn("waitForOpenCodeTurnCompletionQuiet")( + function* ( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration, + quietMs: number, + ) { + let observedActivitySerial = context.activeTurnCompletionActivitySerial; + while (true) { + yield* Effect.sleep(quietMs); + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, generation) + ) { + return false; + } + if (hasPendingInteractionForGeneration(context, generation)) { + observedActivitySerial = context.activeTurnCompletionActivitySerial; + continue; + } + const currentActivitySerial = context.activeTurnCompletionActivitySerial; + if (currentActivitySerial === observedActivitySerial) { + return true; + } + observedActivitySerial = currentActivitySerial; + } + }, + ); + + const deferPrematureIdleCompletion = Effect.fn("deferPrematureIdleCompletion")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + raw: unknown, + ) { + const generation = context.activeTurnGeneration; + if (!isOpenCodeTurnGenerationActive(context, generation) || generation.turnId !== turnId) { + return false; + } + const idleBeforeAssistantActivity = context.activeTurnCompletionActivitySerial === 0; + const idleAfterToolCalls = + context.activeTurnSawToolCallFinish && !context.activeTurnSawFinalAssistant; + const idleWithUnsettledAssistantText = context.activeTurnUnsettledAssistantTextIds.size > 0; + const finalAssistantMessageId = context.activeTurnFinalAssistantMessageId; + const idleBeforeFinalAssistantParts = + context.activeTurnSawFinalAssistant && + finalAssistantMessageId !== undefined && + !isOpenCodeAssistantMessageTextSettled(context, finalAssistantMessageId); + const idleAfterFinalAssistant = + context.activeTurnSawFinalAssistant && finalAssistantMessageId !== undefined; + if ( + !idleBeforeAssistantActivity && + !idleAfterToolCalls && + !idleWithUnsettledAssistantText && + !idleAfterFinalAssistant + ) { + return false; + } + if (!context.activeTurnToolCallIdleWatchdogStarted) { + context.activeTurnToolCallIdleWatchdogStarted = true; + yield* Effect.gen(function* () { + const needsRecoveryGrace = + idleBeforeAssistantActivity || + idleAfterToolCalls || + idleWithUnsettledAssistantText || + idleBeforeFinalAssistantParts; + const initialQuietMs = needsRecoveryGrace + ? prematureIdleCompletionGraceMs + : Math.min(prematureIdleCompletionGraceMs, 250); + if (!(yield* waitForOpenCodeTurnCompletionQuiet(context, generation, initialQuietMs))) { + return; + } + + for (let attempt = 0; attempt < 2; attempt += 1) { + const candidateMessageId = + context.activeTurnFinalAssistantMessageId ?? + activeTurnSettledAssistantMessageId(context); + const activitySerialBeforeSnapshot = context.activeTurnCompletionActivitySerial; + if ( + context.activeTurnSawFinalAssistant && + candidateMessageId !== undefined && + (yield* recoverOpenCodeTurnFromMessageId(context, { + turnId, + messageId: candidateMessageId, + raw: { + source: `scient.${provider}.deferred-idle-snapshot${attempt === 0 ? "" : "-retry"}`, + event: raw, + }, + })) + ) { + return; + } + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, generation) + ) { + return; + } + + const settledMessageId = + context.activeTurnFinalAssistantMessageId ?? + activeTurnSettledAssistantMessageId(context); + const nextTextItemId = openCodeNextTextItemId(turnId); + const hasSettledNextText = + context.completedAssistantPartIds.has(nextTextItemId) && + (context.emittedTextByPartId.get(nextTextItemId)?.length ?? 0) > 0; + if ( + (settledMessageId !== undefined && + isOpenCodeAssistantMessageTextSettled(context, settledMessageId)) || + (hasSettledNextText && !idleAfterToolCalls) + ) { + yield* completeOpenCodeTurn(context, { + turnId, + generation, + raw: { + source: `scient.${provider}.deferred-idle-local-parts${attempt === 0 ? "" : "-retry"}`, + event: raw, + }, + totalCostUsd: context.latestTurnCostUsd, + }); + return; + } + + const activityDuringSnapshot = + context.activeTurnCompletionActivitySerial !== activitySerialBeforeSnapshot; + if ( + attempt === 0 || + activityDuringSnapshot || + context.activeTurnUnsettledAssistantTextIds.size > 0 + ) { + if ( + !(yield* waitForOpenCodeTurnCompletionQuiet( + context, + generation, + prematureIdleCompletionGraceMs, + )) + ) { + return; + } + } + } + + const message = context.activeTurnSawFinalAssistant + ? `${adapterConfig.displayName} became idle before its final assistant response finished arriving.` + : context.activeTurnSawToolCallFinish + ? `${adapterConfig.displayName} became idle after tool calls without producing a final assistant response.` + : `${adapterConfig.displayName} became idle before producing an assistant response.`; + const failureRaw = { + source: `scient.${provider}.deferred-idle-timeout`, + event: raw, + }; + if (hasPendingInteractionForGeneration(context, generation)) { + // A provider can report idle while it is legitimately awaiting the user. + // Leave liveness to the interaction-aware stall watchdog after the reply. + context.activeTurnToolCallIdleWatchdogStarted = false; + return; + } + yield* failOpenCodeTurnWithAbort(context, { + generation, + message, + raw: failureRaw, + errorClass: "provider_error", + }); }).pipe(Effect.forkIn(context.sessionScope), Effect.asVoid); } return true; @@ -2158,13 +3137,36 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { readonly raw: unknown; }, ) { + const generation = context.activeTurnGeneration; + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, generation) || + generation.turnId !== input.turnId || + !areOpenCodeAssistantTextPartsSettled(input.assistantEntry.parts) + ) { + return false; + } context.messageRoleById.set(input.assistantEntry.info.id, "assistant"); + context.messageSnapshotKeyById.set( + input.assistantEntry.info.id, + openCodeSnapshotKey(input.assistantEntry.info), + ); trackActiveTurnAssistantFinish(context, input.turnId, input.assistantEntry); + if (!context.activeTurnAssistantMessageIds.has(input.assistantEntry.info.id)) { + return false; + } for (const part of input.assistantEntry.parts) { + if (context.activeTurnId !== input.turnId) { + return false; + } context.partById.set(part.id, part); + context.partSnapshotKeyById.set(part.id, openCodeSnapshotKey(part)); yield* emitRecoveredAssistantTextDelta(context, part, input.turnId, input.raw); } + if (context.activeTurnId !== input.turnId) { + return false; + } const selectedModel = context.session.model; const maxTokens = selectedModel !== undefined @@ -2188,18 +3190,87 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }, }); } + if (context.activeTurnId !== input.turnId) { + return false; + } const cost = asFiniteNonNegativeNumber( (input.assistantEntry.info as Partial).cost, ); context.latestTurnCostUsd = cost; - yield* completeOpenCodeTurn(context, { + return yield* completeOpenCodeTurn(context, { turnId: input.turnId, + generation, raw: input.raw, totalCostUsd: cost, }); - return true; }); + const loadOpenCodeRecoveryMessages = Effect.fn("loadOpenCodeRecoveryMessages")(function* ( + context: OpenCodeSessionContext, + options?: { readonly limit?: number }, + ) { + return yield* runBoundedOpenCodeSdk("session.messages", (signal) => + context.client.session.messages( + { + sessionID: context.openCodeSessionId, + ...(options?.limit !== undefined ? { limit: options.limit } : {}), + }, + { signal }, + ), + ).pipe( + Effect.map((responseOption) => + responseOption._tag === "Some" ? responseOption.value : null, + ), + Effect.catchCause(() => + Effect.succeed( + null as Awaited> | null, + ), + ), + ); + }); + + const recoverOpenCodeTurnFromMessageId = Effect.fn("recoverOpenCodeTurnFromMessageId")( + function* ( + context: OpenCodeSessionContext, + input: { + readonly turnId: TurnId; + readonly messageId: string; + readonly raw: unknown; + }, + ) { + const messagesResponse = yield* loadOpenCodeRecoveryMessages(context); + if ((yield* Ref.get(context.stopped)) || context.activeTurnId !== input.turnId) { + return false; + } + const assistantEntry = (messagesResponse?.data ?? []) + .flatMap((entry) => + entry.info.role === "assistant" && + entry.info.id === input.messageId && + !context.activeTurnBaselineMessageIds.has(entry.info.id) && + !context.activeTurnRejectedAssistantMessageIds.has(entry.info.id) && + trimNonEmptyString(entry.info.parentID) === context.activeTurnPromptMessageId + ? [ + { + info: entry.info, + parts: entry.parts, + } satisfies OpenCodeMessageSnapshot & { + readonly info: Record; + }, + ] + : [], + ) + .find(isOpenCodeCompletedAssistantMessage); + if (!assistantEntry) { + return false; + } + return yield* recoverOpenCodeTurnFromAssistantMessage(context, { + turnId: input.turnId, + assistantEntry, + raw: input.raw, + }); + }, + ); + const recoverOpenCodeTurnFromMessages = Effect.fn("recoverOpenCodeTurnFromMessages")( function* ( context: OpenCodeSessionContext, @@ -2208,24 +3279,22 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { readonly excludedMessageIds: ReadonlySet; }, ) { - const messagesResponse = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe( - Effect.catchCause(() => - Effect.succeed( - null as Awaited> | null, - ), - ), - ); - if (!messagesResponse) { + const messagesResponse = yield* loadOpenCodeRecoveryMessages(context); + if ( + (yield* Ref.get(context.stopped)) || + context.activeTurnId !== input.turnId || + !messagesResponse + ) { return false; } const assistantEntry = (messagesResponse.data ?? []) .flatMap((entry) => - entry.info.role === "assistant" && !input.excludedMessageIds.has(entry.info.id) + entry.info.role === "assistant" && + !input.excludedMessageIds.has(entry.info.id) && + !context.activeTurnBaselineMessageIds.has(entry.info.id) && + !context.activeTurnRejectedAssistantMessageIds.has(entry.info.id) && + trimNonEmptyString(entry.info.parentID) === context.activeTurnPromptMessageId ? [ { info: entry.info, @@ -2245,43 +3314,18 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { turnId: input.turnId, assistantEntry, raw: { - source: "scient.opencode.prompt.recovery", + source: `scient.${provider}.prompt.recovery`, message: assistantEntry, }, }); }, ); - const captureOpenCodeRecoveryBaseline = Effect.fn("captureOpenCodeRecoveryBaseline")( - function* (context: OpenCodeSessionContext) { - const messagesResponse = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe( - Effect.catchCause(() => - Effect.succeed( - null as Awaited> | null, - ), - ), - ); - const baselineIds = new Set(); - for (const id of context.messageRoleById.keys()) { - baselineIds.add(id); - } - for (const entry of messagesResponse?.data ?? []) { - if (typeof entry.info.id === "string") { - baselineIds.add(entry.info.id); - } - } - return baselineIds; - }, - ); - const schedulePromptAcceptedWatchdog = Effect.fn("schedulePromptAcceptedWatchdog")(function* ( context: OpenCodeSessionContext, input: { readonly turnId: TurnId; + readonly generation: OpenCodeTurnGeneration; readonly providerActivitySerial: number; readonly excludedMessageIds: ReadonlySet; }, @@ -2289,7 +3333,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { yield* Effect.gen(function* () { for (const delayMs of promptAcceptedRecoveryDelaysMs) { yield* Effect.sleep(delayMs); - if ((yield* Ref.get(context.stopped)) || context.activeTurnId !== input.turnId) { + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, input.generation) + ) { break; } const recovered = yield* recoverOpenCodeTurnFromMessages(context, { @@ -2308,38 +3355,18 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { return; } if ( - context.activeTurnId !== input.turnId || + !isOpenCodeTurnGenerationActive(context, input.generation) || context.activeTurnProviderActivitySerial !== input.providerActivitySerial ) { return; } - const message = - "OpenCode did not produce any activity for this prompt. The session may be stuck; try sending again or restart OpenCode."; - yield* completeOpenCodeTurn(context, { - turnId: input.turnId, - raw: { source: "scient.opencode.prompt.watchdog" }, - errorMessage: message, - }); - updateProviderSession( - context, - { - status: "error", - lastError: message, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId: input.turnId, - raw: { source: "scient.opencode.prompt.watchdog" }, - }), - type: "runtime.error", - payload: { - message, - class: "transport_error", - }, + const message = `${adapterConfig.displayName} did not produce any activity for this prompt. The session may be stuck; try sending again or restart ${adapterConfig.displayName}.`; + yield* failOpenCodeTurnWithAbort(context, { + generation: input.generation, + message, + raw: { source: `scient.${provider}.prompt.watchdog` }, + errorClass: "transport_error", }); }), ), @@ -2352,6 +3379,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { context: OpenCodeSessionContext, input: { readonly turnId: TurnId; + readonly generation: OpenCodeTurnGeneration; readonly promptInput: Parameters[0]; }, ) { @@ -2368,7 +3396,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (yield* Ref.get(context.stopped)) { return null; } - if (context.activeTurnId !== input.turnId) { + if (!isOpenCodeTurnGenerationActive(context, input.generation)) { return null; } const assistantEntry = @@ -2385,7 +3413,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { turnId: input.turnId, assistantEntry, raw: { - source: "scient.opencode.prompt.response", + source: `scient.${provider}.prompt.response`, message: assistantEntry, }, }); @@ -2399,27 +3427,19 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { return requestError; } if ( - context.activeTurnId !== input.turnId || + !isOpenCodeTurnGenerationActive(context, input.generation) || context.activeTurnProviderActivitySerial > 0 ) { return requestError; } - clearActiveTurnState(context); - updateProviderSession( - context, - { - status: "ready", - model: context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId: input.turnId }), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, + // Claim cancellation before unblocking sendTurn so a caller cannot open + // a successor during the cleanup window. + claimOpenCodeTurnCancellation(context, input.generation); + yield* failOpenCodeTurnWithAbort(context, { + generation: input.generation, + message: requestError.detail, + raw: { source: `scient.${provider}.prompt.request-error` }, + errorClass: "transport_error", }); return requestError; }), @@ -2440,6 +3460,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { context: OpenCodeSessionContext, input: { readonly turnId: TurnId; + readonly generation: OpenCodeTurnGeneration; readonly promptInput: Parameters[0]; }, ) { @@ -2454,25 +3475,17 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (yield* Ref.get(context.stopped)) { return requestError; } - if (context.activeTurnId !== input.turnId) { + if (!isOpenCodeTurnGenerationActive(context, input.generation)) { return requestError; } - clearActiveTurnState(context); - updateProviderSession( - context, - { - status: "ready", - model: context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...buildEventBase({ threadId: context.session.threadId, turnId: input.turnId }), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, + // Let sendTurn fail immediately only after synchronously closing the + // generation to successors. + claimOpenCodeTurnCancellation(context, input.generation); + yield* failOpenCodeTurnWithAbort(context, { + generation: input.generation, + message: requestError.detail, + raw: { source: `scient.${provider}.prompt-async.request-error` }, + errorClass: "transport_error", }); return requestError; }), @@ -2493,16 +3506,119 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, ) { - if (!shouldHandleSubscribedEvent(context, event)) { + if (yield* Ref.get(context.stopped)) { return; } + const eventSessionId = subscribedEventSessionId(event); + const sessionlessTerminalEventIsUnambiguous = + eventSessionId !== undefined || isOnlyContextAttachedToOpenCodeServer(context); + if (!shouldHandleSubscribedEvent(context, event, sessionlessTerminalEventIsUnambiguous)) { + return; + } + const eventId = subscribedEventId(event); + if (eventId !== undefined) { + if (context.seenSubscribedEventIds.has(eventId)) { + return; + } + context.seenSubscribedEventIds.add(eventId); + if (context.seenSubscribedEventIds.size > context.subscribedEventDedupeLimit) { + const evictedEventId = context.seenSubscribedEventIds.values().next().value; + if (evictedEventId !== undefined) { + context.seenSubscribedEventIds.delete(evictedEventId); + } + } + } + + const sessionNextTimestamp = event.type.startsWith("session.next.") + ? asFiniteNonNegativeNumber( + (event.properties as unknown as { readonly timestamp?: unknown }).timestamp, + ) + : undefined; + if ( + sessionNextTimestamp !== undefined && + (context.latestObservedSessionNextTimestamp === undefined || + sessionNextTimestamp > context.latestObservedSessionNextTimestamp) + ) { + // Keep a session-level watermark even while the generation lane is closed. + // When exact prompt/assistant ownership opens the lane, every earlier next event + // is therefore below that generation's floor and cannot be replayed into it. + context.latestObservedSessionNextTimestamp = sessionNextTimestamp; + } - const turnId = context.activeTurnId; + const generation = context.activeTurnGeneration; + const turnId = generation?.turnId; + if ( + generation !== undefined && + !isOpenCodeTurnGenerationActive(context, generation) && + !(event.type === "session.error" && !isOpenCodeCancellationError(event.properties.error)) + ) { + return; + } + if ( + event.type === "session.error" && + context.cancellationDrainPending && + isOpenCodeCancellationError(event.properties.error) + ) { + // Cancellation errors do not carry prompt or generation identity. They remain + // quarantined behind the retired generation until exact assistant ownership for + // the successor is observed. The general stall watchdog bounds a successor that + // never establishes ownership, without allowing a late error from turn A to fail B. + return; + } + if ( + turnId && + event.type.startsWith("session.next.") && + (!generation?.sessionNextOwned || + !context.activeTurnAcceptsSessionNextEvents || + context.requiresStrictOwnershipUntilBoundary) + ) { + return; + } + if (turnId && generation && event.type.startsWith("session.next.")) { + const timestampFloor = generation.sessionNextTimestampFloor; + const latestAcceptedTimestamp = generation.sessionNextLatestAcceptedTimestamp; + if ( + sessionNextTimestamp === undefined || + (timestampFloor !== undefined && sessionNextTimestamp <= timestampFloor) || + (latestAcceptedTimestamp !== undefined && + sessionNextTimestamp < latestAcceptedTimestamp) + ) { + return; + } + if ( + latestAcceptedTimestamp === undefined || + sessionNextTimestamp > latestAcceptedTimestamp + ) { + generation.sessionNextLatestAcceptedTimestamp = sessionNextTimestamp; + } + } + if ( + turnId && + event.type.startsWith("session.next.step.") && + !generation?.sessionNextProgressObserved + ) { + // Terminal session.next events have no prompt/turn identity. Exact assistant + // ownership opens the lane, but a current-generation non-terminal next event + // is still required before a terminal can settle or fail the turn. + return; + } + if ( + turnId && + generation?.sessionNextOwned && + context.activeTurnAcceptsSessionNextEvents && + event.type.startsWith("session.next.") && + event.type !== "session.next.step.ended" && + event.type !== "session.next.step.failed" + ) { + context.cancellationDrainPending = false; + generation.sessionNextProgressObserved = true; + markOpenCodeTurnOwnedProgress(context, generation); + } if (turnId) { context.activeTurnEventSerial += 1; // User-message echoes should not disable prompt recovery; track provider-side // activity separately for the "accepted but nothing started" watchdog. - if (isOpenCodeTurnProviderActivityEvent(context, event)) { + if (isOpenCodeTurnProviderActivityEvent(event)) { markOpenCodeTurnProviderActivity(context, turnId); } } @@ -2525,6 +3641,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { event.properties.info.id, openCodeSnapshotKey(event.properties.info), ); + if ( + event.properties.info.role === "user" && + generation !== undefined && + event.properties.info.id === generation.promptMessageId + ) { + establishOpenCodePromptOwnership(context, turnId); + } if (event.properties.info.role === "assistant") { const assistantMessage = event.properties.info; trackActiveTurnAssistantFinish(context, turnId, { @@ -2534,6 +3657,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }, parts: [], }); + if ( + turnId !== undefined && + !context.activeTurnAssistantMessageIds.has(assistantMessage.id) + ) { + break; + } + markOpenCodeTurnProviderActivity(context, turnId); const selectedModel = context.session.model; const maxTokens = selectedModel !== undefined @@ -2583,7 +3713,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (resolvedPart !== part) { context.partById.set(resolvedPart.id, resolvedPart); } + if (!trackActiveTurnAssistantTextPart(context, turnId, resolvedPart)) { + continue; + } yield* emitAssistantTextDelta(context, resolvedPart, turnId, event); + yield* projectOwnedOpenCodePart(context, resolvedPart, turnId, event); } } break; @@ -2614,6 +3748,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { bufferPendingTextDelta(context, event.properties.partID, delta); break; } + if (!trackActiveTurnAssistantTextPart(context, turnId, resolvedPart)) { + break; + } + markOpenCodeTurnProviderActivity(context, turnId); if (!shouldProjectOpenCodeTextPart(resolvedPart)) { break; } @@ -2659,98 +3797,126 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const part = applyPendingTextDeltaToPart(context, event.properties.part); context.partById.set(part.id, part); context.partSnapshotKeyById.set(part.id, openCodeSnapshotKey(part)); - const messageRole = messageRoleForPart(context, part); + const messageRole = + messageRoleForPart(context, part) ?? + (part.type === "tool" && + turnId !== undefined && + context.activeTurnAcceptsSessionNextEvents && + !context.requiresStrictOwnershipUntilBoundary && + (eventSessionId === context.openCodeSessionId || + (eventSessionId !== undefined && context.relatedSessionIds.has(eventSessionId))) + ? "assistant" + : undefined); if (messageRole === "assistant") { + if (!trackActiveTurnAssistantTextPart(context, turnId, part)) { + break; + } + markOpenCodeTurnProviderActivity(context, turnId); + if ( + turnId !== undefined && + context.activeTurnId === turnId && + context.activeTurnFinalAssistantMessageId === part.messageID + ) { + markOpenCodeTurnCompletionActivity(context, turnId); + } yield* emitAssistantTextDelta(context, part, turnId, event); - } - - if (part.type === "tool") { - rememberRelatedOpenCodeSession(context, part); - const itemType = toToolLifecycleItemType(part.tool); - const title = - part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; - const detail = detailFromToolPart(part); - const payload = { - itemType, - ...(part.state.status === "error" - ? { status: "failed" as const } - : part.state.status === "completed" - ? { status: "completed" as const } - : { status: "inProgress" as const }), - ...(title ? { title } : {}), - ...(detail ? { detail } : {}), - data: { - tool: part.tool, - toolName: part.tool, - toolCallId: part.callID, - callID: part.callID, - ...("input" in part.state ? { input: part.state.input } : {}), - state: part.state, - }, - }; - const runtimeEvent: ProviderRuntimeEvent = { - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.callID, - createdAt: toolStateCreatedAt(part), - raw: event, - }), - type: - part.state.status === "pending" - ? "item.started" - : part.state.status === "completed" || part.state.status === "error" - ? "item.completed" - : "item.updated", - payload, - }; - appendTurnItem(context, turnId, part); - yield* emit(runtimeEvent); - } - - if (part.type === "compaction") { - yield* emitContextCompactionProgress(context, { - turnId, - raw: event, - detail: part.overflow - ? "Compacting context after provider context overflow" - : "Compacting context", - data: { - auto: part.auto, - ...(part.overflow !== undefined ? { overflow: part.overflow } : {}), - ...(part.tail_start_id ? { tailStartId: part.tail_start_id } : {}), - }, - }); + yield* projectOwnedOpenCodePart(context, part, turnId, event); } break; } case "permission.asked": { + if ( + !openCodeInteractionBelongsToActiveGeneration( + context, + generation, + event.properties.tool, + ) + ) { + break; + } + if ( + context.pendingPermissionGenerationById.get(event.properties.id) === generation.id || + context.autoApprovedPermissionGenerationById.get(event.properties.id) === + generation.id || + context.permissionReplyStateById.has(event.properties.id) + ) { + break; + } + markOpenCodeTurnProviderActivity(context, turnId); + markOpenCodeTurnOwnedProgress(context, generation); // Full access: auto-approve instead of surfacing an approval, mirroring the // Claude/Cursor/Grok adapters. Covers asks the declarative session ruleset cannot // (resumed/forked sessions on older servers, subagent child sessions, user config). if (context.session.runtimeMode === "full-access") { + const replyState: OpenCodeInteractionReplyState = { + generationId: generation.id, + abortController: new AbortController(), + acknowledged: false, + cancelled: false, + }; + context.permissionReplyStateById.set(event.properties.id, replyState); context.autoApprovedPermissionIds.add(event.properties.id); + context.autoApprovedPermissionGenerationById.set(event.properties.id, generation.id); + while (context.autoApprovedPermissionIds.size > context.subscribedEventDedupeLimit) { + const oldestRequestId = context.autoApprovedPermissionIds.values().next().value; + if (oldestRequestId === undefined) { + break; + } + context.autoApprovedPermissionIds.delete(oldestRequestId); + context.autoApprovedPermissionGenerationById.delete(oldestRequestId); + } const replyExit = yield* Effect.exit( - runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: event.properties.id, - reply: "always", - }), + runBoundedOpenCodeInteractionSdk("permission.reply", replyState, (signal) => + context.client.permission.reply( + { + requestID: event.properties.id, + reply: "always", + }, + { signal }, + ), ), ); + const clearReplyState = () => { + if (context.permissionReplyStateById.get(event.properties.id) === replyState) { + context.permissionReplyStateById.delete(event.properties.id); + } + }; + if ( + replyState.cancelled || + !isOpenCodeTurnGenerationActive(context, generation) || + context.autoApprovedPermissionGenerationById.get(event.properties.id) !== + generation.id + ) { + clearReplyState(); + break; + } + if (Exit.isSuccess(replyExit) && replyExit.value._tag === "Some") { + clearReplyState(); + break; + } if (Exit.isSuccess(replyExit)) { + yield* failOpenCodeTurnWithAbort(context, { + generation, + message: `${adapterConfig.displayName} timed out while applying a full-access permission decision.`, + raw: event, + errorClass: "transport_error", + }); + clearReplyState(); break; } - // Fall back to a visible approval so the turn cannot hang on a failed reply. - context.autoApprovedPermissionIds.delete(event.properties.id); - yield* Effect.logWarning( - `${adapterConfig.displayName} full-access auto-approve failed; surfacing approval`, - Cause.squash(replyExit.cause), - ); + yield* failOpenCodeTurnWithAbort(context, { + generation, + message: `${adapterConfig.displayName} full-access permission outcome could not be confirmed: ${openCodeRuntimeErrorDetail(Cause.squash(replyExit.cause))}`, + raw: event, + errorClass: "transport_error", + }); + clearReplyState(); + break; } context.pendingPermissions.set(event.properties.id, event.properties); + context.pendingPermissionGenerationById.set(event.properties.id, generation.id); yield* emit({ ...buildEventBase({ threadId: context.session.threadId, @@ -2773,10 +3939,35 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { case "permission.replied": { if (context.autoApprovedPermissionIds.delete(event.properties.requestID)) { + const autoApprovedGenerationId = context.autoApprovedPermissionGenerationById.get( + event.properties.requestID, + ); + const replyState = context.permissionReplyStateById.get(event.properties.requestID); + if ( + replyState !== undefined && + replyState.generationId === autoApprovedGenerationId && + !replyState.cancelled + ) { + replyState.acknowledged = true; + } + context.autoApprovedPermissionGenerationById.delete(event.properties.requestID); // Auto-approved in full access; nothing was surfaced to the UI. break; } + if ( + !generation || + context.pendingPermissionGenerationById.get(event.properties.requestID) !== + generation.id + ) { + break; + } + const replyState = context.permissionReplyStateById.get(event.properties.requestID); + if (replyState?.generationId === generation.id && !replyState.cancelled) { + replyState.acknowledged = true; + } context.pendingPermissions.delete(event.properties.requestID); + context.pendingPermissionGenerationById.delete(event.properties.requestID); + markOpenCodeTurnOwnedProgress(context, generation); yield* emit({ ...buildEventBase({ threadId: context.session.threadId, @@ -2794,7 +3985,25 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "question.asked": { + if ( + !openCodeInteractionBelongsToActiveGeneration( + context, + generation, + event.properties.tool, + ) + ) { + break; + } + if ( + context.pendingQuestionGenerationById.get(event.properties.id) === generation.id || + context.questionReplyStateById.has(event.properties.id) + ) { + break; + } + markOpenCodeTurnProviderActivity(context, turnId); + markOpenCodeTurnOwnedProgress(context, generation); context.pendingQuestions.set(event.properties.id, event.properties); + context.pendingQuestionGenerationById.set(event.properties.id, generation.id); yield* emit({ ...buildEventBase({ threadId: context.session.threadId, @@ -2811,8 +4020,21 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "question.replied": { + if ( + !generation || + context.pendingQuestionGenerationById.get(event.properties.requestID) !== + generation.id + ) { + break; + } + const replyState = context.questionReplyStateById.get(event.properties.requestID); + if (replyState?.generationId === generation.id && !replyState.cancelled) { + replyState.acknowledged = true; + } const request = context.pendingQuestions.get(event.properties.requestID); context.pendingQuestions.delete(event.properties.requestID); + context.pendingQuestionGenerationById.delete(event.properties.requestID); + markOpenCodeTurnOwnedProgress(context, generation); const answers = Object.fromEntries( (request?.questions ?? []).map((question, index) => [ openCodeQuestionId(index, question), @@ -2833,7 +4055,20 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "question.rejected": { + if ( + !generation || + context.pendingQuestionGenerationById.get(event.properties.requestID) !== + generation.id + ) { + break; + } + const replyState = context.questionReplyStateById.get(event.properties.requestID); + if (replyState?.generationId === generation.id) { + replyState.cancelled = true; + } context.pendingQuestions.delete(event.properties.requestID); + context.pendingQuestionGenerationById.delete(event.properties.requestID); + markOpenCodeTurnOwnedProgress(context, generation); yield* emit({ ...buildEventBase({ threadId: context.session.threadId, @@ -2865,7 +4100,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "session.status": { - if (event.properties.status.type === "busy") { + if ( + event.properties.status.type === "busy" && + isOpenCodeTurnGenerationActive(context, generation) + ) { updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -2888,12 +4126,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { break; } - if (event.properties.status.type === "idle" && turnId) { + if (event.properties.status.type === "idle" && turnId && generation) { if (yield* deferPrematureIdleCompletion(context, turnId, event)) { break; } yield* completeOpenCodeTurn(context, { turnId, + generation, raw: event, totalCostUsd: context.latestTurnCostUsd, }); @@ -2902,12 +4141,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "session.idle": { - if (turnId) { + if (turnId && generation) { if (yield* deferPrematureIdleCompletion(context, turnId, event)) { break; } yield* completeOpenCodeTurn(context, { turnId, + generation, raw: event, totalCostUsd: context.latestTurnCostUsd, }); @@ -2931,6 +4171,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { break; } context.emittedTextByPartId.set(itemId, nextText); + context.activeTurnUnsettledAssistantTextIds.add(itemId); markOpenCodeTurnCompletionActivity(context, turnId); yield* emit({ ...buildEventBase({ @@ -2958,6 +4199,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const previousText = context.emittedTextByPartId.get(itemId) ?? ""; const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); context.emittedTextByPartId.set(itemId, latestText); + context.activeTurnUnsettledAssistantTextIds.delete(itemId); if (deltaToEmit.length > 0) { markOpenCodeTurnCompletionActivity(context, turnId); yield* emit({ @@ -3195,7 +4437,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "session.next.step.ended": { - if (!turnId) { + if (!turnId || !generation) { break; } const selectedModel = context.session.model; @@ -3226,6 +4468,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (isOpenCodeTerminalStepFinish(event.properties.finish)) { yield* completeOpenCodeTurn(context, { turnId, + generation, raw: event, totalCostUsd: context.latestTurnCostUsd, }); @@ -3234,34 +4477,20 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "session.next.step.failed": { - const message = event.properties.error.message || "OpenCode session failed."; - if (turnId) { - yield* completeOpenCodeTurn(context, { - turnId, + const message = + event.properties.error.message || `${adapterConfig.displayName} session failed.`; + let completedActiveTurn = true; + if (turnId && generation) { + completedActiveTurn = yield* failOpenCodeTurnWithAbort(context, { + generation, + message, raw: event, - errorMessage: message, + errorClass: "provider_error", }); } - updateProviderSession( - context, - { - status: "error", - lastError: message, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...buildEventBase({ - threadId: context.session.threadId, - raw: event, - }), - type: "runtime.error", - payload: { - message, - class: "provider_error", - detail: event.properties.error, - }, - }); + if (!completedActiveTurn) { + break; + } break; } @@ -3288,7 +4517,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } case "session.error": { - const message = sessionErrorMessage(event.properties.error); + const message = sessionErrorMessage( + event.properties.error, + `${adapterConfig.displayName} session failed.`, + ); if (isOpenCodeContextOverflowError(event.properties.error)) { updateProviderSession( context, @@ -3307,7 +4539,18 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }); break; } - const activeTurnId = context.activeTurnId; + if (generation && isOpenCodeTurnGenerationActive(context, generation)) { + context.cancellationDrainPending = false; + yield* failOpenCodeTurnWithAbort(context, { + generation, + message, + raw: event, + errorClass: "provider_error", + }); + break; + } + const failedTurnId = turnId; + context.cancellationDrainPending = false; clearActiveTurnState(context); updateProviderSession( context, @@ -3317,23 +4560,21 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }, { clearActiveTurnId: true }, ); - if (activeTurnId) { + if (failedTurnId) { yield* emit({ ...buildEventBase({ threadId: context.session.threadId, - turnId: activeTurnId, + turnId: failedTurnId, raw: event, }), type: "turn.completed", - payload: { - state: "failed", - errorMessage: message, - }, + payload: { state: "failed", errorMessage: message }, }); } yield* emit({ ...buildEventBase({ threadId: context.session.threadId, + ...(failedTurnId ? { turnId: failedTurnId } : {}), raw: event, }), type: "runtime.error", @@ -3353,18 +4594,18 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const loadCurrentMessageSnapshots = Effect.fn("loadCurrentMessageSnapshots")(function* ( context: OpenCodeSessionContext, + options?: { readonly limit?: number }, ) { - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ); - return openCodeMessageSnapshotsFromResponse(messages.data ?? []); + const messages = yield* loadOpenCodeRecoveryMessages(context, options); + return messages ? openCodeMessageSnapshotsFromResponse(messages.data ?? []) : null; }); const rememberCurrentMessageSnapshots = Effect.fn("rememberCurrentMessageSnapshots")( function* (context: OpenCodeSessionContext) { const snapshots = yield* loadCurrentMessageSnapshots(context); + if (!snapshots) { + return null; + } for (const snapshot of snapshots) { rememberOpenCodeMessageSnapshot(context, snapshot); } @@ -3378,21 +4619,24 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const captureTurnSnapshotWatchdogBaseline = Effect.fn("captureTurnSnapshotWatchdogBaseline")( function* (context: OpenCodeSessionContext) { const knownMessageIds = collectKnownMessageIds(context); - const snapshotsExit = yield* Effect.exit(rememberCurrentMessageSnapshots(context)); - if (Exit.isSuccess(snapshotsExit)) { + const snapshotMessageIds = yield* rememberCurrentMessageSnapshots(context); + for (const messageId of context.messageRoleById.keys()) { + knownMessageIds.add(messageId); + } + if (snapshotMessageIds) { // Preserve already observed ids even if OpenCode returns a partial message list. - for (const messageId of snapshotsExit.value) { + for (const messageId of snapshotMessageIds) { knownMessageIds.add(messageId); } return { - canStartWatchdog: true, + baselineTrusted: true, messageIds: knownMessageIds, } as const; } if (knownMessageIds.size > 0) { return { - canStartWatchdog: true, + baselineTrusted: false, messageIds: knownMessageIds, } as const; } @@ -3405,12 +4649,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { provider, threadId: context.session.threadId, providerThreadId: context.openCodeSessionId, - type: "turn.snapshot-watchdog.disabled", - detail: openCodeRuntimeErrorDetail(Cause.squash(snapshotsExit.cause)), + type: "turn.snapshot-watchdog.strict-parent-fallback", + detail: `${adapterConfig.displayName} message baseline was unavailable within ${completionSnapshotTimeoutMs}ms; completion now requires the active prompt parent id.`, }, }); return { - canStartWatchdog: false, + baselineTrusted: false, messageIds: knownMessageIds, } as const; }, @@ -3470,33 +4714,57 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { baselineMessageIds: ReadonlySet, options: { readonly pollMessagesWhileBusy: boolean }, ) { + const generation = context.activeTurnGeneration; + if (!isOpenCodeTurnGenerationActive(context, generation) || generation.turnId !== turnId) { + return; + } yield* Effect.gen(function* () { let idlePollsWithFinalMessage = 0; + let idlePollsWithoutOwnedAssistant = 0; - while (!(yield* Ref.get(context.stopped)) && context.activeTurnId === turnId) { + while ( + !(yield* Ref.get(context.stopped)) && + isOpenCodeTurnGenerationActive(context, generation) + ) { yield* Effect.sleep(500); - const statusExit = yield* Effect.exit( - runOpenCodeSdk("session.status", () => - context.client.session.status({ + const statusOption = yield* runBoundedOpenCodeSdk("session.status", (signal) => + context.client.session.status( + { directory: context.directory, - }), + }, + { signal }, ), - ); - const statusKnown = Exit.isSuccess(statusExit); + ).pipe(Effect.catchCause(() => Effect.succeed(null))); + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, generation) + ) { + return; + } + const statusKnown = statusOption !== null && statusOption._tag === "Some"; const status = statusKnown - ? statusExit.value.data?.[context.openCodeSessionId] + ? statusOption.value.data?.[context.openCodeSessionId] : undefined; const sessionBusy = status?.type === "busy" || status?.type === "retry"; let hasFinalAssistantMessage = false; if (options.pollMessagesWhileBusy || (statusKnown && !sessionBusy)) { - const snapshotsExit = yield* Effect.exit(loadCurrentMessageSnapshots(context)); - if (Exit.isSuccess(snapshotsExit)) { - yield* replayOpenCodeMessageSnapshots(context, snapshotsExit.value, turnId); - hasFinalAssistantMessage = snapshotsExit.value.some( + const snapshots = yield* loadCurrentMessageSnapshots(context, { limit: 256 }); + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, generation) + ) { + return; + } + if (snapshots) { + yield* replayOpenCodeMessageSnapshots(context, snapshots, turnId); + hasFinalAssistantMessage = snapshots.some( (snapshot) => !baselineMessageIds.has(snapshot.info.id) && + !context.activeTurnRejectedAssistantMessageIds.has(snapshot.info.id) && + trimNonEmptyString(snapshot.info.parentID) === + context.activeTurnPromptMessageId && isFinalAssistantMessageSnapshot(snapshot), ); } @@ -3504,13 +4772,32 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (!statusKnown || sessionBusy) { idlePollsWithFinalMessage = 0; + idlePollsWithoutOwnedAssistant = 0; continue; } + idlePollsWithoutOwnedAssistant = + context.requiresStrictOwnershipUntilBoundary && !hasFinalAssistantMessage + ? idlePollsWithoutOwnedAssistant + 1 + : 0; + if ( + idlePollsWithoutOwnedAssistant >= 1 && + isOpenCodeTurnGenerationActive(context, generation) + ) { + yield* deferPrematureIdleCompletion(context, turnId, { + source: `scient.${provider}.snapshot-watchdog-idle-without-ownership`, + status, + }); + return; + } + idlePollsWithFinalMessage = hasFinalAssistantMessage ? idlePollsWithFinalMessage + 1 : 0; - if (idlePollsWithFinalMessage < 1 || context.activeTurnId !== turnId) { + if ( + idlePollsWithFinalMessage < 1 || + !isOpenCodeTurnGenerationActive(context, generation) + ) { continue; } @@ -3543,6 +4830,39 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ); }); + const startTurnStallWatchdog = Effect.fn("startTurnStallWatchdog")(function* ( + context: OpenCodeSessionContext, + generation: OpenCodeTurnGeneration, + ) { + const pollIntervalMs = Math.min(1_000, Math.max(5, Math.floor(turnStallTimeoutMs / 4))); + yield* Effect.gen(function* () { + while (isOpenCodeTurnGenerationActive(context, generation)) { + yield* Effect.sleep(pollIntervalMs); + if ( + (yield* Ref.get(context.stopped)) || + !isOpenCodeTurnGenerationActive(context, generation) + ) { + return; + } + if (hasPendingInteractionForGeneration(context, generation)) { + generation.lastOwnedProgressAtMs = Date.now(); + continue; + } + if (Date.now() - generation.lastOwnedProgressAtMs < turnStallTimeoutMs) { + continue; + } + const message = `${adapterConfig.displayName} stopped making progress for ${turnStallTimeoutMs}ms. The provider turn was cancelled to prevent an indefinitely running session.`; + yield* failOpenCodeTurnWithAbort(context, { + generation, + message, + raw: { source: `scient.${provider}.turn-stall-watchdog` }, + errorClass: "transport_error", + }); + return; + } + }).pipe(Effect.forkIn(context.sessionScope), Effect.asVoid); + }); + const startEventPump = Effect.fn("startEventPump")(function* ( context: OpenCodeSessionContext, ) { @@ -3596,6 +4916,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { yield* emitUnexpectedExit( context, `${adapterConfig.displayName} server exited unexpectedly (${code}).`, + true, ); }), ), @@ -3630,8 +4951,9 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { : undefined; const existing = sessions.get(input.threadId); if (existing) { - yield* stopOpenCodeContext(existing); - sessions.delete(input.threadId); + yield* retireOpenCodeContextForReplacement(existing).pipe( + Effect.mapError((cause) => toAdapterProcessError(input.threadId, cause)), + ); } const resumedSessionId = extractResumeSessionId(input.resumeCursor); @@ -3657,8 +4979,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }); const createSessionId = resumedSessionId ? // Resumed sessions skip session.create, so re-apply the runtime-mode - // permission ruleset explicitly. Non-fatal: older servers may reject the - // field, and full-access asks are still auto-approved in the event pump. + // permission ruleset explicitly. Approval-required must fail closed: + // otherwise a previously permissive provider session can keep executing + // without emitting approval events. Full access may retain compatibility + // with older servers because its event pump still auto-approves asks. runOpenCodeSdk("session.update", () => client.session.update({ sessionID: resumedSessionId, @@ -3666,10 +4990,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }), ).pipe( Effect.catchCause((cause) => - Effect.logWarning( - `${adapterConfig.displayName} failed to apply permission ruleset on resume`, - Cause.squash(cause), - ), + input.runtimeMode === "full-access" + ? Effect.logWarning( + `${adapterConfig.displayName} failed to apply permission ruleset on resume`, + Cause.squash(cause), + ) + : Effect.failCause(cause), ), Effect.as(resumedSessionId), ) @@ -3727,12 +5053,17 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const raceWinner = sessions.get(input.threadId); if (raceWinner) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSessionId, - }), - ).pipe(Effect.ignore); + const abortExit = yield* Effect.exit( + abortOpenCodeClientSessionBounded( + started.client, + started.openCodeSessionId, + completionSnapshotTimeoutMs, + ), + ); yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); + if (Exit.isFailure(abortExit)) { + return yield* toAdapterProcessError(input.threadId, Cause.squash(abortExit.cause)); + } return raceWinner.session; } @@ -3756,8 +5087,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { directory, openCodeSessionId: started.openCodeSessionId, pendingPermissions: new Map(), + pendingPermissionGenerationById: new Map(), + permissionReplyStateById: new Map(), autoApprovedPermissionIds: new Set(), + autoApprovedPermissionGenerationById: new Map(), pendingQuestions: new Map(), + pendingQuestionGenerationById: new Map(), + questionReplyStateById: new Map(), pendingTextDeltasByPartId: new Map(), partById: new Map(), partSnapshotKeyById: new Map(), @@ -3765,26 +5101,48 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { messageRoleById: new Map(), messageSnapshotKeyById: new Map(), completedAssistantPartIds: new Set(), + projectedOwnedPartSnapshotKeyById: new Map(), + seenSubscribedEventIds: new Set(), + subscribedEventDedupeLimit: eventDedupeLimit, relatedSessionIds: new Set(), turns: [], modelContextLimitBySlug: started.modelContextLimitBySlug, lastKnownTokenUsage: undefined, lastEmittedTokenUsageKey: undefined, latestTurnCostUsd: undefined, + turnGenerationCounter: 0, + activeTurnGeneration: undefined, activeTurnId: undefined, activeTurnEventSerial: 0, activeTurnProviderActivitySerial: 0, activeTurnCompletionActivitySerial: 0, activeTurnSawToolCallFinish: false, activeTurnSawFinalAssistant: false, + latestObservedSessionNextTimestamp: undefined, + activeTurnFinalAssistantMessageId: undefined, + activeTurnPromptMessageId: undefined, + activeTurnBaselineTrusted: false, + activeTurnBaselineMessageIds: new Set(), + activeTurnAssistantMessageIds: new Set(), + activeTurnRejectedAssistantMessageIds: new Set(), + activeTurnToolCallAssistantMessageIds: new Set(), + activeTurnUnsettledAssistantTextIds: new Set(), activeTurnToolCallIdleWatchdogStarted: false, + requiresStrictOwnershipUntilBoundary: false, + cancellationDrainPending: false, + cancellationInFlight: false, + quarantinedAfterAbortFailure: false, + activeTurnAcceptsSessionNextEvents: true, activeInteractionMode: undefined, activeAgent: undefined, activeVariant: undefined, stopped: yield* Ref.make(false), sessionScope: started.sessionScope, + retirementAttempt: undefined, + replacementClaimed: false, }; sessions.set(input.threadId, context); + registerOpenCodeContext(context); yield* startEventPump(context); yield* emit({ @@ -3811,6 +5169,22 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = ensureAdapterSessionContext(input.threadId); + if (context.cancellationInFlight || context.quarantinedAfterAbortFailure) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "sendTurn", + detail: context.cancellationInFlight + ? `${adapterConfig.displayName} is still cancelling the previous turn.` + : `${adapterConfig.displayName} could not confirm cancellation of the previous turn. Restart the provider session before sending again.`, + }); + } + if (context.activeTurnGeneration !== undefined) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "sendTurn", + detail: `${adapterConfig.displayName} already has an active turn.`, + }); + } const turnId = TurnId.makeUnsafe(`${adapterConfig.turnIdPrefix}-${randomUUID()}`); const modelSelection = input.modelSelection ?? @@ -3860,13 +5234,40 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ? input.modelSelection.options?.variant : undefined; + const promptMessageId = `msg_${randomUUID()}`; + const generation: OpenCodeTurnGeneration = { + id: ++context.turnGenerationCounter, + turnId, + promptMessageId, + state: "active", + cancellationFinalizerStarted: false, + interactionOwned: false, + sessionNextOwned: false, + sessionNextProgressObserved: false, + sessionNextTimestampFloor: context.latestObservedSessionNextTimestamp, + sessionNextLatestAcceptedTimestamp: context.latestObservedSessionNextTimestamp, + lastOwnedProgressAtMs: Date.now(), + }; + context.activeTurnGeneration = generation; context.activeTurnId = turnId; context.activeTurnEventSerial = 0; context.activeTurnProviderActivitySerial = 0; context.activeTurnCompletionActivitySerial = 0; context.activeTurnSawToolCallFinish = false; context.activeTurnSawFinalAssistant = false; + context.activeTurnFinalAssistantMessageId = undefined; + context.activeTurnPromptMessageId = promptMessageId; + context.activeTurnBaselineTrusted = false; + context.activeTurnBaselineMessageIds.clear(); + context.activeTurnAssistantMessageIds.clear(); + context.activeTurnRejectedAssistantMessageIds.clear(); + context.activeTurnToolCallAssistantMessageIds.clear(); + context.activeTurnUnsettledAssistantTextIds.clear(); context.activeTurnToolCallIdleWatchdogStarted = false; + // session.next.* events carry no turn identifier. Keep them fenced until the + // provider echoes this exact prompt (or an assistant explicitly parented to it), + // which is the ordered event-stream boundary between provider generations. + context.activeTurnAcceptsSessionNextEvents = false; context.activeInteractionMode = input.interactionMode === "plan" ? "plan" : "default"; // Always pin Synara's interaction mode to OpenCode's primary agent. // Otherwise a user config with default agent=plan can trap default turns in plan mode. @@ -3884,150 +5285,431 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { { clearLastError: true }, ); - yield* emit({ - ...buildEventBase({ threadId: input.threadId, turnId }), - type: "turn.started", - payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), - }, - }); - - if (provider === "kilo") { - const snapshotWatchdogBaseline = yield* captureTurnSnapshotWatchdogBaseline(context); - const recoveryBaselineMessageIds = yield* captureOpenCodeRecoveryBaseline(context); - const providerActivitySerial = context.activeTurnProviderActivitySerial; - yield* schedulePromptAcceptedWatchdog(context, { - turnId, - providerActivitySerial, - excludedMessageIds: recoveryBaselineMessageIds, - }); - yield* submitOpenCodePrompt(context, { - turnId, - promptInput: { - sessionID: context.openCodeSessionId, - messageID: `msg_${randomUUID()}`, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - noReply: false, - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + return yield* Effect.gen(function* () { + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), }, }); - if (snapshotWatchdogBaseline.canStartWatchdog) { + + if (provider === "kilo") { + const snapshotWatchdogBaseline = yield* captureTurnSnapshotWatchdogBaseline(context); + context.activeTurnBaselineTrusted = snapshotWatchdogBaseline.baselineTrusted; + for (const messageId of snapshotWatchdogBaseline.messageIds) { + context.activeTurnBaselineMessageIds.add(messageId); + } + const providerActivitySerial = context.activeTurnProviderActivitySerial; + yield* schedulePromptAcceptedWatchdog(context, { + turnId, + generation, + providerActivitySerial, + excludedMessageIds: snapshotWatchdogBaseline.messageIds, + }); + yield* submitOpenCodePrompt(context, { + turnId, + generation, + promptInput: { + sessionID: context.openCodeSessionId, + messageID: generation.promptMessageId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + noReply: false, + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }, + }); yield* startTurnSnapshotWatchdog(context, turnId, snapshotWatchdogBaseline.messageIds, { pollMessagesWhileBusy: true, }); - } - } else { - // Capture the pre-turn message ids before submitting so the watchdog can - // distinguish this turn's final assistant message from prior ones. - const snapshotWatchdogBaseline = yield* captureTurnSnapshotWatchdogBaseline(context); - yield* submitOpenCodePromptAsync(context, { - turnId, - promptInput: { - sessionID: context.openCodeSessionId, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], - }, - }); - // OpenCode lacks Kilo's prompt-accepted hard-fail watchdog, but it still - // needs a completion backstop for dropped/delayed idle events. Keep the - // poll cheap (status-first) so large turns are not penalized. - if (snapshotWatchdogBaseline.canStartWatchdog) { + } else { + // Capture the pre-turn message ids before submitting so the watchdog can + // distinguish this turn's final assistant message from prior ones. + const snapshotWatchdogBaseline = yield* captureTurnSnapshotWatchdogBaseline(context); + context.activeTurnBaselineTrusted = snapshotWatchdogBaseline.baselineTrusted; + for (const messageId of snapshotWatchdogBaseline.messageIds) { + context.activeTurnBaselineMessageIds.add(messageId); + } + yield* submitOpenCodePromptAsync(context, { + turnId, + generation, + promptInput: { + sessionID: context.openCodeSessionId, + messageID: generation.promptMessageId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }, + }); + // OpenCode lacks Kilo's prompt-accepted hard-fail watchdog, but it still + // needs a completion backstop for dropped/delayed idle events. Keep the + // poll cheap (status-first) so large turns are not penalized. yield* startTurnSnapshotWatchdog(context, turnId, snapshotWatchdogBaseline.messageIds, { pollMessagesWhileBusy: false, }); } - } + yield* startTurnStallWatchdog(context, generation); - return { - threadId: input.threadId, - turnId, - resumeCursor: { openCodeSessionId: context.openCodeSessionId, cwd: context.directory }, - }; + return { + threadId: input.threadId, + turnId, + resumeCursor: { openCodeSessionId: context.openCodeSessionId, cwd: context.directory }, + }; + }).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? failOpenCodeTurnWithAbort(context, { + generation, + message: `${adapterConfig.displayName} turn initialization was interrupted or failed.`, + raw: { source: `scient.${provider}.send-turn-initialization-failed` }, + errorClass: "transport_error", + }).pipe(Effect.ignore) + : Effect.void, + ), + ); }); const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = ensureAdapterSessionContext(threadId); - const activeTurnId = turnId ?? context.activeTurnId; - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toAdapterRequestError)); - clearActiveTurnState(context); - updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - if (activeTurnId) { - yield* emit({ - ...buildEventBase({ threadId, turnId: activeTurnId }), - type: "turn.aborted", - payload: { - reason: "Interrupted by user.", - }, + if (context.cancellationInFlight) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "interruptTurn", + detail: `${adapterConfig.displayName} is already cancelling the active turn.`, + }); + } + const generation = context.activeTurnGeneration; + if (!isOpenCodeTurnGenerationActive(context, generation)) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "interruptTurn", + detail: `${adapterConfig.displayName} has no active turn to interrupt.`, + }); + } + if (turnId !== undefined && generation.turnId !== turnId) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "interruptTurn", + detail: `Refusing stale interrupt for ${turnId}; the active turn is ${generation.turnId}.`, }); } + return yield* Effect.uninterruptible( + Effect.gen(function* () { + // Claim cancellation synchronously before asking the provider to abort. + // Keep the claim uninterruptible through a short quiet drain so an RPC + // caller disappearing cannot strand or prematurely reopen the session. + claimOpenCodeTurnCancellation(context, generation); + generation.cancellationFinalizerStarted = true; + yield* cancelPendingInteractionsForGeneration(context, generation); + const abortExit = yield* Effect.exit( + abortOpenCodeSessionBounded(context, completionSnapshotTimeoutMs), + ); + if (Exit.isSuccess(abortExit)) { + yield* Effect.sleep(cancellationDrainQuietMs); + if (context.activeTurnGeneration !== generation) { + context.cancellationInFlight = false; + return; + } + clearActiveTurnState(context, generation); + context.cancellationInFlight = false; + updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + yield* emit({ + ...buildEventBase({ threadId, turnId: generation.turnId }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + return; + } + context.cancellationDrainPending = false; + context.cancellationInFlight = false; + const abortCause = Cause.squash(abortExit.cause); + const requestError = toAdapterRequestError( + OpenCodeRuntimeError.is(abortCause) + ? abortCause + : new OpenCodeRuntimeError({ + operation: "session.abort", + detail: openCodeRuntimeErrorDetail(abortCause), + cause: abortCause, + }), + ); + context.quarantinedAfterAbortFailure = true; + updateProviderSession( + context, + { + status: "error", + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + clearActiveTurnState(context, generation); + yield* emit({ + ...buildEventBase({ threadId, turnId: generation.turnId }), + type: "runtime.error", + payload: { + message: requestError.detail, + class: "transport_error", + }, + }); + return yield* requestError; + }), + ); }, ); const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( "respondToRequest", - )(function* (threadId, requestId, decision) { - const context = ensureAdapterSessionContext(threadId); - if (!context.pendingPermissions.has(requestId)) { - return yield* new ProviderAdapterRequestError({ - provider, - method: "permission.reply", - detail: `Unknown pending permission request: ${requestId}`, - }); - } + )((threadId, requestId, decision) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const context = ensureAdapterSessionContext(threadId); + const generation = context.activeTurnGeneration; + if ( + !isOpenCodeTurnGenerationActive(context, generation) || + !context.pendingPermissions.has(requestId) || + context.pendingPermissionGenerationById.get(requestId) !== generation.id || + context.permissionReplyStateById.has(requestId) + ) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + const replyState: OpenCodeInteractionReplyState = { + generationId: generation.id, + abortController: new AbortController(), + acknowledged: false, + cancelled: false, + }; + context.permissionReplyStateById.set(requestId, replyState); + const clearReplyState = () => { + if (context.permissionReplyStateById.get(requestId) === replyState) { + context.permissionReplyStateById.delete(requestId); + } + }; + const outcome = yield* Effect.exit( + restore( + runBoundedOpenCodeInteractionSdk("permission.reply", replyState, (signal) => + context.client.permission.reply( + { + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }, + { signal }, + ), + ).pipe(Effect.mapError(toAdapterRequestError)), + ), + ); + + if (replyState.acknowledged && !replyState.cancelled) { + clearReplyState(); + return; + } + if ( + replyState.cancelled || + !isOpenCodeTurnGenerationActive(context, generation) || + context.pendingPermissionGenerationById.get(requestId) !== generation.id + ) { + clearReplyState(); + return yield* new ProviderAdapterRequestError({ + provider, + method: "permission.reply", + detail: `Permission request ${requestId} no longer belongs to the active turn.`, + }); + } + if (Exit.isFailure(outcome)) { + yield* failOpenCodeTurnWithAbort(context, { + generation, + message: Cause.hasInterrupts(outcome.cause) + ? `${adapterConfig.displayName} permission reply was interrupted before its provider outcome could be confirmed.` + : `${adapterConfig.displayName} permission reply outcome could not be confirmed: ${openCodeRuntimeErrorDetail(Cause.squash(outcome.cause))}`, + raw: { + source: `scient.${provider}.permission-reply-outcome-ambiguous`, + requestId, + }, + errorClass: "transport_error", + }); + clearReplyState(); + return yield* Effect.failCause(outcome.cause); + } + if (outcome.value._tag === "None") { + const detail = `${adapterConfig.displayName} timed out while replying to permission ${requestId}.`; + yield* failOpenCodeTurnWithAbort(context, { + generation, + message: detail, + raw: { source: `scient.${provider}.permission-reply-timeout`, requestId }, + errorClass: "transport_error", + }); + clearReplyState(); + return yield* new ProviderAdapterRequestError({ + provider, + method: "permission.reply", + detail, + }); + } - yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), + const request = context.pendingPermissions.get(requestId); + context.pendingPermissions.delete(requestId); + context.pendingPermissionGenerationById.delete(requestId); + markOpenCodeTurnOwnedProgress(context, generation); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: generation.turnId, + requestId, + raw: { source: `scient.${provider}.permission-reply-submitted` }, + }), + type: "request.resolved", + payload: { + requestType: request ? mapPermissionToRequestType(request.permission) : "unknown", + decision, + }, + }); + clearReplyState(); }), - ).pipe(Effect.mapError(toAdapterRequestError)); - }); + ), + ); const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( "respondToUserInput", - )(function* (threadId, requestId, answers) { - const context = ensureAdapterSessionContext(threadId); - const request = context.pendingQuestions.get(requestId); - if (!request) { - return yield* new ProviderAdapterRequestError({ - provider, - method: "question.reply", - detail: `Unknown pending user-input request: ${requestId}`, - }); - } + )((threadId, requestId, answers) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const context = ensureAdapterSessionContext(threadId); + const generation = context.activeTurnGeneration; + const request = context.pendingQuestions.get(requestId); + if ( + !isOpenCodeTurnGenerationActive(context, generation) || + !request || + context.pendingQuestionGenerationById.get(requestId) !== generation.id || + context.questionReplyStateById.has(requestId) + ) { + return yield* new ProviderAdapterRequestError({ + provider, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + const replyState: OpenCodeInteractionReplyState = { + generationId: generation.id, + abortController: new AbortController(), + acknowledged: false, + cancelled: false, + }; + context.questionReplyStateById.set(requestId, replyState); + const clearReplyState = () => { + if (context.questionReplyStateById.get(requestId) === replyState) { + context.questionReplyStateById.delete(requestId); + } + }; + const outcome = yield* Effect.exit( + restore( + runBoundedOpenCodeInteractionSdk("question.reply", replyState, (signal) => + context.client.question.reply( + { + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }, + { signal }, + ), + ).pipe(Effect.mapError(toAdapterRequestError)), + ), + ); + + if (replyState.acknowledged && !replyState.cancelled) { + clearReplyState(); + return; + } + if ( + replyState.cancelled || + !isOpenCodeTurnGenerationActive(context, generation) || + context.pendingQuestionGenerationById.get(requestId) !== generation.id + ) { + clearReplyState(); + return yield* new ProviderAdapterRequestError({ + provider, + method: "question.reply", + detail: `Question request ${requestId} no longer belongs to the active turn.`, + }); + } + if (Exit.isFailure(outcome)) { + yield* failOpenCodeTurnWithAbort(context, { + generation, + message: Cause.hasInterrupts(outcome.cause) + ? `${adapterConfig.displayName} question reply was interrupted before its provider outcome could be confirmed.` + : `${adapterConfig.displayName} question reply outcome could not be confirmed: ${openCodeRuntimeErrorDetail(Cause.squash(outcome.cause))}`, + raw: { + source: `scient.${provider}.question-reply-outcome-ambiguous`, + requestId, + }, + errorClass: "transport_error", + }); + clearReplyState(); + return yield* Effect.failCause(outcome.cause); + } + if (outcome.value._tag === "None") { + const detail = `${adapterConfig.displayName} timed out while replying to question ${requestId}.`; + yield* failOpenCodeTurnWithAbort(context, { + generation, + message: detail, + raw: { source: `scient.${provider}.question-reply-timeout`, requestId }, + errorClass: "transport_error", + }); + clearReplyState(); + return yield* new ProviderAdapterRequestError({ + provider, + method: "question.reply", + detail, + }); + } - yield* runOpenCodeSdk("question.reply", () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), + context.pendingQuestions.delete(requestId); + context.pendingQuestionGenerationById.delete(requestId); + markOpenCodeTurnOwnedProgress(context, generation); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: generation.turnId, + requestId, + raw: { source: `scient.${provider}.question-reply-submitted` }, + }), + type: "user-input.resolved", + payload: { answers }, + }); + clearReplyState(); }), - ).pipe(Effect.mapError(toAdapterRequestError)); - }); + ), + ); const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( function* (threadId) { const context = ensureAdapterSessionContext(threadId); - yield* stopOpenCodeContext(context); - sessions.delete(threadId); - yield* emit({ - ...buildEventBase({ threadId }), - type: "session.exited", - payload: { - reason: "Session stopped.", - recoverable: false, - exitKind: "graceful", - }, - }); + return yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* retireOpenCodeContext(context).pipe(Effect.mapError(toAdapterRequestError)); + if (sessions.get(threadId) !== context) { + return; + } + sessions.delete(threadId); + yield* emit({ + ...buildEventBase({ threadId }), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }), + ); }, ); @@ -4517,10 +6199,21 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const stopAll: OpenCodeAdapterShape["stopAll"] = () => Effect.gen(function* () { const contexts = [...sessions.values()]; - sessions.clear(); yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => + Effect.uninterruptible( + retireOpenCodeContext(context).pipe( + Effect.tap(() => + Effect.sync(() => { + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + }), + ), + Effect.mapError(toAdapterRequestError), + ), + ), { concurrency: "unbounded", discard: true }, ); }); diff --git a/apps/server/src/provider/Layers/ProviderConnection.test.ts b/apps/server/src/provider/Layers/ProviderConnection.test.ts index 580bd9968..dc9fb0a10 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.test.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.test.ts @@ -1,6 +1,7 @@ import type { ProviderKind, ServerProviderConnectionState, + ServerProviderInstallationState, ServerProviderRuntimeSource, ServerProviderStatus, } from "@synara/contracts"; @@ -30,11 +31,15 @@ import { import { antigravityAuthenticationCommandArgs, + CODEX_DEVICE_CODE_CONNECTION_TIMEOUT, expectedMethodForProvider, makeProviderConnectionLive, parseAntigravityOAuthAuthorizationUrl, + parseCodexDeviceAuthorization, + parseCodexOAuthAuthorizationUrl, parseGrokOAuthAuthorizationUrl, providerConnectionCommandArgs, + resolveProviderConnectionTimeout, } from "./ProviderConnection"; import { resolveProviderProbeCwd } from "./ProviderHealth"; @@ -127,6 +132,7 @@ function makeConnectionTestLayer(input?: { readonly provider?: ProviderKind; readonly runtimeSource?: ServerProviderRuntimeSource; readonly timeout?: Duration.Duration; + readonly codexDeviceCodeTimeout?: Duration.Duration; readonly antigravityCodeWindowTimeout?: Duration.Duration; readonly antigravityCodeWindowCloseSignal?: Effect.Effect; readonly antigravityTimeout?: Duration.Duration; @@ -153,6 +159,9 @@ function makeConnectionTestLayer(input?: { readonly listModelsHanging?: boolean; readonly initiallyAuthenticated?: boolean; readonly requiresProviderAccount?: boolean | null; + readonly installationState?: + | ServerProviderInstallationState + | (() => ServerProviderInstallationState | null); readonly onListModels?: (input: { readonly provider: ProviderKind; readonly binaryPath?: string; @@ -337,7 +346,10 @@ function makeConnectionTestLayer(input?: { previousReleaseAvailable: false, bundled: false, canInstall: false, - installationState: null, + installationState: + typeof input?.installationState === "function" + ? input.installationState() + : (input?.installationState ?? null), }), resolve: (provider, configured) => Effect.succeed({ @@ -362,6 +374,9 @@ function makeConnectionTestLayer(input?: { } satisfies ProviderRuntimeManagerShape); const layer = makeProviderConnectionLive({ ...(input?.timeout ? { timeout: input.timeout } : {}), + ...(input?.codexDeviceCodeTimeout + ? { codexDeviceCodeTimeout: input.codexDeviceCodeTimeout } + : {}), ...(input?.antigravityCodeWindowTimeout ? { antigravityCodeWindowTimeout: input.antigravityCodeWindowTimeout } : {}), @@ -417,6 +432,10 @@ describe("provider connection command allowlist", () => { it("uses Codex browser login with fixed argv", () => { expect(expectedMethodForProvider("codex")).toBe("codex_browser"); expect(providerConnectionCommandArgs("codex", "codex_browser")).toEqual(["login"]); + expect(providerConnectionCommandArgs("codex", "codex_device_code")).toEqual([ + "login", + "--device-auth", + ]); }); it("uses normal Claude account login by default and keeps explicit alternatives", () => { @@ -469,6 +488,49 @@ describe("provider connection command allowlist", () => { }); }); +describe("Codex authorization output parsing", () => { + const authorizationUrl = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + + it("accepts only an official PKCE browser authorization URL", () => { + expect(parseCodexOAuthAuthorizationUrl(`Open this URL:\n${authorizationUrl}\n`)).toBe( + authorizationUrl, + ); + expect( + parseCodexOAuthAuthorizationUrl( + authorizationUrl.replace("auth.openai.com", "auth.openai.com.example.com"), + ), + ).toBeNull(); + expect( + parseCodexOAuthAuthorizationUrl(authorizationUrl.replace("code_challenge", "ignored")), + ).toBeNull(); + expect( + parseCodexOAuthAuthorizationUrl( + authorizationUrl.replace( + "http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + "https%3A%2F%2Fexample.com%2Fcallback", + ), + ), + ).toBeNull(); + }); + + it("extracts the official device page and bounded one-time code", () => { + expect( + parseCodexDeviceAuthorization( + "1. Open this link\n\u001B[34mhttps://auth.openai.com/codex/device\u001B[0m\n2. Enter this one-time code\n\u001B[34mABCD-EFGH\u001B[0m\n", + ), + ).toEqual({ + authorizationUrl: "https://auth.openai.com/codex/device", + userCode: "ABCD-EFGH", + }); + expect( + parseCodexDeviceAuthorization( + "https://auth.openai.com.example.com/codex/device\nnot-a-device-code", + ), + ).toEqual({}); + }); +}); + describe("Grok OAuth authorization URL parsing", () => { const authorizationUrl = "https://auth.x.ai/oauth2/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A50418%2Fcallback&state=test-state&code_challenge=test-challenge"; @@ -541,6 +603,202 @@ describe("Antigravity OAuth authorization URL parsing", () => { }); describe("ProviderConnectionLive", () => { + it("waits for the exact installation operation before starting sign-in", async () => { + const onSpawn = vi.fn(); + let installationState: ServerProviderInstallationState = { + operationId: "trusted-plan-transition", + operation: "install", + status: "downloading", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: null, + message: "Downloading Codex.", + }; + const fixture = makeConnectionTestLayer({ + provider: "codex", + installationState: () => installationState, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: "trusted-plan-transition", + }); + yield* Effect.sleep(Duration.millis(20)); + expect(onSpawn).not.toHaveBeenCalled(); + installationState = { + ...installationState, + status: "installed", + finishedAt: "2026-07-23T10:00:02.000Z", + message: "Codex is installed and verified.", + }; + const connected = fixture.waitForConnectionState((state) => state?.status === "connected"); + yield* Effect.promise(() => connected); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: "codex", args: ["login"] }), + ); + }); + + it.each(["failed", "cancelled"] as const)( + "does not start sign-in after the exact installation operation is %s", + async (status) => { + const onSpawn = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + installationState: { + operationId: `trusted-plan-${status}`, + operation: "install", + status, + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:02.000Z", + message: `Installation ${status}.`, + }, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: `trusted-plan-${status}`, + }); + yield* Effect.sleep(Duration.millis(40)); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).not.toHaveBeenCalled(); + }, + ); + + it("starts sign-in only after the exact requested installation succeeds", async () => { + const onSpawn = vi.fn(); + const installationState = { + operationId: "trusted-plan-1", + operation: "install", + status: "installed", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:02.000Z", + message: "Codex is installed and verified.", + } satisfies ServerProviderInstallationState; + const fixture = makeConnectionTestLayer({ + provider: "codex", + installationState, + onSpawn, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: "different-plan", + }); + yield* Effect.sleep(Duration.millis(20)); + expect(onSpawn).not.toHaveBeenCalled(); + yield* connection.startAfterInstallation({ + provider: "codex", + method: "codex_browser", + installationOperationId: "trusted-plan-1", + }); + const connected = fixture.waitForConnectionState((state) => state?.status === "connected"); + yield* Effect.promise(() => connected); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: "codex", args: ["login"] }), + ); + }); + + it("publishes Codex's official device page and one-time code", async () => { + const fixture = makeConnectionTestLayer({ + provider: "codex", + hanging: true, + processStdout: + "1. Open this link\nhttps://auth.openai.com/codex/device\n2. Enter this one-time code\nABCD-EFGH\n", + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + const codePublished = fixture.waitForConnectionState( + (state) => state?.userCode === "ABCD-EFGH", + ); + const started = yield* connection.start({ + provider: "codex", + method: "codex_device_code", + }); + const operationId = started.providers[0]?.connectionState?.operationId; + yield* Effect.promise(() => codePublished); + expect(fixture.getConnectionState()).toMatchObject({ + authorizationUrl: "https://auth.openai.com/codex/device", + userCode: "ABCD-EFGH", + }); + yield* connection.cancel({ provider: "codex", operationId: operationId! }); + }).pipe(Effect.provide(fixture.layer)), + ); + }); + + it("gives Codex device authorization its full provider window and respects explicit overrides", () => { + const defaultTimeout = Duration.minutes(10); + const antigravityTimeout = Duration.minutes(11); + expect(Duration.toMillis(CODEX_DEVICE_CODE_CONNECTION_TIMEOUT)).toBe(16 * 60 * 1_000); + expect( + Duration.toMillis( + resolveProviderConnectionTimeout({ + provider: "codex", + method: "codex_device_code", + defaultTimeout, + codexDeviceCodeTimeout: CODEX_DEVICE_CODE_CONNECTION_TIMEOUT, + antigravityTimeout, + }), + ), + ).toBe(16 * 60 * 1_000); + expect( + Duration.toMillis( + resolveProviderConnectionTimeout({ + provider: "codex", + method: "codex_device_code", + explicitTimeout: Duration.millis(5), + defaultTimeout, + codexDeviceCodeTimeout: CODEX_DEVICE_CODE_CONNECTION_TIMEOUT, + antigravityTimeout, + }), + ), + ).toBe(5); + }); + + it("times out and cleans up a Codex device authorization using its dedicated policy", async () => { + const onKill = vi.fn(); + const fixture = makeConnectionTestLayer({ + provider: "codex", + hanging: true, + codexDeviceCodeTimeout: Duration.millis(5), + onKill, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const connection = yield* ProviderConnection; + yield* connection.start({ provider: "codex", method: "codex_device_code" }); + yield* Effect.sleep(Duration.millis(20)); + expect(fixture.getConnectionState()?.status).toBe("failed"); + expect(fixture.getConnectionState()?.message).toContain("timed out"); + }).pipe(Effect.provide(fixture.layer)), + ); + + expect(onKill).toHaveBeenCalledTimes(1); + }); + it("runs managed Antigravity's browser-auth bootstrap and verifies models", async () => { const onSpawn = vi.fn(); const onAuthenticationKill = vi.fn(); diff --git a/apps/server/src/provider/Layers/ProviderConnection.ts b/apps/server/src/provider/Layers/ProviderConnection.ts index e1aac571a..f3b3da097 100644 --- a/apps/server/src/provider/Layers/ProviderConnection.ts +++ b/apps/server/src/provider/Layers/ProviderConnection.ts @@ -49,6 +49,9 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import { parseAntigravityModelsAuthStatus, resolveProviderProbeCwd } from "./ProviderHealth"; const CONNECTION_TIMEOUT = Duration.minutes(10); +export const CODEX_DEVICE_CODE_CONNECTION_TIMEOUT = Duration.minutes(16); +const INSTALLATION_HANDOFF_TIMEOUT = Duration.minutes(30); +const INSTALLATION_HANDOFF_POLL_INTERVAL = Duration.millis(250); const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60; const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS); const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500); @@ -89,10 +92,19 @@ const GOOGLE_OAUTH_AUTHORIZATION_ORIGIN = "https://accounts.google.com"; const GOOGLE_OAUTH_AUTHORIZATION_PATHS = new Set(["/o/oauth2/auth", "/o/oauth2/v2/auth"]); const ANTIGRAVITY_OAUTH_CALLBACK_ORIGIN = "https://antigravity.google"; const ANTIGRAVITY_OAUTH_CALLBACK_PATH = "/oauth-callback"; +const CODEX_OAUTH_AUTHORIZATION_ORIGINS = new Set([ + "https://auth.openai.com", + "https://chatgpt.com", +]); +const CODEX_DEVICE_PATH = "/codex/device"; const OAUTH_OUTPUT_BUFFER_MAX_CHARS = 16 * 1024; const ANTIGRAVITY_AUTH_PROMPT = "Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task."; const authorizationCodeEncoder = new TextEncoder(); +const ANSI_SEQUENCE_PATTERN = new RegExp( + String.raw`\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))`, + "gu", +); function outputUrlCandidates(output: string): ReadonlyArray { return (output.match(/https:\/\/[^\s<>"']+/gu) ?? []).filter( @@ -104,6 +116,85 @@ function outputUrlCandidates(output: string): ReadonlyArray { ); } +function stripAnsi(output: string): string { + return output.replace(ANSI_SEQUENCE_PATTERN, ""); +} + +function isSafeCodexUrl(candidate: string): URL | null { + if (candidate.length > 8_192) return null; + try { + const url = new URL(candidate); + return url.protocol === "https:" && + CODEX_OAUTH_AUTHORIZATION_ORIGINS.has(url.origin) && + !url.hash && + !url.username && + !url.password + ? url + : null; + } catch { + return null; + } +} + +export function parseCodexOAuthAuthorizationUrl(output: string): string | null { + for (const candidate of outputUrlCandidates(stripAnsi(output))) { + const url = isSafeCodexUrl(candidate); + if ( + url && + url.pathname === "/oauth/authorize" && + url.searchParams.get("response_type") === "code" && + url.searchParams.get("client_id") && + url.searchParams.get("state") && + url.searchParams.get("code_challenge") && + url.searchParams.get("code_challenge_method") === "S256" + ) { + const redirectValue = url.searchParams.get("redirect_uri"); + if (!redirectValue) continue; + try { + const redirectUrl = new URL(redirectValue); + if ( + redirectUrl.protocol === "http:" && + (redirectUrl.hostname === "localhost" || redirectUrl.hostname === "127.0.0.1") && + redirectUrl.port && + redirectUrl.pathname === "/auth/callback" && + !redirectUrl.search && + !redirectUrl.hash && + !redirectUrl.username && + !redirectUrl.password + ) { + return url.toString(); + } + } catch { + // Ignore malformed or incomplete output while the CLI is still streaming. + } + } + } + return null; +} + +export function parseCodexDeviceAuthorization(output: string): { + readonly authorizationUrl?: string; + readonly userCode?: string; +} { + const cleaned = stripAnsi(output); + let authorizationUrl: string | undefined; + for (const candidate of outputUrlCandidates(cleaned)) { + const url = isSafeCodexUrl(candidate); + if (url?.pathname === CODEX_DEVICE_PATH && !url.search) { + authorizationUrl = url.toString(); + break; + } + } + const userCode = cleaned + .split(/\r?\n/gu) + .map((line) => line.trim()) + .find((line) => /^[A-Z0-9]{4,}(?:-[A-Z0-9]{2,})+$/u.test(line) && line.length <= 64); + return { + ...(authorizationUrl ? { authorizationUrl } : {}), + ...(userCode ? { userCode } : {}), + }; +} + export function parseGrokOAuthAuthorizationUrl(output: string): string | null { for (const candidate of outputUrlCandidates(output)) { if (candidate.length > 8_192) continue; @@ -230,6 +321,9 @@ export function providerConnectionCommandArgs( method: ServerProviderConnectionMethod, ): ReadonlyArray | null { if (provider === "codex" && method === "codex_browser") return ["login"]; + if (provider === "codex" && method === "codex_device_code") { + return ["login", "--device-auth"]; + } if (provider === "claudeAgent" && method === "claude_account") { return ["auth", "login"]; } @@ -256,8 +350,25 @@ function makeConnectionError(input: { return new ServerProviderConnectionError(input); } +export function resolveProviderConnectionTimeout(input: { + readonly provider: ProviderKind; + readonly method: ServerProviderConnectionMethod; + readonly explicitTimeout?: Duration.Duration; + readonly defaultTimeout: Duration.Duration; + readonly codexDeviceCodeTimeout: Duration.Duration; + readonly antigravityTimeout: Duration.Duration; +}): Duration.Duration { + if (input.explicitTimeout !== undefined) return input.explicitTimeout; + if (input.provider === "codex" && input.method === "codex_device_code") { + return input.codexDeviceCodeTimeout; + } + if (input.provider === "antigravity") return input.antigravityTimeout; + return input.defaultTimeout; +} + export function makeProviderConnectionLive(options?: { readonly timeout?: Duration.Duration; + readonly codexDeviceCodeTimeout?: Duration.Duration; readonly antigravityCodeWindowTimeout?: Duration.Duration; readonly antigravityCodeWindowCloseSignal?: Effect.Effect; readonly antigravityTimeout?: Duration.Duration; @@ -268,6 +379,8 @@ export function makeProviderConnectionLive(options?: { readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication; }) { const timeout = options?.timeout ?? CONNECTION_TIMEOUT; + const codexDeviceCodeTimeout = + options?.codexDeviceCodeTimeout ?? CODEX_DEVICE_CODE_CONNECTION_TIMEOUT; const antigravityCodeWindowTimeout = options?.antigravityCodeWindowTimeout ?? ANTIGRAVITY_CODE_WINDOW_TIMEOUT; const antigravityTimeout = options?.antigravityTimeout ?? ANTIGRAVITY_CONNECTION_TIMEOUT; @@ -394,7 +507,10 @@ export function makeProviderConnectionLive(options?: { ...runtimeEnv, CODEX_HOME: resolveBaseCodexHomePath(process.env, homePath), }, - waitingMessage: "Finish signing in to ChatGPT in the browser window.", + waitingMessage: + method === "codex_device_code" + ? "Open the secure OpenAI page and enter the one-time code shown here." + : "Finish signing in to ChatGPT in the browser window.", } satisfies ConnectionCommand; } @@ -766,6 +882,7 @@ export function makeProviderConnectionLive(options?: { readonly message: string; readonly finished?: boolean; readonly authorizationUrl?: string; + readonly userCode?: string; }): ServerProviderConnectionState => ({ operationId, method, @@ -774,11 +891,12 @@ export function makeProviderConnectionLive(options?: { finishedAt: input.finished ? new Date().toISOString() : null, message: input.message, ...(input.authorizationUrl ? { authorizationUrl: input.authorizationUrl } : {}), + ...(input.userCode ? { userCode: input.userCode } : {}), }); yield* publishState( provider, - state({ status: "starting", message: "Starting secure browser sign in." }), + state({ status: "starting", message: "Starting secure provider sign in." }), ); const operation = Effect.gen(function* () { @@ -788,27 +906,46 @@ export function makeProviderConnectionLive(options?: { ); let oauthOutputBuffer = ""; let publishedAuthorizationUrl: string | null = null; + let publishedUserCode: string | null = null; const oauthOutputObserver: ConnectionOutputObserver | undefined = - provider === "grok" || provider === "antigravity" + provider === "grok" || provider === "antigravity" || provider === "codex" ? { onOutputChunk: (chunk) => { - if (publishedAuthorizationUrl) return undefined; oauthOutputBuffer = `${oauthOutputBuffer}${Buffer.from(chunk).toString("utf8")}`.slice( -OAUTH_OUTPUT_BUFFER_MAX_CHARS, ); - const authorizationUrl = - provider === "grok" - ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer) - : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer); - if (!authorizationUrl) return undefined; - publishedAuthorizationUrl = authorizationUrl; + const codexDevice = + provider === "codex" && method === "codex_device_code" + ? parseCodexDeviceAuthorization(oauthOutputBuffer) + : null; + const authorizationUrl = codexDevice + ? (codexDevice.authorizationUrl ?? null) + : provider === "codex" + ? parseCodexOAuthAuthorizationUrl(oauthOutputBuffer) + : provider === "grok" + ? parseGrokOAuthAuthorizationUrl(oauthOutputBuffer) + : parseAntigravityOAuthAuthorizationUrl(oauthOutputBuffer); + const userCode = codexDevice?.userCode ?? null; + const nextAuthorizationUrl = authorizationUrl ?? publishedAuthorizationUrl; + const nextUserCode = userCode ?? publishedUserCode; + if ( + nextAuthorizationUrl === publishedAuthorizationUrl && + nextUserCode === publishedUserCode + ) { + return undefined; + } + publishedAuthorizationUrl = nextAuthorizationUrl; + publishedUserCode = nextUserCode; return publishState( provider, state({ status: "waiting_for_browser", message: command.waitingMessage, - authorizationUrl, + ...(nextAuthorizationUrl + ? { authorizationUrl: nextAuthorizationUrl } + : {}), + ...(nextUserCode ? { userCode: nextUserCode } : {}), }), ).pipe(Effect.asVoid); }, @@ -839,10 +976,14 @@ export function makeProviderConnectionLive(options?: { oauthOutputObserver, ) : runCommand(command, oauthOutputObserver).pipe(Effect.scoped); - const operationTimeout = - provider === "antigravity" && options?.timeout === undefined - ? antigravityTimeout - : timeout; + const operationTimeout = resolveProviderConnectionTimeout({ + provider, + method, + ...(options?.timeout !== undefined ? { explicitTimeout: options.timeout } : {}), + defaultTimeout: timeout, + codexDeviceCodeTimeout, + antigravityTimeout, + }); const exitCodeResult = yield* connectionProcess.pipe( Effect.timeoutOption(operationTimeout), Effect.result, @@ -989,6 +1130,60 @@ export function makeProviderConnectionLive(options?: { }, ); + const startAfterInstallation: ProviderConnectionShape["startAfterInstallation"] = Effect.fn( + "ProviderConnection.startAfterInstallation", + )(function* (input) { + if ( + providerConnectionCommandArgs(input.provider, input.method) === null || + (input.provider !== "codex" && expectedMethodForProvider(input.provider) !== input.method) + ) { + yield* Effect.logWarning("Ignoring an invalid provider installation handoff.", { + provider: input.provider, + method: input.method, + }); + return; + } + + const monitor = Effect.gen(function* () { + const initial = yield* providerRuntimeManager.getSnapshot(input.provider); + if (initial.installationState?.operationId !== input.installationOperationId) { + yield* Effect.logWarning("Ignoring a stale provider installation handoff.", { + provider: input.provider, + installationOperationId: input.installationOperationId, + }); + return; + } + while (true) { + const snapshot = yield* providerRuntimeManager.getSnapshot(input.provider); + const installation = snapshot.installationState; + if (installation?.operationId === input.installationOperationId) { + if (installation.status === "installed") { + const result = yield* Effect.result( + start({ provider: input.provider, method: input.method }), + ); + if (Result.isFailure(result)) { + const now = new Date().toISOString(); + yield* publishState(input.provider, { + operationId: `handoff-${input.installationOperationId}`, + method: input.method, + status: "failed", + startedAt: now, + finishedAt: now, + message: `Installation succeeded, but sign in could not start: ${result.failure.message}`, + }); + } + return; + } + if (installation.status === "failed" || installation.status === "cancelled") { + return; + } + } + yield* Effect.sleep(INSTALLATION_HANDOFF_POLL_INTERVAL); + } + }).pipe(Effect.timeoutOption(INSTALLATION_HANDOFF_TIMEOUT), Effect.asVoid); + yield* monitor.pipe(Effect.forkIn(operationScope)); + }); + const cancel: ProviderConnectionShape["cancel"] = Effect.fn("ProviderConnection.cancel")( function* (input) { const active = (yield* Ref.get(activeConnectionsRef)).get(input.provider); @@ -1075,7 +1270,12 @@ export function makeProviderConnectionLive(options?: { return { providers: yield* providerHealth.getStatuses }; }); - return { start, cancel, submitAuthorizationCode } satisfies ProviderConnectionShape; + return { + start, + cancel, + submitAuthorizationCode, + startAfterInstallation, + } satisfies ProviderConnectionShape; }), ); } diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index 412ce1e29..bd038303b 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -1,18 +1,41 @@ import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, Layer } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +const shellMocks = vi.hoisted(() => ({ + readWindowsPersistentEnvironmentAsync: vi.fn( + async (): Promise>> => ({ ...process.env }), + ), +})); + +vi.mock("@synara/shared/shell", async (importOriginal) => ({ + ...(await importOriginal()), + readWindowsPersistentEnvironmentAsync: shellMocks.readWindowsPersistentEnvironmentAsync, +})); import { ServerConfig } from "../../config"; import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager"; import type { ProviderRuntimeCurrentRecord } from "../providerRuntimeTypes"; import { + cancelAndAwaitProviderRuntimeOperations, canActivateManagedRuntimeVersion, + findExecutableOnPath, + makeProviderRuntimeOperationGate, ProviderRuntimeManagerLive, + windowsKnownProviderExecutableCandidates, } from "./ProviderRuntimeManager"; function sha256(filePath: string): string { @@ -33,7 +56,52 @@ function resolveAntigravity(baseDir: string, configuredExecutable?: string) { }).pipe(Effect.provide(layer), Effect.scoped); } +function getProviderSnapshot(baseDir: string, provider: "codex" | "antigravity") { + const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe( + Layer.provide(NodeServices.layer), + ); + const layer = Layer.mergeAll( + configLayer, + ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer)), + ).pipe(Layer.provide(NodeServices.layer)); + return Effect.gen(function* () { + const manager = yield* ProviderRuntimeManager; + return yield* manager.getSnapshot(provider); + }).pipe(Effect.provide(layer), Effect.scoped); +} + describe("ProviderRuntimeManager managed integrity", () => { + it("makes cancellation and activation mutually exclusive", () => { + const cancelled = makeProviderRuntimeOperationGate(); + expect(cancelled.cancel()).toBe(true); + expect(cancelled.signal.aborted).toBe(true); + expect(cancelled.beginCommit()).toBe(false); + + const committing = makeProviderRuntimeOperationGate(); + expect(committing.beginCommit()).toBe(true); + expect(committing.cancel()).toBe(false); + expect(committing.signal.aborted).toBe(false); + }); + + it("waits for active runtime operations to finish during shutdown", async () => { + const gate = makeProviderRuntimeOperationGate(); + let finishOperation: (() => void) | undefined; + let operationFinished = false; + const completion = new Promise((resolve) => { + finishOperation = () => { + operationFinished = true; + resolve(); + }; + }); + + const shutdown = cancelAndAwaitProviderRuntimeOperations([{ gate, completion }]); + expect(gate.signal.aborted).toBe(true); + expect(operationFinished).toBe(false); + finishOperation?.(); + await shutdown; + expect(operationFinished).toBe(true); + }); + it("allows install or repair at the current version but never downgrades", () => { expect( canActivateManagedRuntimeVersion({ currentVersion: null, candidateVersion: "1.1.5" }), @@ -67,6 +135,142 @@ describe("ProviderRuntimeManager managed integrity", () => { } }); + it("loads the last terminal installation failure after restart", async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-state-")); + try { + const stateDir = path.join(baseDir, "userdata", "provider-installations"); + mkdirSync(stateDir, { recursive: true }); + const failure = { + operationId: "install-failed-1", + operation: "install", + status: "failed", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:01.000Z", + message: "Installation failed while downloading the provider: connection reset.", + }; + writeFileSync(path.join(stateDir, "codex.json"), JSON.stringify(failure)); + + const snapshot = await Effect.runPromise(getProviderSnapshot(baseDir, "codex")); + expect(snapshot.installationState).toEqual(failure); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("does not restore a stale successful installation after restart", async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-state-")); + try { + const stateDir = path.join(baseDir, "userdata", "provider-installations"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + path.join(stateDir, "codex.json"), + JSON.stringify({ + operationId: "install-succeeded-1", + operation: "install", + status: "installed", + startedAt: "2026-07-23T10:00:00.000Z", + finishedAt: "2026-07-23T10:00:01.000Z", + message: "Codex is installed and verified.", + }), + ); + + const snapshot = await Effect.runPromise(getProviderSnapshot(baseDir, "codex")); + expect(snapshot.installationState).toBeNull(); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it("recognizes the official standalone Codex install location on Windows", () => { + expect( + windowsKnownProviderExecutableCandidates({ + provider: "codex", + environment: { LOCALAPPDATA: "C:\\Users\\Levin\\AppData\\Local" }, + }), + ).toEqual(["C:\\Users\\Levin\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"]); + expect( + windowsKnownProviderExecutableCandidates({ + provider: "antigravity", + environment: { LOCALAPPDATA: "C:\\Users\\Levin\\AppData\\Local" }, + }), + ).toEqual([]); + }); + + it.runIf(process.platform === "win32")( + "finds a provider installed into a newly persisted Windows PATH entry", + async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-path-")); + try { + const executable = path.join(baseDir, "codex.exe"); + writeFileSync(executable, "test"); + const discovered = await findExecutableOnPath({ + command: "codex", + pathValue: baseDir, + platform: "win32", + }); + expect(discovered).not.toBeNull(); + expect(readFileSync(discovered!, "utf8")).toBe("test"); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform === "win32")( + "refreshes the manager's persisted Windows PATH after an external provider install", + async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-manager-path-")); + const previousPath = process.env.PATH; + const emptyPath = path.join(baseDir, "empty"); + let persistedPath = emptyPath; + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + shellMocks.readWindowsPersistentEnvironmentAsync.mockClear(); + shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation(async () => ({ + PATH: persistedPath, + })); + try { + mkdirSync(emptyPath); + writeFileSync(path.join(baseDir, "codex.exe"), "test"); + process.env.PATH = emptyPath; + const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe( + Layer.provide(NodeServices.layer), + ); + const runtimeLayer = ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer)); + const layer = Layer.mergeAll(configLayer, runtimeLayer).pipe( + Layer.provide(NodeServices.layer), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const manager = yield* ProviderRuntimeManager; + const beforeInstall = yield* manager.resolve("codex"); + persistedPath = baseDir; + now += 5_001; + const afterInstall = yield* manager.resolve("codex"); + return { beforeInstall, afterInstall }; + }).pipe(Effect.provide(layer), Effect.scoped), + ); + + expect(result.beforeInstall.source).toBe("missing"); + expect(result.afterInstall.source).toBe("system"); + expect(result.afterInstall.executable).not.toBeNull(); + expect(realpathSync.native(result.afterInstall.executable!)).toBe( + realpathSync.native(path.join(baseDir, "codex.exe")), + ); + expect(shellMocks.readWindowsPersistentEnvironmentAsync).toHaveBeenCalledTimes(2); + } finally { + nowSpy.mockRestore(); + shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation( + async (): Promise>> => ({ ...process.env }), + ); + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + it("revalidates the executable after restart and rejects later corruption", async () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-integrity-")); const previousPath = process.env.PATH; @@ -80,9 +284,18 @@ describe("ProviderRuntimeManager managed integrity", () => { "releases", releaseId, ); - const executablePath = path.join(releaseDir, "bin", "agy"); + const executableRelativePath = path.join( + "bin", + process.platform === "win32" ? "agy.cmd" : "agy", + ); + const executablePath = path.join(releaseDir, executableRelativePath); mkdirSync(path.dirname(executablePath), { recursive: true }); - writeFileSync(executablePath, "#!/bin/sh\necho 'Antigravity CLI 1.1.4'\n"); + writeFileSync( + executablePath, + process.platform === "win32" + ? "@echo off\r\necho Antigravity CLI 1.1.4\r\n" + : "#!/bin/sh\necho 'Antigravity CLI 1.1.4'\n", + ); chmodSync(executablePath, 0o700); const record: ProviderRuntimeCurrentRecord = { version: 1, @@ -90,7 +303,7 @@ describe("ProviderRuntimeManager managed integrity", () => { releaseId, previousReleaseId: null, runtimeVersion: "1.1.4", - executableRelativePath: path.join("bin", "agy"), + executableRelativePath, executablePath, smokeArgs: ["--version"], digestAlgorithm: "sha256", @@ -114,12 +327,29 @@ describe("ProviderRuntimeManager managed integrity", () => { expect(first.source).toBe("managed"); expect(first.executable).toBe(executablePath); - writeFileSync(executablePath, "#!/bin/sh\necho 'tampered'\n"); + writeFileSync( + executablePath, + process.platform === "win32" + ? "@echo off\r\necho tampered\r\n" + : "#!/bin/sh\necho 'tampered'\n", + ); chmodSync(executablePath, 0o700); const afterRestart = await Effect.runPromise(resolveAntigravity(baseDir)); expect(afterRestart.source).toBe("missing"); expect(afterRestart.executable).toBeNull(); expect(afterRestart.canRepair).toBe(true); + expect( + JSON.parse( + readFileSync( + path.join(baseDir, "userdata", "provider-installations", "antigravity.json"), + "utf8", + ), + ), + ).toMatchObject({ + operation: "repair", + status: "failed", + message: expect.stringContaining("damaged and must be repaired"), + }); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index a10e70582..8f5fb1987 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -2,17 +2,18 @@ import { createHash, randomUUID } from "node:crypto"; import { constants as FS_CONSTANTS, createReadStream } from "node:fs"; import FS from "node:fs/promises"; import Path from "node:path"; -import { delimiter as pathDelimiter } from "node:path"; import { PROVIDER_DISPLAY_NAMES, + ServerProviderInstallationState as ServerProviderInstallationStateSchema, type ProviderKind, ServerProviderInstallationError, type ServerProviderInstallationState, type ServerProviderRuntimeSource, } from "@synara/contracts"; import { compareSemverVersions } from "@synara/shared/providerVersions"; -import { Effect, Layer, PubSub, Stream } from "effect"; +import { mergePathEntries, readWindowsPersistentEnvironmentAsync } from "@synara/shared/shell"; +import { Effect, Layer, PubSub, Schema, Stream } from "effect"; import { ServerConfig } from "../../config"; import { writeFileStringAtomically } from "../../atomicWrite"; @@ -84,6 +85,17 @@ const PLAN_TTL_MS = 10 * 60 * 1000; const SMOKE_TIMEOUT_MS = 15_000; const SMOKE_OUTPUT_LIMIT = 64 * 1024; const MINIMUM_INSTALL_FREE_BYTES = 256 * 1024 * 1024; +const WINDOWS_ENVIRONMENT_CACHE_MS = 5_000; +const TERMINAL_INSTALLATION_STATUSES = new Set([ + "installed", + "succeeded", + "failed", + "cancelled", +]); +const RESTORABLE_INSTALLATION_STATUSES = new Set([ + "failed", + "cancelled", +]); type RuntimeOperation = ServerProviderInstallationState["operation"]; @@ -96,7 +108,43 @@ interface PreparedInstall { interface ActiveOperation { readonly operationId: string; readonly operation: RuntimeOperation; - readonly controller: AbortController; + readonly gate: ProviderRuntimeOperationGate; + completion: Promise; +} + +export interface ProviderRuntimeOperationGate { + readonly signal: AbortSignal; + readonly cancel: () => boolean; + readonly beginCommit: () => boolean; +} + +export function makeProviderRuntimeOperationGate(): ProviderRuntimeOperationGate { + const controller = new AbortController(); + let phase: "running" | "cancelled" | "committing" = "running"; + return { + signal: controller.signal, + cancel: () => { + if (phase !== "running") return false; + phase = "cancelled"; + controller.abort(); + return true; + }, + beginCommit: () => { + if (phase !== "running" || controller.signal.aborted) return false; + phase = "committing"; + return true; + }, + }; +} + +export async function cancelAndAwaitProviderRuntimeOperations( + operations: ReadonlyArray<{ + readonly gate: ProviderRuntimeOperationGate; + readonly completion: Promise; + }>, +): Promise { + for (const operation of operations) operation.gate.cancel(); + await Promise.allSettled(operations.map((operation) => operation.completion)); } function installationError(input: { @@ -119,6 +167,39 @@ function currentRecordPath(stateDir: string, provider: ProviderKind): string { return Path.join(providerRoot(stateDir, provider), "current.json"); } +function installationStatePath(stateDir: string, provider: ProviderKind): string { + return Path.join(stateDir, "provider-installations", `${provider}.json`); +} + +const decodeInstallationState = Schema.decodeUnknownSync(ServerProviderInstallationStateSchema); + +async function readPersistedInstallationState( + stateDir: string, + provider: ProviderKind, +): Promise { + try { + const state = decodeInstallationState( + JSON.parse(await FS.readFile(installationStatePath(stateDir, provider), "utf8")), + ); + return RESTORABLE_INSTALLATION_STATUSES.has(state.status) ? state : null; + } catch { + return null; + } +} + +async function writePersistedInstallationState( + stateDir: string, + provider: ProviderKind, + state: ServerProviderInstallationState, +): Promise { + await Effect.runPromise( + writeFileStringAtomically({ + filePath: installationStatePath(stateDir, provider), + contents: `${JSON.stringify(state, null, 2)}\n`, + }), + ); +} + function releaseRoot(stateDir: string, provider: ProviderKind, releaseId: string): string { return Path.join(providerRoot(stateDir, provider), "releases", releaseId); } @@ -237,21 +318,23 @@ async function removeCurrentRecord(stateDir: string, provider: ProviderKind): Pr await FS.rm(currentRecordPath(stateDir, provider), { force: true }); } -function findExecutableOnPath(input: { +export function findExecutableOnPath(input: { readonly command: string; readonly pathValue: string; readonly platform?: NodeJS.Platform; }): Promise { const platform = input.platform ?? process.platform; + const pathApi = platform === "win32" ? Path.win32 : Path.posix; + const delimiter = platform === "win32" ? ";" : ":"; const extensions = platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((part) => part.toLowerCase()) : [""]; return (async () => { - for (const directory of input.pathValue.split(pathDelimiter).filter(Boolean)) { + for (const directory of input.pathValue.split(delimiter).filter(Boolean)) { for (const extension of extensions) { - const hasExtension = Path.extname(input.command).length > 0; - const candidate = Path.join( + const hasExtension = pathApi.extname(input.command).length > 0; + const candidate = pathApi.join( directory, hasExtension ? input.command : `${input.command}${extension}`, ); @@ -271,21 +354,45 @@ function findExecutableOnPath(input: { })(); } +export function windowsKnownProviderExecutableCandidates(input: { + readonly provider: ProviderKind; + readonly environment: NodeJS.ProcessEnv | Partial>; +}): ReadonlyArray { + if (input.provider !== "codex") return []; + const localAppData = Object.entries(input.environment) + .find(([name]) => name.toUpperCase() === "LOCALAPPDATA")?.[1] + ?.trim(); + return localAppData + ? [Path.win32.join(localAppData, "Programs", "OpenAI", "Codex", "bin", "codex.exe")] + : []; +} + +async function firstExistingFile(candidates: ReadonlyArray): Promise { + for (const candidate of candidates) { + const stat = await FS.stat(candidate).catch(() => null); + if (stat?.isFile()) return FS.realpath(candidate).catch(() => candidate); + } + return null; +} + async function resolveConfiguredExecutable(input: { readonly command: string; readonly pathValue: string; + readonly platform?: NodeJS.Platform; }): Promise { + const platform = input.platform ?? process.platform; + const pathApi = platform === "win32" ? Path.win32 : Path.posix; const hasPathSeparator = - Path.isAbsolute(input.command) || - input.command.includes(Path.sep) || - (process.platform === "win32" && input.command.includes("/")); + pathApi.isAbsolute(input.command) || + input.command.includes(pathApi.sep) || + (platform === "win32" && input.command.includes("/")); if (!hasPathSeparator) { - return findExecutableOnPath(input); + return findExecutableOnPath({ ...input, platform }); } const stat = await FS.stat(input.command).catch(() => null); if (!stat?.isFile()) return null; - if (process.platform !== "win32") { + if (platform !== "win32") { try { await FS.access(input.command, FS_CONSTANTS.X_OK); } catch { @@ -330,16 +437,25 @@ export const ProviderRuntimeManagerLive = Layer.effect( const changes = yield* PubSub.unbounded>(); const records = new Map(); const installationStates = new Map(); + const installationStateWrites = new Map>(); const plans = new Map(); const active = new Map(); const verifiedManagedReleases = new Set(); const managedVerificationPromises = new Map>(); const basePath = process.env.PATH ?? ""; + let windowsEnvironmentCache: Partial> | null = null; + let windowsEnvironmentReadAt = 0; const managedDirectoriesAdded = new Set(); let lastAssignedPath = basePath; let disposed = false; for (const provider of PROVIDERS) { + const persistedInstallationState = yield* Effect.promise(() => + readPersistedInstallationState(config.stateDir, provider), + ); + if (persistedInstallationState) { + installationStates.set(provider, persistedInstallationState); + } const record = yield* Effect.promise(() => readCurrentRecord(config.stateDir, provider)); if (record) { records.set(provider, record); @@ -353,9 +469,10 @@ export const ProviderRuntimeManagerLive = Layer.effect( // A prior remove only deactivates the runtime immediately so an already-running // provider process is never invalidated. The next app start is the safe GC point. yield* Effect.promise(() => - FS.rm(providerRoot(config.stateDir, provider), { recursive: true, force: true }).catch( - () => undefined, - ), + FS.rm(providerRoot(config.stateDir, provider), { + recursive: true, + force: true, + }).catch(() => undefined), ); } else { yield* Effect.logWarning( @@ -365,14 +482,42 @@ export const ProviderRuntimeManagerLive = Layer.effect( } } + const currentSystemEnvironment = async (): Promise>> => { + if (process.platform !== "win32") return process.env; + if ( + windowsEnvironmentCache && + Date.now() - windowsEnvironmentReadAt < WINDOWS_ENVIRONMENT_CACHE_MS + ) { + return windowsEnvironmentCache; + } + try { + windowsEnvironmentCache = await readWindowsPersistentEnvironmentAsync(); + windowsEnvironmentReadAt = Date.now(); + } catch { + windowsEnvironmentCache = {}; + windowsEnvironmentReadAt = Date.now(); + } + return windowsEnvironmentCache; + }; + + const currentSystemPath = async (): Promise<{ + readonly pathValue: string; + readonly environment: Partial>; + }> => { + const environment = await currentSystemEnvironment(); + return { + pathValue: mergePathEntries(environment.PATH, basePath, process.platform) ?? basePath, + environment, + }; + }; + const refreshProcessPath = () => { const managedDirectories = Array.from(records.values()) .filter((record) => verifiedManagedReleases.has(`${record.provider}:${record.releaseId}`)) .map((record) => Path.dirname(record.executablePath)); for (const directory of managedDirectories) managedDirectoriesAdded.add(directory); - lastAssignedPath = [basePath, ...new Set(managedDirectories)] - .filter(Boolean) - .join(pathDelimiter); + const delimiter = process.platform === "win32" ? ";" : ":"; + lastAssignedPath = [basePath, ...new Set(managedDirectories)].filter(Boolean).join(delimiter); process.env.PATH = lastAssignedPath; }; refreshProcessPath(); @@ -412,7 +557,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( readonly totalBytes?: number | null; }, ) => { - installationStates.set(provider, { + const state: ServerProviderInstallationState = { operationId: input.operationId, operation: input.operation, status: input.status, @@ -429,7 +574,23 @@ export const ProviderRuntimeManagerLive = Layer.effect( input.totalBytes === null ? null : Math.max(0, Math.trunc(input.totalBytes)), } : {}), - }); + }; + installationStates.set(provider, state); + if (TERMINAL_INSTALLATION_STATUSES.has(state.status)) { + const previousWrite = installationStateWrites.get(provider) ?? Promise.resolve(); + const nextWrite = previousWrite + .catch(() => undefined) + .then(() => writePersistedInstallationState(config.stateDir, provider, state)) + .catch((cause) => + Effect.runPromise( + Effect.logWarning("Failed to persist provider installation diagnostics.", { + provider, + cause, + }), + ), + ); + installationStateWrites.set(provider, nextWrite); + } publish(); }; @@ -470,10 +631,11 @@ export const ProviderRuntimeManagerLive = Layer.effect( const startOperation = (input: { readonly provider: ProviderKind; readonly operation: RuntimeOperation; + readonly operationId?: string; readonly run: (context: { readonly operationId: string; readonly startedAt: string; - readonly controller: AbortController; + readonly gate: ProviderRuntimeOperationGate; }) => Promise; }): Effect.Effect => Effect.gen(function* () { @@ -484,10 +646,16 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "A provider runtime operation is already running.", }); } - const operationId = randomUUID(); + const operationId = input.operationId ?? randomUUID(); const startedAt = new Date().toISOString(); - const controller = new AbortController(); - active.set(input.provider, { operationId, operation: input.operation, controller }); + const gate = makeProviderRuntimeOperationGate(); + const activeOperation: ActiveOperation = { + operationId, + operation: input.operation, + gate, + completion: Promise.resolve(), + }; + active.set(input.provider, activeOperation); setInstallationState(input.provider, { operationId, operation: input.operation, @@ -506,10 +674,34 @@ export const ProviderRuntimeManagerLive = Layer.effect( : "Removing the Scient-managed provider runtime.", }); - void input - .run({ operationId, startedAt, controller }) + const completion = Promise.resolve() + .then(() => input.run({ operationId, startedAt, gate })) .catch((cause) => { - const cancelled = controller.signal.aborted; + const cancelled = gate.signal.aborted; + const previousState = installationStates.get(input.provider); + const failedFromStatus = previousState?.status; + const failedAt = + input.operation === "rollback" + ? "restoring the previous provider" + : input.operation === "remove" + ? "removing the managed provider" + : failedFromStatus === "resolving" + ? "resolving the trusted release" + : failedFromStatus === "downloading" + ? "downloading the provider" + : failedFromStatus === "verifying" + ? "verifying the download" + : failedFromStatus === "smoke_testing" + ? "checking the installed provider" + : "installing the provider"; + const operationLabel = + input.operation === "repair" + ? "Repair" + : input.operation === "rollback" + ? "Rollback" + : input.operation === "remove" + ? "Removal" + : "Installation"; setInstallationState(input.provider, { operationId, operation: input.operation, @@ -518,13 +710,15 @@ export const ProviderRuntimeManagerLive = Layer.effect( finishedAt: new Date().toISOString(), message: cancelled ? "Provider runtime operation was cancelled." - : errorMessage(cause), + : `${operationLabel} failed while ${failedAt}: ${errorMessage(cause)}`, + ...(previousState?.version ? { version: previousState.version } : {}), }); }) .finally(() => { if (active.get(input.provider)?.operationId === operationId) active.delete(input.provider); }); + activeOperation.completion = completion; }); const runInstall = async (input: { @@ -533,9 +727,9 @@ export const ProviderRuntimeManagerLive = Layer.effect( readonly operationId: string; readonly operation: "install" | "repair"; readonly startedAt: string; - readonly controller: AbortController; + readonly gate: ProviderRuntimeOperationGate; }): Promise => { - const { provider, artifact, operationId, operation, startedAt, controller } = input; + const { provider, artifact, operationId, operation, startedAt, gate } = input; const root = providerRoot(config.stateDir, provider); const downloads = Path.join(root, "downloads"); await FS.mkdir(downloads, { recursive: true }); @@ -572,7 +766,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( url: artifact.url, destination: archivePath, allowedHosts: artifact.allowedHosts, - signal: controller.signal, + signal: gate.signal, ...(artifact.size ? { expectedSize: artifact.size } : {}), onProgress: (bytesDownloaded, totalBytes) => setInstallationState(provider, { @@ -599,8 +793,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( algorithm: artifact.digestAlgorithm, expectedDigest: artifact.digest, }); - if (controller.signal.aborted) - throw new DOMException("Installation cancelled.", "AbortError"); + if (gate.signal.aborted) throw new DOMException("Installation cancelled.", "AbortError"); setInstallationState(provider, { operationId, @@ -615,7 +808,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( destination: stagedRelease, format: artifact.archiveFormat, executablePath: artifact.executablePath, - signal: controller.signal, + signal: gate.signal, }); const recipe = getProviderRuntimeRecipe(provider); const managedExecutableRelativePath = Path.join( @@ -643,7 +836,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( await smokeTestExecutable({ executable, args: artifact.smokeArgs, - signal: controller.signal, + signal: gate.signal, }); const current = records.get(provider) ?? null; @@ -684,10 +877,12 @@ export const ProviderRuntimeManagerLive = Layer.effect( catalogRevision: artifact.catalogRevision, installedAt: new Date().toISOString(), }; + if (gate.signal.aborted) throw new DOMException("Installation cancelled.", "AbortError"); await writeFileStringAtomically({ filePath: releaseRecordPath(config.stateDir, provider, releaseId), contents: `${JSON.stringify({ ...record, previousReleaseId: null }, null, 2)}\n`, }).pipe(Effect.runPromise); + if (!gate.beginCommit()) throw new DOMException("Installation cancelled.", "AbortError"); await writeCurrentRecord(config.stateDir, provider, record); records.set(provider, record); verifiedManagedReleases.add(`${provider}:${record.releaseId}`); @@ -782,14 +977,15 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "install", - run: ({ operationId, startedAt, controller }) => + operationId: input.planToken, + run: ({ operationId, startedAt, gate }) => runInstall({ provider: input.provider, artifact: plan.artifact, operationId, operation: "install", startedAt, - controller, + gate, }), }); }); @@ -804,7 +1000,13 @@ export const ProviderRuntimeManagerLive = Layer.effect( message: "This provider runtime operation is no longer running.", }); } - operation.controller.abort(); + if (!operation.gate.cancel()) { + return yield* installationError({ + provider: input.provider, + reason: "operation_not_found", + message: "This provider runtime operation is already finishing.", + }); + } }); const repair: ProviderRuntimeManagerShape["repair"] = (input) => @@ -848,14 +1050,14 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "repair", - run: ({ operationId, startedAt, controller }) => + run: ({ operationId, startedAt, gate }) => runInstall({ provider: input.provider, artifact, operationId, operation: "repair", startedAt, - controller, + gate, }), }); }); @@ -873,7 +1075,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "rollback", - run: async ({ operationId, startedAt, controller }) => { + run: async ({ operationId, startedAt, gate }) => { const previousMetadata = await readReleaseRecord( config.stateDir, input.provider, @@ -888,7 +1090,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( await smokeTestExecutable({ executable: previousExecutable, args: previousMetadata.smokeArgs, - signal: controller.signal, + signal: gate.signal, }); const previousRecord: ProviderRuntimeCurrentRecord = { ...previousMetadata, @@ -897,6 +1099,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( executableDigest: await hashExecutable(previousExecutable), installedAt: new Date().toISOString(), }; + if (!gate.beginCommit()) throw new DOMException("Rollback cancelled.", "AbortError"); await writeCurrentRecord(config.stateDir, input.provider, previousRecord); records.set(input.provider, previousRecord); verifiedManagedReleases.add(`${input.provider}:${previousRecord.releaseId}`); @@ -925,7 +1128,8 @@ export const ProviderRuntimeManagerLive = Layer.effect( yield* startOperation({ provider: input.provider, operation: "remove", - run: async ({ operationId, startedAt }) => { + run: async ({ operationId, startedAt, gate }) => { + if (!gate.beginCommit()) throw new DOMException("Removal cancelled.", "AbortError"); await removeCurrentRecord(config.stateDir, input.provider); records.delete(input.provider); for (const key of verifiedManagedReleases) { @@ -954,9 +1158,26 @@ export const ProviderRuntimeManagerLive = Layer.effect( const configured = configuredExecutable?.trim() ?? ""; const explicitCustom = configured.length > 0 && configured !== recipe.executableName; const record = records.get(provider) ?? null; - const systemExecutable = explicitCustom - ? await resolveConfiguredExecutable({ command: configured, pathValue: basePath }) - : await findExecutableOnPath({ command: recipe.executableName, pathValue: basePath }); + const systemSearch = await currentSystemPath(); + const pathExecutable = explicitCustom + ? await resolveConfiguredExecutable({ + command: configured, + pathValue: systemSearch.pathValue, + }) + : await findExecutableOnPath({ + command: recipe.executableName, + pathValue: systemSearch.pathValue, + }); + const knownExecutable = + !explicitCustom && process.platform === "win32" + ? await firstExistingFile( + windowsKnownProviderExecutableCandidates({ + provider, + environment: { ...process.env, ...systemSearch.environment }, + }), + ) + : null; + const systemExecutable = pathExecutable ?? knownExecutable; const source: ServerProviderRuntimeSource = explicitCustom ? "custom" : systemExecutable @@ -1017,18 +1238,23 @@ export const ProviderRuntimeManagerLive = Layer.effect( }); yield* Effect.addFinalizer(() => - Effect.sync(() => { + Effect.gen(function* () { disposed = true; - for (const operation of active.values()) operation.controller.abort(); + const activeOperations = [...active.values()]; + yield* Effect.promise(() => cancelAndAwaitProviderRuntimeOperations(activeOperations)); active.clear(); const currentPath = process.env.PATH ?? ""; + const delimiter = process.platform === "win32" ? ";" : ":"; process.env.PATH = currentPath === lastAssignedPath ? basePath : currentPath - .split(pathDelimiter) + .split(delimiter) .filter((directory) => !managedDirectoriesAdded.has(directory)) - .join(pathDelimiter); + .join(delimiter); + yield* Effect.promise(() => + Promise.allSettled(installationStateWrites.values()).then(() => undefined), + ); }), ); diff --git a/apps/server/src/provider/Services/ProviderConnection.ts b/apps/server/src/provider/Services/ProviderConnection.ts index 3e43c1e21..7cc21a784 100644 --- a/apps/server/src/provider/Services/ProviderConnection.ts +++ b/apps/server/src/provider/Services/ProviderConnection.ts @@ -8,6 +8,8 @@ * @module ProviderConnection */ import type { + ProviderKind, + ServerProviderConnectionMethod, ServerProviderConnectionCancelInput, ServerProviderConnectionError, ServerProviderConnectionResult, @@ -27,6 +29,11 @@ export interface ProviderConnectionShape { readonly submitAuthorizationCode: ( input: ServerProviderConnectionSubmitAuthorizationCodeInput, ) => Effect.Effect; + readonly startAfterInstallation: (input: { + readonly provider: ProviderKind; + readonly method: ServerProviderConnectionMethod; + readonly installationOperationId: string; + }) => Effect.Effect; } export class ProviderConnection extends ServiceMap.Service< diff --git a/apps/server/src/provider/claudeCapabilities.test.ts b/apps/server/src/provider/claudeCapabilities.test.ts index 4714ac94b..f863ab40c 100644 --- a/apps/server/src/provider/claudeCapabilities.test.ts +++ b/apps/server/src/provider/claudeCapabilities.test.ts @@ -1,3 +1,15 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { @@ -46,7 +58,15 @@ describe("Claude account capability probing", () => { persistSession: false, allowedTools: [], cwd: "/workspace", + settingSources: ["user", "project", "local"], + mcpServers: {}, + strictMcpConfig: true, + env: { + HOME: "/Users/tester", + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, }); + expect(capturedOptions?.env).not.toBe(process.env); expect(close).toHaveBeenCalledOnce(); }); @@ -79,6 +99,104 @@ describe("Claude account capability probing", () => { expect(close).toHaveBeenCalledOnce(); }); + it("aborts and closes exactly once when SDK initialization fails", async () => { + let capturedSignal: AbortSignal | undefined; + const close = vi.fn(); + const createQuery: ClaudeCapabilitiesQueryFactory = (input) => { + capturedSignal = input.options.abortController?.signal; + return { + initializationResult: async () => { + throw new Error("simulated initialization failure"); + }, + close, + }; + }; + + await expect( + probeClaudeAccountCapabilities({ executable: "claude", env: {}, createQuery }), + ).resolves.toBeUndefined(); + expect(capturedSignal?.aborted).toBe(true); + expect(close).toHaveBeenCalledOnce(); + }); + + it("serializes the isolation boundary through the pinned Claude SDK", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "scient-claude-probe-sdk-")); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + const workspaceCwd = path.join(tempDir, "workspace"); + mkdirSync(workspaceCwd, { recursive: true }); + + writeFileSync( + executablePath, + [ + "#!/usr/bin/env node", + 'import { writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const args = process.argv.slice(2);", + "writeFileSync(process.env.SCIENT_PROBE_INVOCATION_PATH, JSON.stringify({", + " args,", + " cwd: process.cwd(),", + " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", + "}));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [],", + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "scientist@example.test", subscriptionType: "max", tokenSource: "claude.ai" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + chmodSync(executablePath, 0o755); + + try { + await expect( + probeClaudeAccountCapabilities({ + executable: executablePath, + env: { + ...process.env, + SCIENT_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + cwd: workspaceCwd, + timeoutMs: 5_000, + }), + ).resolves.toEqual({ + email: "scientist@example.test", + subscriptionType: "max", + tokenSource: "claude.ai", + }); + + const invocation = JSON.parse(readFileSync(invocationPath, "utf8")) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + }; + expect(invocation.cwd).toBe(realpathSync(workspaceCwd)); + expect(invocation.connectorEnv).toBe("false"); + expect(invocation.args).toContain("--strict-mcp-config"); + expect(invocation.args).not.toContain("--mcp-config"); + expect(invocation.args).toContain("--setting-sources=user,project,local"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("rejects token-only objects during sanitization", () => { expect(sanitizeClaudeAccountCapabilities({ accessToken: "secret" })).toBeUndefined(); }); diff --git a/apps/server/src/provider/claudeCapabilities.ts b/apps/server/src/provider/claudeCapabilities.ts index 86d5536bb..4e2f0d1da 100644 --- a/apps/server/src/provider/claudeCapabilities.ts +++ b/apps/server/src/provider/claudeCapabilities.ts @@ -8,6 +8,8 @@ import { type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import { buildIsolatedClaudeDiscoveryOptions } from "./claudeDiscoveryIsolation.ts"; + export interface ClaudeAccountCapabilities { readonly email?: string; readonly organization?: string; @@ -114,16 +116,14 @@ export async function probeClaudeAccountCapabilities( try { runtime = createQuery({ prompt: neverSendingPrompt(abortController.signal), - options: { + options: buildIsolatedClaudeDiscoveryOptions({ pathToClaudeCodeExecutable: input.executable, env: input.env, - persistSession: false, - settingSources: ["user", "project", "local"], allowedTools: [], abortController, stderr: () => {}, ...(input.cwd ? { cwd: input.cwd } : {}), - }, + }), }); const timeout = new Promise((resolve) => { diff --git a/apps/server/src/provider/claudeDiscoveryIsolation.test.ts b/apps/server/src/provider/claudeDiscoveryIsolation.test.ts new file mode 100644 index 000000000..b709c7366 --- /dev/null +++ b/apps/server/src/provider/claudeDiscoveryIsolation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { buildIsolatedClaudeDiscoveryOptions } from "./claudeDiscoveryIsolation.ts"; + +describe("Claude discovery isolation", () => { + it("preserves caller options while enforcing an MCP-free temporary process", () => { + const abortController = new AbortController(); + const stderr = () => undefined; + const env = { + HOME: "/Users/tester", + PATH: "/custom/bin", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }; + + const result = buildIsolatedClaudeDiscoveryOptions({ + cwd: "/workspace", + pathToClaudeCodeExecutable: "/managed/claude", + permissionMode: "plan", + allowedTools: [], + abortController, + stderr, + env, + persistSession: true, + settingSources: [], + mcpServers: { + unsafe: { + command: "should-not-run", + }, + }, + strictMcpConfig: false, + agent: "unsafe-agent", + agents: { + "unsafe-agent": { + description: "Must not participate in passive discovery", + prompt: "Start an MCP server", + tools: [], + }, + }, + extraArgs: { + "mcp-config": "/tmp/unsafe-mcp.json", + }, + }); + + expect(result).toMatchObject({ + cwd: "/workspace", + pathToClaudeCodeExecutable: "/managed/claude", + permissionMode: "plan", + allowedTools: [], + abortController, + stderr, + persistSession: false, + settingSources: ["user", "project", "local"], + mcpServers: {}, + strictMcpConfig: true, + env: { + HOME: "/Users/tester", + PATH: "/custom/bin", + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + }); + expect(result.env).not.toBe(env); + expect(result).not.toHaveProperty("agent"); + expect(result).not.toHaveProperty("agents"); + expect(result).not.toHaveProperty("extraArgs"); + expect(env.ENABLE_CLAUDEAI_MCP_SERVERS).toBe("true"); + }); + + it("does not mutate process.env when enforcing the connector override", () => { + const previous = process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + try { + delete process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + const result = buildIsolatedClaudeDiscoveryOptions({ env: process.env }); + + expect(result.env?.ENABLE_CLAUDEAI_MCP_SERVERS).toBe("false"); + expect(process.env.ENABLE_CLAUDEAI_MCP_SERVERS).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + } else { + process.env.ENABLE_CLAUDEAI_MCP_SERVERS = previous; + } + } + }); +}); diff --git a/apps/server/src/provider/claudeDiscoveryIsolation.ts b/apps/server/src/provider/claudeDiscoveryIsolation.ts new file mode 100644 index 000000000..8c5bfa20b --- /dev/null +++ b/apps/server/src/provider/claudeDiscoveryIsolation.ts @@ -0,0 +1,43 @@ +// FILE: claudeDiscoveryIsolation.ts +// Purpose: Keep passive Claude capability discovery from starting user-configured MCP servers. +// Layer: Provider utility. + +import type { Options as ClaudeQueryOptions, SettingSource } from "@anthropic-ai/claude-agent-sdk"; + +const CLAUDE_DISCOVERY_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** + * Applies the non-interactive safety boundary shared by temporary Claude + * discovery processes. Filesystem settings remain available for command and + * model metadata, but their MCP declarations (including Claude.ai connectors) + * cannot start. Interactive Claude sessions must not use this helper. + */ +export function buildIsolatedClaudeDiscoveryOptions( + options: ClaudeQueryOptions & { readonly env: NodeJS.ProcessEnv }, +): ClaudeQueryOptions { + const safeOptions = { ...options }; + + // strictMcpConfig blocks MCP declarations loaded from settings, but the SDK + // still accepts MCP-capable agent definitions and arbitrary raw CLI flags. + // Temporary discovery has no need for either, so remove both escape hatches + // even if a future caller passes them accidentally. + delete safeOptions.agent; + delete safeOptions.agents; + delete safeOptions.extraArgs; + + return { + ...safeOptions, + env: { + ...options.env, + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + settingSources: [...CLAUDE_DISCOVERY_SETTING_SOURCES], + persistSession: false, + mcpServers: {}, + strictMcpConfig: true, + }; +} diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 41be16c22..ef2074e48 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -1117,6 +1117,15 @@ export const makeWsRpcLayer = () => providerRuntimeManager.prepareInstall(input.provider), [WS_METHODS.serverInstallProvider]: (input) => providerRuntimeManager.install(input).pipe( + Effect.tap(() => + input.connectionMethod + ? providerConnection.startAfterInstallation({ + provider: input.provider, + method: input.connectionMethod, + installationOperationId: input.planToken, + }) + : Effect.void, + ), Effect.andThen(providerClientStatusProjection.getStatuses), Effect.map((providers) => ({ providers })), ), diff --git a/apps/web/src/browserStateStore.test.ts b/apps/web/src/browserStateStore.test.ts index e701de692..7a4675bc5 100644 --- a/apps/web/src/browserStateStore.test.ts +++ b/apps/web/src/browserStateStore.test.ts @@ -27,33 +27,36 @@ describe("browserStateStore selectors", () => { expect(first).toEqual([]); }); - it("never persists artifact capability URLs into browser history", () => { - const history = browserHistoryAfterThreadState([], { - threadId: THREAD_ID, - version: 1, - open: true, - activeTabId: "artifact", - tabs: [ - { - id: "artifact", - kind: "artifact", - url: "http://g-secret.preview.localhost:5000/", - displayUrl: "/workspace/report.html", - title: "Report", - status: "live", - isLoading: false, - canGoBack: false, - canGoForward: false, - faviconUrl: null, - lastCommittedUrl: "http://g-secret.preview.localhost:5000/", - lastError: null, - }, - ], - lastError: null, - }); - - expect(history).toEqual([]); - }); + it.each(["artifact", "local-html"] as const)( + "never persists %s capability URLs into browser history", + (kind) => { + const history = browserHistoryAfterThreadState([], { + threadId: THREAD_ID, + version: 1, + open: true, + activeTabId: "preview", + tabs: [ + { + id: "preview", + kind, + url: "http://g-secret.preview.localhost:5000/", + displayUrl: "/workspace/report.html", + title: "Report", + status: "live", + isLoading: false, + canGoBack: false, + canGoForward: false, + faviconUrl: null, + lastCommittedUrl: "http://g-secret.preview.localhost:5000/", + lastError: null, + }, + ], + lastError: null, + }); + + expect(history).toEqual([]); + }, + ); }); describe("createDedupedBrowserStateStorage", () => { diff --git a/apps/web/src/browserStateStore.ts b/apps/web/src/browserStateStore.ts index 1047ded35..8b4aed5e8 100644 --- a/apps/web/src/browserStateStore.ts +++ b/apps/web/src/browserStateStore.ts @@ -92,7 +92,7 @@ export function browserHistoryAfterThreadState( : state.tabs; return orderedTabs.reduce( (entries, tab) => - tab.kind === "artifact" + tab.kind === "artifact" || tab.kind === "local-html" ? entries : upsertRecentHistoryEntry(entries, { url: tab.lastCommittedUrl ?? tab.url, diff --git a/apps/web/src/components/AddProjectDialog.browser.tsx b/apps/web/src/components/AddProjectDialog.browser.tsx index 387ba78c4..badb15584 100644 --- a/apps/web/src/components/AddProjectDialog.browser.tsx +++ b/apps/web/src/components/AddProjectDialog.browser.tsx @@ -17,6 +17,7 @@ function installNativeApi(overrides: { cloneSource?: NativeApi["projects"]["cloneSource"]; browse?: NativeApi["filesystem"]["browse"]; createDirectory?: NativeApi["filesystem"]["createDirectory"]; + pickFolder?: NativeApi["dialogs"]["pickFolder"]; }) { const previousNativeApi = window.nativeApi; const baseApi = readNativeApi(); @@ -40,7 +41,7 @@ function installNativeApi(overrides: { }, dialogs: { ...baseApi.dialogs, - pickFolder: vi.fn().mockResolvedValue(null), + pickFolder: overrides.pickFolder ?? vi.fn().mockResolvedValue(null), }, } satisfies NativeApi, }); @@ -71,6 +72,60 @@ function renderDialog( return queryClient; } +function installDesktopFilePathResolver(resolvePath: (file: File) => string | null) { + const previousDesktopBridge = window.desktopBridge; + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: { + ...previousDesktopBridge, + getPathForFile: resolvePath, + }, + }); + return () => { + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: previousDesktopBridge, + }); + }; +} + +function makeFolderDragTransfer( + entries: ReadonlyArray<{ readonly file: File; readonly directory: boolean | "unknown" }>, +): DataTransfer { + return { + types: ["Files"], + items: entries.map(({ file, directory }) => ({ + kind: "file", + type: file.type, + getAsFile: () => file, + ...(directory === "unknown" ? {} : { webkitGetAsEntry: () => ({ isDirectory: directory }) }), + })), + files: entries.map(({ file }) => file), + dropEffect: "none", + effectAllowed: "all", + } as unknown as DataTransfer; +} + +function dispatchDrag( + target: EventTarget, + type: "dragenter" | "dragover" | "dragleave" | "drop", + dataTransfer: DataTransfer, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); + target.dispatchEvent(event); + return event; +} + +function dispatchDialogDrag( + type: "dragenter" | "dragover" | "dragleave" | "drop", + dataTransfer: DataTransfer, +) { + const card = document.querySelector('[data-testid="add-project-dialog-card"]'); + if (!card) throw new Error("Expected folder-drop dialog card."); + return dispatchDrag(card, type, dataTransfer); +} + describe("AddProjectDialog", () => { afterEach(() => { vi.restoreAllMocks(); @@ -127,6 +182,394 @@ describe("AddProjectDialog", () => { restore(); }); + it("shows a compact resting folder-drop row with its icon centered in the tile", async () => { + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [ + { name: "Documents", fullPath: "/Users/tester/Documents" }, + ...Array.from({ length: 24 }, (_, index) => ({ + name: `Project ${String(index + 1).padStart(2, "0")}`, + fullPath: `/Users/tester/Project ${String(index + 1).padStart(2, "0")}`, + })), + ], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research"); + renderDialog(); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const row = page.getByTestId("folder-drop-affordance"); + const tile = page.getByTestId("folder-drop-icon-tile"); + await expect.element(row).toHaveAttribute("data-drop-state", "idle"); + await expect.element(row).toHaveTextContent("Drop your folder here or browse below"); + + const rowRect = (await row.element()).getBoundingClientRect(); + const tileRect = (await tile.element()).getBoundingClientRect(); + expect(rowRect.height).toBeLessThanOrEqual(52); + expect( + Math.abs(tileRect.top + tileRect.height / 2 - (rowRect.top + rowRect.height / 2)), + ).toBeLessThanOrEqual(1); + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + + const listbox = page.getByRole("listbox"); + const listboxElement = await listbox.element(); + expect(rowRect.bottom).toBeLessThanOrEqual(listboxElement.getBoundingClientRect().top + 1); + expect(listboxElement.querySelector('[role="status"]')).toBeNull(); + listboxElement.scrollTop = listboxElement.scrollHeight; + await expect.element(row).toBeVisible(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("opens the native folder picker from the folder-plus tile with synchronous single-flight", async () => { + let resolveAdd!: (shouldClose: boolean) => void; + const onAddProjectPath = vi.fn( + () => + new Promise((resolve) => { + resolveAdd = resolve; + }), + ); + const pickFolder = vi.fn().mockResolvedValue("/Users/tester/Research"); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + pickFolder, + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const tile = page.getByTestId("folder-drop-icon-tile"); + const normalizedPlatform = navigator.platform.toLowerCase(); + const expectedFileManagerLabel = normalizedPlatform.includes("mac") + ? "Open in Finder" + : normalizedPlatform.includes("win") + ? "Open in File Explorer" + : "Open in file manager"; + await expect.element(tile).toHaveAccessibleName(expectedFileManagerLabel); + const tileElement = (await tile.element()) as HTMLButtonElement; + tileElement.click(); + tileElement.click(); + + await vi.waitFor(() => expect(pickFolder).toHaveBeenCalledTimes(1)); + expect(onAddProjectPath).toHaveBeenCalledTimes(1); + expect(onAddProjectPath).toHaveBeenCalledWith("/Users/tester/Research", { + createIfMissing: false, + }); + + resolveAdd(false); + await expect + .element(page.getByPlaceholder("Type or browse a folder path")) + .toHaveValue("/Users/tester/Research"); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("does not advertise folder drop when the desktop path bridge is unavailable", async () => { + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + }); + const previousDesktopBridge = window.desktopBridge; + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: undefined, + }); + renderDialog(); + + try { + await page.getByText("Local folder", { exact: true }).click(); + await expect.element(page.getByTestId("folder-drop-affordance")).not.toBeInTheDocument(); + await expect.element(page.getByText("Directories", { exact: true })).toBeVisible(); + } finally { + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: previousDesktopBridge, + }); + restoreApi(); + } + }); + + it("accepts folder drag feedback across the dialog card and safely rejects backdrop drops", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(false); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const folder = new File([new Blob([])], "Research"); + const transfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + const targets = [ + await page.getByPlaceholder("Type or browse a folder path").element(), + await page.getByText("Documents", { exact: true }).element(), + await page.getByTestId("folder-drop-affordance").element(), + document.querySelector('[data-slot="command-footer"]'), + ]; + + for (const target of targets) { + expect(target).not.toBeNull(); + dispatchDrag(target!, "dragenter", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "active"); + const dragOver = dispatchDrag(target!, "dragover", transfer); + expect(dragOver.defaultPrevented).toBe(true); + expect(transfer.dropEffect).toBe("copy"); + dispatchDrag(target!, "dragleave", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "idle"); + } + + const backdrop = document.querySelector('[data-slot="command-dialog-backdrop"]'); + expect(backdrop).not.toBeNull(); + const outsideTransfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + const outsideOver = dispatchDrag(backdrop!, "dragover", outsideTransfer); + expect(outsideOver.defaultPrevented).toBe(true); + expect(outsideTransfer.dropEffect).toBe("none"); + const outsideDrop = dispatchDrag(backdrop!, "drop", outsideTransfer); + expect(outsideDrop.defaultPrevented).toBe(true); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "idle"); + expect(onAddProjectPath).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Back", exact: true }).click(); + const afterCleanupTransfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + const afterCleanupOver = dispatchDrag(backdrop!, "dragover", afterCleanupTransfer); + const afterCleanupDrop = dispatchDrag(backdrop!, "drop", afterCleanupTransfer); + expect(afterCleanupOver.defaultPrevented).toBe(false); + expect(afterCleanupDrop.defaultPrevented).toBe(false); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("accepts a folder dropped inside the card with copy feedback and synchronous single-flight", async () => { + let resolveAdd!: (shouldClose: boolean) => void; + const onAddProjectPath = vi.fn( + () => + new Promise((resolve) => { + resolveAdd = resolve; + }), + ); + const browse = vi.fn(async ({ partialPath }: { partialPath: string }) => + partialPath === "/Users/tester/Research Projects/Study (2)/" + ? { parentPath: "/Users/tester/Research Projects/Study (2)", entries: [] } + : { + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }, + ); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse, + }); + const restoreBridge = installDesktopFilePathResolver( + () => "/Users/tester/Research Projects/Study (2)", + ); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const folder = new File([new Blob([])], "Study (2)"); + const transfer = makeFolderDragTransfer([{ file: folder, directory: "unknown" }]); + + dispatchDialogDrag("dragenter", transfer); + await expect + .element(page.getByText("Release to add this folder", { exact: true })) + .toBeVisible(); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "active"); + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + + const dragOver = dispatchDialogDrag("dragover", transfer); + expect(dragOver.defaultPrevented).toBe(true); + expect(transfer.dropEffect).toBe("copy"); + + dispatchDialogDrag("drop", transfer); + await vi.waitFor(() => + expect(onAddProjectPath).toHaveBeenCalledWith("/Users/tester/Research Projects/Study (2)", { + createIfMissing: false, + }), + ); + expect(browse).toHaveBeenCalledWith({ + partialPath: "/Users/tester/Research Projects/Study (2)/", + }); + expect(onAddProjectPath).toHaveBeenCalledTimes(1); + + const secondTransfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + dispatchDialogDrag("drop", secondTransfer); + expect(onAddProjectPath).toHaveBeenCalledTimes(1); + + resolveAdd(false); + await expect + .element(page.getByPlaceholder("Type or browse a folder path")) + .toHaveValue("/Users/tester/Research Projects/Study (2)"); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("clears drag feedback on leave and rejects file drops without hiding folders", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/notes.md"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const file = new File(["notes"], "notes.md", { type: "text/markdown" }); + const transfer = makeFolderDragTransfer([{ file, directory: false }]); + + dispatchDialogDrag("dragenter", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "idle"); + const dragOver = dispatchDialogDrag("dragover", transfer); + expect(dragOver.defaultPrevented).toBe(true); + expect(transfer.dropEffect).toBe("none"); + dispatchDialogDrag("dragleave", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveTextContent("Drop your folder here or browse below"); + + dispatchDialogDrag("drop", transfer); + const alert = page.getByRole("alert"); + await expect.element(alert).toHaveTextContent("Drop a folder, not a file."); + expect((await page.getByRole("listbox").element()).contains(await alert.element())).toBe( + false, + ); + for (const viewport of [ + { width: 1_280, height: 800 }, + { width: 600, height: 480 }, + ]) { + await page.viewport(viewport.width, viewport.height); + const popup = document.querySelector('[data-slot="command-dialog-popup"]'); + const footer = document.querySelector('[data-slot="command-footer"]'); + expect(popup).not.toBeNull(); + expect(footer).not.toBeNull(); + const popupRect = popup!.getBoundingClientRect(); + const alertRect = (await alert.element()).getBoundingClientRect(); + const footerRect = footer!.getBoundingClientRect(); + const footerStyle = getComputedStyle(footer!); + expect(alertRect.height).toBeGreaterThan(0); + expect(alertRect.top).toBeGreaterThanOrEqual(popupRect.top - 1); + expect(alertRect.bottom).toBeLessThanOrEqual(popupRect.bottom + 1); + expect(footerRect.bottom).toBeLessThanOrEqual(popupRect.bottom + 1); + if (viewport.width >= 640) { + expect(footerStyle.flexDirection).toBe("row"); + expect(footerRect.height).toBeLessThanOrEqual(64); + } else { + expect(footerStyle.flexDirection).toBe("column"); + expect(footerRect.height).toBeLessThanOrEqual(112); + } + } + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + await page.viewport(1_280, 720); + restoreBridge(); + restoreApi(); + } + }); + + it("validates an opaque Finder item before submitting it as a project folder", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const browse = vi.fn(async ({ partialPath }: { partialPath: string }) => { + if (partialPath === "/Users/tester/notes.md/") { + throw new Error("ENOTDIR"); + } + return { parentPath: "/Users/tester", entries: [] }; + }); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse, + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/notes.md"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const file = new File(["notes"], "notes.md", { type: "text/markdown" }); + const transfer = makeFolderDragTransfer([{ file, directory: "unknown" }]); + + dispatchDialogDrag("dragenter", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "active"); + dispatchDialogDrag("drop", transfer); + + await expect + .element(page.getByRole("alert")) + .toHaveTextContent("Drop an accessible folder, not a file."); + expect(browse).toHaveBeenCalledWith({ partialPath: "/Users/tester/notes.md/" }); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("rejects whitespace-ended dropped folders without submitting a different path", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research "); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const folder = new File([new Blob([])], "Research "); + const transfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + + dispatchDialogDrag("drop", transfer); + await expect + .element( + page.getByText( + "Folders with names ending in whitespace are not supported. Rename the folder and try again.", + { exact: true }, + ), + ) + .toBeVisible(); + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + it("browses through a whitespace-ended folder and selects its child", async () => { const browse = vi.fn(async ({ partialPath }: { partialPath: string }) => { if (partialPath === "/Users/tester/") { diff --git a/apps/web/src/components/AddProjectDialog.logic.test.ts b/apps/web/src/components/AddProjectDialog.logic.test.ts index 5f4ffef11..25056aecc 100644 --- a/apps/web/src/components/AddProjectDialog.logic.test.ts +++ b/apps/web/src/components/AddProjectDialog.logic.test.ts @@ -1,13 +1,172 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildCloneProjectSourceInput, + canAcceptProjectFolderDrop, getAvailableNewFolderName, inferCloneDirectoryName, + isProjectFolderDrag, joinProjectPath, + resolveDroppedProjectFolder, } from "./AddProjectDialog.logic"; +function makeFile(name: string): File { + return new File([new Blob([])], name); +} + +function makeDropItem(file: File, options?: { directory?: boolean }) { + return { + kind: "file", + getAsFile: () => file, + webkitGetAsEntry: () => ({ isDirectory: options?.directory === true }), + }; +} + describe("AddProjectDialog logic", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("recognizes OS file drags without treating internal text drags as folders", () => { + expect(isProjectFolderDrag(["Files", "text/plain"])).toBe(true); + expect(isProjectFolderDrag(["text/plain"])).toBe(false); + }); + + it("offers acceptance feedback only for one real directory", () => { + const folder = makeFile("Research"); + const file = makeFile("notes.md"); + + expect( + canAcceptProjectFolderDrop({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toBe(true); + expect(canAcceptProjectFolderDrop({ items: [makeDropItem(file)], files: [file] })).toBe(false); + expect( + canAcceptProjectFolderDrop({ + items: [ + makeDropItem(folder, { directory: true }), + makeDropItem(makeFile("Second"), { directory: true }), + ], + files: [folder], + }), + ).toBe(false); + expect( + canAcceptProjectFolderDrop({ + items: [{ kind: "file", getAsFile: () => folder }], + files: [], + }), + ).toBe(true); + expect(canAcceptProjectFolderDrop({ items: [], files: [] })).toBe(true); + }); + + it("resolves one dropped directory through the Electron path bridge", () => { + const folder = makeFile("Research"); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research (2)", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toEqual({ path: "/Users/tester/Research (2)" }); + }); + + it("falls back to the transfer file when Chromium cannot expose the item file", () => { + const folder = makeFile("Research"); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [ + { + kind: "file", + getAsFile: () => { + throw new Error("unavailable"); + }, + webkitGetAsEntry: () => ({ isDirectory: true }), + }, + ], + files: [folder], + }), + ).toEqual({ path: "/Users/tester/Research" }); + }); + + it("resolves an opaque Finder item for native directory validation", () => { + const folder = makeFile("Research"); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [{ kind: "file", getAsFile: () => folder }], + files: [folder], + }), + ).toEqual({ path: "/Users/tester/Research", requiresDirectoryValidation: true }); + }); + + it("rejects files, ambiguous multi-folder drops, and unavailable absolute paths", () => { + const file = makeFile("notes.md"); + const folder = makeFile("Research"); + vi.stubGlobal("window", { desktopBridge: { getPathForFile: () => null } }); + + expect(resolveDroppedProjectFolder({ items: [makeDropItem(file)], files: [file] })).toEqual({ + error: "Drop a folder, not a file.", + }); + expect( + resolveDroppedProjectFolder({ + items: [ + makeDropItem(folder, { directory: true }), + makeDropItem(makeFile("Second"), { directory: true }), + ], + files: [folder], + }), + ).toEqual({ error: "Drop one folder at a time." }); + expect( + resolveDroppedProjectFolder({ + items: [], + files: [folder, makeFile("Second")], + }), + ).toEqual({ error: "Drop one folder at a time." }); + expect( + resolveDroppedProjectFolder({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toEqual({ error: "Could not read the folder's path. Use browse below instead." }); + }); + + it("rejects a dropped path that downstream project normalization would trim", () => { + const folder = makeFile("Research "); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research ", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toEqual({ + error: + "Folders with names ending in whitespace are not supported. Rename the folder and try again.", + }); + }); + it("derives stable destination folder names from supported repository inputs", () => { expect(inferCloneDirectoryName("git-url", "https://github.com/owner/repo.git")).toBe("repo"); expect(inferCloneDirectoryName("git-url", "git@gitlab.com:group/repo.git")).toBe("repo"); diff --git a/apps/web/src/components/AddProjectDialog.logic.ts b/apps/web/src/components/AddProjectDialog.logic.ts index 7cda76bbc..86ece0513 100644 --- a/apps/web/src/components/AddProjectDialog.logic.ts +++ b/apps/web/src/components/AddProjectDialog.logic.ts @@ -1,7 +1,96 @@ import type { CloneProjectSourceInput, RepositoryProvider } from "@synara/contracts"; +import { + resolveDroppedFileAbsolutePath, + type ComposerDroppedFileItem, +} from "~/lib/composerDropPaths"; + export type AddProjectSource = "local" | "git-url" | RepositoryProvider; +export type DroppedProjectFolderResult = + | { readonly path: string; readonly requiresDirectoryValidation?: true } + | { readonly error: string }; + +export interface ProjectFolderDataTransfer { + readonly items: Iterable; + readonly files: Iterable; +} + +export function isProjectFolderDrag(types: Iterable): boolean { + return Array.from(types).includes("Files"); +} + +function getDroppedItemKind( + item: ComposerDroppedFileItem | undefined, +): "directory" | "file" | "unknown" { + if (!item || typeof item.webkitGetAsEntry !== "function") { + return "unknown"; + } + try { + const entry = item.webkitGetAsEntry(); + if (!entry) return "unknown"; + return entry.isDirectory === true ? "directory" : "file"; + } catch { + return "unknown"; + } +} + +export function canAcceptProjectFolderDrop(dataTransfer: ProjectFolderDataTransfer): boolean { + const fileItems = Array.from(dataTransfer.items).filter((item) => item.kind === "file"); + const files = Array.from(dataTransfer.files); + if (fileItems.length > 1 || files.length > 1) return false; + + // Finder and some Electron/Chromium versions withhold both File objects and + // webkit entries until `drop`. Keep the surface receptive while the payload + // is opaque, then validate the resolved path before opening it. + return getDroppedItemKind(fileItems[0]) !== "file"; +} + +function getDroppedProjectFile( + item: ComposerDroppedFileItem | undefined, + files: Iterable, +): File | null { + try { + return item?.getAsFile() ?? Array.from(files)[0] ?? null; + } catch { + return Array.from(files)[0] ?? null; + } +} + +export function resolveDroppedProjectFolder( + dataTransfer: ProjectFolderDataTransfer, +): DroppedProjectFolderResult { + const fileItems = Array.from(dataTransfer.items).filter((item) => item.kind === "file"); + const files = Array.from(dataTransfer.files); + if (fileItems.length > 1 || files.length > 1) { + return { error: "Drop one folder at a time." }; + } + + const item = fileItems[0]; + const file = getDroppedProjectFile(item, files); + if (!file) { + return { error: "Could not read the dropped folder. Use browse below instead." }; + } + const itemKind = getDroppedItemKind(item); + if (itemKind === "file") { + return { error: "Drop a folder, not a file." }; + } + + const absolutePath = resolveDroppedFileAbsolutePath(file); + if (!absolutePath) { + return { error: "Could not read the folder's path. Use browse below instead." }; + } + if (absolutePath !== absolutePath.trim()) { + return { + error: + "Folders with names ending in whitespace are not supported. Rename the folder and try again.", + }; + } + return itemKind === "unknown" + ? { path: absolutePath, requiresDirectoryValidation: true } + : { path: absolutePath }; +} + export function inferCloneDirectoryName(source: AddProjectSource, value: string): string { const trimmed = value .trim() diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index 2304b7f82..6065cebf2 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -4,7 +4,7 @@ import type { RepositorySourceStatus, } from "@synara/contracts"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { SiGithub, SiGitlab } from "react-icons/si"; import { LuArrowLeft, LuCornerLeftUp, LuFolderPlus, LuLink } from "react-icons/lu"; @@ -40,10 +40,13 @@ import { import { Kbd, KbdGroup } from "./ui/kbd"; import { buildCloneProjectSourceInput, + canAcceptProjectFolderDrop, getAvailableNewFolderName, type AddProjectSource, inferCloneDirectoryName, + isProjectFolderDrag, joinProjectPath, + resolveDroppedProjectFolder, } from "./AddProjectDialog.logic"; const BROWSE_STALE_TIME_MS = 10_000; @@ -65,6 +68,8 @@ interface PathBrowserProps { busyLabel: string; isBusy: boolean; cloneDirectoryName?: string; + acceptFolderDrop?: boolean; + folderDropTarget?: HTMLElement | null; onBack: () => void; onSubmit: (path: string, options: { createIfMissing: boolean }) => Promise; } @@ -141,11 +146,15 @@ function DialogBackButton(props: { label: string; onClick: () => void }) { } function ProjectPathBrowser(props: PathBrowserProps & { homeDir: string | null }) { + const { isBusy, onSubmit } = props; const [query, setQuery] = useState(props.initialQuery); const [highlightedValue, setHighlightedValue] = useState(null); const [error, setError] = useState(null); + const [isFolderDragActive, setIsFolderDragActive] = useState(false); const inputRef = useRef(null); const submitInFlightRef = useRef(false); + const supportsFolderDrop = + props.acceptFolderDrop === true && typeof window.desktopBridge?.getPathForFile === "function"; const pendingNameSelectionRef = useRef<{ query: string; start: number; end: number } | null>( null, ); @@ -315,6 +324,140 @@ function ProjectPathBrowser(props: PathBrowserProps & { homeDir: string | null } props.onBack(); }; + const runSubmission = useCallback( + async (operation: () => Promise) => { + if (isBusy || submitInFlightRef.current) return; + submitInFlightRef.current = true; + setError(null); + try { + await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Unable to add project."); + } finally { + submitInFlightRef.current = false; + } + }, + [isBusy], + ); + + const pickAndSubmitFolder = useCallback(() => { + void runSubmission(async () => { + const api = readNativeApi(); + if (!api) throw new Error("The app server is unavailable."); + const picked = await api.dialogs.pickFolder(); + if (!picked) return; + const target = props.cloneDirectoryName + ? joinProjectPath(picked, props.cloneDirectoryName) + : picked; + setQuery(target); + await onSubmit(target, { createIfMissing: false }); + }); + }, [onSubmit, props.cloneDirectoryName, runSubmission]); + + useEffect(() => { + if (!supportsFolderDrop) { + setIsFolderDragActive(false); + return; + } + + const dropTarget = props.folderDropTarget; + if (!dropTarget) return; + const ownerDocument = dropTarget.ownerDocument; + + let dragDepth = 0; + const resetDragState = () => { + dragDepth = 0; + setIsFolderDragActive(false); + }; + const handleDragEnter = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + dragDepth += 1; + setIsFolderDragActive( + !isBusy && !submitInFlightRef.current && canAcceptProjectFolderDrop(event.dataTransfer), + ); + }; + const handleDragOver = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = + !isBusy && !submitInFlightRef.current && canAcceptProjectFolderDrop(event.dataTransfer) + ? "copy" + : "none"; + }; + const handleDragLeave = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) setIsFolderDragActive(false); + }; + const handleDrop = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + event.preventDefault(); + event.stopPropagation(); + resetDragState(); + if (isBusy || submitInFlightRef.current) return; + + const dropped = resolveDroppedProjectFolder(event.dataTransfer); + if ("error" in dropped) { + setError(dropped.error); + return; + } + + setQuery(dropped.path); + void runSubmission(async () => { + if (dropped.requiresDirectoryValidation) { + const api = readNativeApi(); + if (!api) throw new Error("The app server is unavailable."); + try { + await api.filesystem.browse({ partialPath: getInitialBrowseQuery(dropped.path) }); + } catch { + throw new Error("Drop an accessible folder, not a file."); + } + } + await onSubmit(dropped.path, { createIfMissing: false }); + }); + }; + const handleOutsideDragOver = (event: globalThis.DragEvent) => { + if ( + !event.dataTransfer || + !isProjectFolderDrag(event.dataTransfer.types) || + event.composedPath().includes(dropTarget) + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + resetDragState(); + }; + const handleOutsideDrop = (event: globalThis.DragEvent) => { + if ( + !event.dataTransfer || + !isProjectFolderDrag(event.dataTransfer.types) || + event.composedPath().includes(dropTarget) + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + resetDragState(); + }; + + ownerDocument.addEventListener("dragover", handleOutsideDragOver, true); + ownerDocument.addEventListener("drop", handleOutsideDrop, true); + dropTarget.addEventListener("dragenter", handleDragEnter, true); + dropTarget.addEventListener("dragover", handleDragOver, true); + dropTarget.addEventListener("dragleave", handleDragLeave, true); + dropTarget.addEventListener("drop", handleDrop, true); + return () => { + ownerDocument.removeEventListener("dragover", handleOutsideDragOver, true); + ownerDocument.removeEventListener("drop", handleOutsideDrop, true); + dropTarget.removeEventListener("dragenter", handleDragEnter, true); + dropTarget.removeEventListener("dragover", handleDragOver, true); + dropTarget.removeEventListener("dragleave", handleDragLeave, true); + dropTarget.removeEventListener("drop", handleDrop, true); + dragDepth = 0; + }; + }, [isBusy, onSubmit, props.folderDropTarget, runSubmission, supportsFolderDrop]); + return ( setHighlightedValue(typeof value === "string" ? value : null)} > - +
- + {supportsFolderDrop ? ( +
+ + {isFolderDragActive ? ( + Release to add this folder + ) : ( + + Drop your folder here + or browse below + + )} +
+ ) : null} + {props.cloneDirectoryName ? "Select where to clone" : "Directories"} @@ -392,15 +580,18 @@ function ProjectPathBrowser(props: PathBrowserProps & { homeDir: string | null } {!isFetching && filteredEntries.length === 0 && !canBrowseUp ? ( No matching folders. ) : null} - {error || browseError ? ( -
- {error ?? - (browseError instanceof Error - ? browseError.message - : "Unable to browse this folder.")} -
- ) : null}
+ {error || browseError ? ( +
+ {error ?? + (browseError instanceof Error + ? browseError.message + : "Unable to browse this folder.")} +
+ ) : null}
{ - if (submitInFlightRef.current) return; - submitInFlightRef.current = true; - try { - const api = readNativeApi(); - if (!api) throw new Error("The app server is unavailable."); - const picked = await api.dialogs.pickFolder(); - if (!picked) return; - const target = props.cloneDirectoryName - ? joinProjectPath(picked, props.cloneDirectoryName) - : picked; - await props.onSubmit(target, { createIfMissing: false }); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Unable to add project."); - } finally { - submitInFlightRef.current = false; - } - }} + onClick={pickAndSubmitFolder} > {fileManagerLabel(platform)} @@ -469,6 +643,7 @@ export function AddProjectDialog(props: AddProjectDialogProps) { const [error, setError] = useState(null); const [isWorking, setIsWorking] = useState(false); const [highlightedSource, setHighlightedSource] = useState(null); + const [dialogPopupElement, setDialogPopupElement] = useState(null); const openRef = useRef(props.open); const operationGenerationRef = useRef(0); openRef.current = props.open; @@ -598,7 +773,12 @@ export function AddProjectDialog(props: AddProjectDialogProps) { return ( - + {step === "local" ? ( { const operationGeneration = beginOperation(); diff --git a/apps/web/src/components/AppSnapCoordinator.tsx b/apps/web/src/components/AppSnapCoordinator.tsx index 7d487a7a7..180aeff5a 100644 --- a/apps/web/src/components/AppSnapCoordinator.tsx +++ b/apps/web/src/components/AppSnapCoordinator.tsx @@ -49,6 +49,10 @@ import { transientAlertManager } from "../notifications/transientAlert"; import { useSplitViewStore } from "../splitViewStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { + coordinateExternalRouteNavigation, + draftNavigationSlotKey, +} from "../lib/stagedDraftNavigation"; const MAX_REMEMBERED_CAPTURE_IDS = 100; const APPSNAP_LISTENER_ACTIVITY_KEY = "appsnap:listener"; @@ -327,6 +331,8 @@ export function AppSnapCoordinator() { const activateExistingTarget = useCallback( async (target: AppSnapThreadTarget) => { + const mayActivate = await coordinateExternalRouteNavigation(draftNavigationSlotKey()); + if (!mayActivate) return; openChatThreadPage(target.threadId); // Same thread is only "already active" when the split pane matches too; // a capture aimed at another pane still needs activation below. diff --git a/apps/web/src/components/AutomationListRow.browser.tsx b/apps/web/src/components/AutomationListRow.browser.tsx new file mode 100644 index 000000000..7130e2d48 --- /dev/null +++ b/apps/web/src/components/AutomationListRow.browser.tsx @@ -0,0 +1,84 @@ +// FILE: AutomationListRow.browser.tsx +// Purpose: Browser semantics and narrow-layout coverage for automation status rows. +// Layer: Vitest browser tests + +import "../index.css"; + +import { page, userEvent } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { AutomationListRow } from "./AutomationListRow"; + +async function renderRow(meta: string, onClick = vi.fn()) { + await render( +
+
, + ); + return onClick; +} + +describe("AutomationListRow", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps plain-language state in the row's accessible name and supports keyboard opening", async () => { + const onClick = await renderRow("Waiting for approval"); + const row = page.getByRole("button", { + name: /Nightly upstream review Scient desktop Waiting for approval/i, + }); + + const rowElement = await row.element(); + rowElement.focus(); + await userEvent.keyboard("{Enter}"); + + expect(onClick).toHaveBeenCalledOnce(); + await expect.element(page.getByLabelText("Delete automation", { exact: true })).toBeVisible(); + }); + + it("[geometry:linux] preserves the status at narrow sidebar widths without row overflow", async () => { + await renderRow("Last run interrupted"); + + const container = page.getByTestId("narrow-automation-list"); + const row = page.getByRole("button", { + name: /Nightly upstream review Scient desktop Last run interrupted/i, + }); + const meta = page.getByText("Last run interrupted"); + const containerBox = container.element().getBoundingClientRect(); + const rowBox = row.element().getBoundingClientRect(); + const metaBox = meta.element().getBoundingClientRect(); + + expect(rowBox.right).toBeLessThanOrEqual(containerBox.right + 0.5); + expect(metaBox.right).toBeLessThanOrEqual(rowBox.right + 0.5); + await expect.element(meta).toBeVisible(); + }); + + it.each([ + "Running", + "Waiting for approval", + "Last run failed", + "New result", + "Paused", + "Every 1h", + ])("keeps the representative %s state visible and announced", async (meta) => { + await renderRow(meta); + + await expect.element(page.getByText(meta, { exact: true })).toBeVisible(); + await expect + .element( + page.getByRole("button", { + name: new RegExp(`Nightly upstream review Scient desktop ${meta}`, "i"), + }), + ) + .toBeVisible(); + }); +}); diff --git a/apps/web/src/components/AutomationListRow.tsx b/apps/web/src/components/AutomationListRow.tsx new file mode 100644 index 000000000..1d730fff4 --- /dev/null +++ b/apps/web/src/components/AutomationListRow.tsx @@ -0,0 +1,82 @@ +// FILE: AutomationListRow.tsx +// Purpose: Shared accessible row presentation for automation definitions and run triage. +// Layer: Web UI component + +import type { ReactNode } from "react"; + +import { isRowInteractiveEventTarget } from "~/routes/-automations.shared"; +import { CentralIcon } from "~/lib/central-icons"; +import { cn } from "~/lib/utils"; + +export function AutomationListRow({ + onClick, + leading, + title, + detail, + meta, + trailing, + onDelete, + deleteDisabled = false, + error, +}: { + readonly onClick: () => void; + readonly leading: ReactNode; + readonly title: string; + readonly detail: string; + readonly meta?: ReactNode; + readonly trailing?: ReactNode; + readonly onDelete?: () => void; + readonly deleteDisabled?: boolean; + readonly error?: string | undefined; +}) { + return ( + // A div with role="button" (not a real + ) : null} + {trailing} + + ); +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 2e3c6dfea..1a7ba7c3f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -261,7 +261,7 @@ export default function BranchToolbar({ }); const setThreadWorkspace = useCallback( - (patch: ThreadWorkspacePatch) => { + (patch: ThreadWorkspacePatch, options?: { preserveDraftWorkspaceOrigin?: boolean }) => { if (!activeThreadId) return; const branch = patch.branch !== undefined ? patch.branch : activeThreadBranch; const worktreePath = @@ -328,6 +328,9 @@ export default function BranchToolbar({ branch, worktreePath, envMode: nextDraftEnvMode, + ...(options?.preserveDraftWorkspaceOrigin && draftThread + ? { workspaceOrigin: draftThread.workspaceOrigin } + : {}), }); }, [ @@ -335,6 +338,7 @@ export default function BranchToolbar({ activeThreadBranch, serverThread?.session, activeWorktreePath, + draftThread, hasServerThread, setThreadWorkspaceAction, serverThread?.associatedWorktreePath, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.browser.tsx b/apps/web/src/components/BranchToolbarBranchSelector.browser.tsx new file mode 100644 index 000000000..a59b91817 --- /dev/null +++ b/apps/web/src/components/BranchToolbarBranchSelector.browser.tsx @@ -0,0 +1,286 @@ +// FILE: BranchToolbarBranchSelector.browser.tsx +// Purpose: Browser-level coverage for accessible branch-name copy actions. +// Layer: Vitest browser tests + +import "../index.css"; + +import type { GitListBranchesResult, GitStatusResult } from "@synara/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { page, userEvent } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { gitQueryKeys } from "../lib/gitReactQuery"; +import { readNativeApi } from "../nativeApi"; +import { transientAlertManager } from "../notifications/transientAlert"; +import type { ThreadWorkspacePatch } from "../types"; +import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; + +const CWD = "/Users/tester/scient"; + +const branchesResult: GitListBranchesResult = { + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: CWD, + }, + { + name: "feature/copy-listed-branch", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + hasOriginRemote: true, +}; + +const statusResult: GitStatusResult = { + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + upstreamBranch: "origin/main", + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +async function renderSelector(input?: { + activeThreadBranch?: string | null; + branchCwd?: string | null; + onSetThreadWorkspace?: (patch: ThreadWorkspacePatch) => void; + seedBranches?: boolean; +}) { + const branchCwd = input?.branchCwd === undefined ? CWD : input.branchCwd; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (input?.seedBranches !== false && branchCwd) { + queryClient.setQueryData(gitQueryKeys.branches(branchCwd), branchesResult); + queryClient.setQueryData(gitQueryKeys.status(branchCwd), statusResult); + } + + const onSetThreadWorkspace = + input?.onSetThreadWorkspace ?? vi.fn<(patch: ThreadWorkspacePatch) => void>(); + await render( + + + , + ); + return { onSetThreadWorkspace }; +} + +describe("BranchToolbarBranchSelector branch-name copy actions", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("copies the active branch through the discoverable picker action", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + await renderSelector(); + + await page.getByText("main", { exact: true }).click(); + await page.getByRole("button", { name: "Copy branch name: main" }).click(); + + await expect.poll(() => writeText.mock.calls).toEqual([["main"]]); + }); + + it("copies a listed branch from its native context action without checking it out", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const previousNativeApi = window.nativeApi; + const baseApi = readNativeApi(); + if (!baseApi) throw new Error("Expected browser native API fixture."); + const showContextMenu = vi.fn().mockResolvedValue("copy-branch-name"); + const checkout = vi.fn(); + const createBranch = vi.fn(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: { + ...baseApi, + contextMenu: { ...baseApi.contextMenu, show: showContextMenu }, + git: { ...baseApi.git, checkout, createBranch }, + }, + }); + + try { + const { onSetThreadWorkspace } = await renderSelector(); + await page.getByText("main", { exact: true }).click(); + const listedBranch = page.getByRole("option", { name: /feature\/copy-listed-branch/i }); + listedBranch + .element() + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 12, clientY: 18 })); + + await expect.poll(() => writeText.mock.calls).toEqual([["feature/copy-listed-branch"]]); + expect(showContextMenu).toHaveBeenCalledOnce(); + expect(showContextMenu).toHaveBeenCalledWith( + [{ id: "copy-branch-name", label: "Copy branch name" }], + { x: 12, y: 18 }, + ); + expect(checkout).not.toHaveBeenCalled(); + expect(createBranch).not.toHaveBeenCalled(); + expect(onSetThreadWorkspace).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); + + it("leaves the clipboard and workspace untouched when the native context menu is cancelled", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const previousNativeApi = window.nativeApi; + const baseApi = readNativeApi(); + if (!baseApi) throw new Error("Expected browser native API fixture."); + const showContextMenu = vi.fn().mockResolvedValue(null); + const checkout = vi.fn(); + const createBranch = vi.fn(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: { + ...baseApi, + contextMenu: { ...baseApi.contextMenu, show: showContextMenu }, + git: { ...baseApi.git, checkout, createBranch }, + }, + }); + + try { + const { onSetThreadWorkspace } = await renderSelector(); + await page.getByText("main", { exact: true }).click(); + page + .getByRole("option", { name: /feature\/copy-listed-branch/i }) + .element() + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 7, clientY: 9 })); + + await expect.poll(() => showContextMenu.mock.calls.length).toBe(1); + expect(writeText).not.toHaveBeenCalled(); + expect(checkout).not.toHaveBeenCalled(); + expect(createBranch).not.toHaveBeenCalled(); + expect(onSetThreadWorkspace).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); + + it("reports a native context-menu failure without copying or mutating Git", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const addAlert = vi.spyOn(transientAlertManager, "add"); + const previousNativeApi = window.nativeApi; + const baseApi = readNativeApi(); + if (!baseApi) throw new Error("Expected browser native API fixture."); + const showContextMenu = vi.fn().mockRejectedValue(new Error("Native menu unavailable")); + const checkout = vi.fn(); + const createBranch = vi.fn(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: { + ...baseApi, + contextMenu: { ...baseApi.contextMenu, show: showContextMenu }, + git: { ...baseApi.git, checkout, createBranch }, + }, + }); + + try { + const { onSetThreadWorkspace } = await renderSelector(); + await page.getByText("main", { exact: true }).click(); + page + .getByRole("option", { name: /feature\/copy-listed-branch/i }) + .element() + .dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 4, clientY: 6 })); + + await expect.poll(() => addAlert.mock.calls.length).toBe(1); + expect(addAlert).toHaveBeenCalledWith({ + type: "error", + title: "Could not open branch actions", + description: "Native menu unavailable", + }); + expect(writeText).not.toHaveBeenCalled(); + expect(checkout).not.toHaveBeenCalled(); + expect(createBranch).not.toHaveBeenCalled(); + expect(onSetThreadWorkspace).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); + + it("copies a keyboard-filtered branch without checking it out", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const previousNativeApi = window.nativeApi; + const baseApi = readNativeApi(); + if (!baseApi) throw new Error("Expected browser native API fixture."); + const checkout = vi.fn(); + const createBranch = vi.fn(); + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: { ...baseApi, git: { ...baseApi.git, checkout, createBranch } }, + }); + + try { + const { onSetThreadWorkspace } = await renderSelector(); + await page.getByText("main", { exact: true }).click(); + await page.getByPlaceholder("Search branches...").fill("feature/copy-listed-branch"); + + const copyButton = page.getByRole("button", { + name: "Copy branch name: feature/copy-listed-branch", + }); + await expect.element(copyButton).toBeVisible(); + expect(copyButton.element().tabIndex).toBeGreaterThanOrEqual(0); + copyButton.element().focus(); + await userEvent.keyboard("{Enter}"); + + await expect.poll(() => writeText.mock.calls).toEqual([["feature/copy-listed-branch"]]); + expect(checkout).not.toHaveBeenCalled(); + expect(createBranch).not.toHaveBeenCalled(); + expect(onSetThreadWorkspace).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "nativeApi", { + configurable: true, + value: previousNativeApi, + }); + } + }); + + it("keeps branch actions unavailable when there is no branch context", async () => { + await renderSelector({ activeThreadBranch: null, branchCwd: null }); + + await page.getByText("Select branch", { exact: true }).click(); + await expect.element(page.getByRole("button", { name: "Copy branch name" })).toBeDisabled(); + }); + + it("reports clipboard failure without switching branches", async () => { + vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue(new Error("Clipboard denied")); + vi.spyOn(document, "execCommand").mockReturnValue(false); + const addAlert = vi.spyOn(transientAlertManager, "add"); + await renderSelector(); + + await page.getByText("main", { exact: true }).click(); + await page.getByRole("button", { name: "Copy branch name: main" }).click(); + + await expect.poll(() => addAlert.mock.calls.length).toBe(1); + expect(addAlert).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + title: "Failed to copy branch name", + description: "main: Clipboard denied", + }), + ); + }); +}); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c218f9b48..81e716fbd 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,11 +3,17 @@ // Depends on: git React Query helpers, native API mutations, and toolbar selection rules. // Note: the "Create branch" footer row uses raw + {!isSelectingWorktreeBase ? (
); })} + {browserChromeStatus ? (
) : null}
-
+
= 0 ? `${browserTabsId}-tab-${activeTabIndex}` : undefined + } + > {!isLiveRuntime ? (
) : null} + {/* Native pages can leave their canvas transparent. A white host matches + normal Chromium instead of Scient's dark backing surface. */} {isLiveRuntime ? ( -
+
+ ) : null} + {(isLiveRuntime ? workspaceReady : true) && activeTab?.lastError ? ( +
+
+ + +

+ This page could not be opened +

+

+ {activeTab.lastError} +

+

+ {activeTab.displayUrl ?? activeTab.url} +

+
+ + {activeTab.kind === "local-html" && activeTab.displayUrl ? ( + + ) : null} +
+
+
) : null} {showLocalServersHome ? ( { branch: "feature/draft-automation", worktreePath: "/repo/worktrees/draft-automation", envMode: "worktree", + workspaceOrigin: "intentional", }, }, projectDraftThreadIdByProjectId: { @@ -3034,6 +3042,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "default", }, }, projectDraftThreadIdByProjectId: { @@ -3121,6 +3130,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "default", }, }, projectDraftThreadIdByProjectId: { @@ -3203,6 +3213,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "default", }, }, projectDraftThreadIdByProjectId: { @@ -3281,6 +3292,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: "feature/draft", worktreePath: "/repo/worktrees/feature-draft", envMode: "worktree", + workspaceOrigin: "intentional", }, }, projectDraftThreadIdByProjectId: { @@ -4526,6 +4538,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "intentional", }, }, projectDraftThreadIdByProjectId: { @@ -4674,6 +4687,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "intentional", }, }, projectDraftThreadIdByProjectId: { @@ -5431,12 +5445,21 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("creates a new thread from the global chat.new shortcut", async () => { + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-chat-shortcut-test" as MessageId, + targetText: "chat shortcut test", + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", - }), + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/viewed-worktree", + worktreePath: "/repo/worktrees/viewed-worktree", + })), + }, configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, @@ -5467,31 +5490,38 @@ describe("ChatView timeline estimator parity (full app)", () => { const composerEditor = await waitForComposerEditor(); composerEditor.focus(); await waitForLayout(); - await triggerChatNewShortcutUntilPath( + const newThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID from the shortcut.", ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + expect(useComposerDraftStore.getState().getDraftThread(newThreadId)).toMatchObject({ + branch: null, + worktreePath: null, + envMode: "local", + workspaceOrigin: "default", + }); } finally { await mounted.cleanup(); } }); - it("promotes terminal-first shortcut threads so they render as terminal rows", async () => { + it("uses the configured New worktree default for provider-specific shortcuts", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-shortcut-test" as MessageId, - targetText: "terminal shortcut test", + targetMessageId: "msg-user-provider-shortcut-worktree-default" as MessageId, + targetText: "provider shortcut worktree default", }), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, keybindings: [ { - command: "chat.newTerminal", + command: "chat.newCodex", shortcut: { - key: "t", + key: "c", metaKey: false, ctrlKey: false, shiftKey: true, @@ -5506,53 +5536,1176 @@ describe("ChatView timeline estimator parity (full app)", () => { ], }; }, + configureNativeApi: (api) => ({ + ...api, + server: { + ...api.server, + getSettings: async () => ({ + ...DEFAULT_SERVER_SETTINGS, + defaultThreadEnvMode: "worktree", + }), + }, + }), }); try { + await waitForNewThreadShortcutLabel(); await waitForServerConfigToApply(); const composerEditor = await waitForComposerEditor(); composerEditor.focus(); await waitForLayout(); - const newThreadPath = await triggerTerminalThreadShortcutUntilPath( + const newThreadPath = await triggerThreadShortcutUntilPath( mounted.router, + () => dispatchThreadShortcut("c"), (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new terminal-first draft thread UUID from the shortcut.", + "Provider shortcut should open a fresh draft thread.", ); const newThreadId = newThreadPath.slice(1) as ThreadId; + expect(useComposerDraftStore.getState().getDraftThread(newThreadId)).toMatchObject({ + branch: "main", + worktreePath: null, + envMode: "worktree", + workspaceOrigin: "default", + }); + } finally { + await mounted.cleanup(); + } + }); - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - typeof request.command === "object" && - request.command !== null && - "type" in request.command && - "threadId" in request.command && - request.command.type === "thread.create" && - request.command.threadId === newThreadId, - ), - ).toBe(true); + it("does not let a delayed provider preflight replace a later reused draft", async () => { + const reusableDraftThreadId = ThreadId.makeUnsafe("thread-provider-preflight-race"); + const unavailableProvider = { + provider: "codex" as const, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + message: "Codex is temporarily unavailable.", + checkedAt: NOW_ISO, + }; + const availableProvider = { + provider: "codex" as const, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + checkedAt: NOW_ISO, + runtime: { + source: "system" as const, + managedVersion: null, + canInstall: false, + canRepair: false, + canRollback: false, + canRemove: false, + message: null, + }, + }; + let resolveProviderRefresh!: (value: { providers: [typeof availableProvider] }) => void; + const providerRefresh = new Promise<{ providers: [typeof availableProvider] }>((resolve) => { + resolveProviderRefresh = resolve; + }); + const refreshProviders = vi.fn(() => providerRefresh); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-provider-preflight-race" as MessageId, + targetText: "provider preflight race", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [unavailableProvider], + keybindings: [ + { + command: "chat.newCodex", + shortcut: { + key: "c", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + configureNativeApi: (api) => ({ + ...api, + server: { + ...api.server, + refreshProviders, + }, + }), + }); + + try { + await waitForServerConfigToApply(); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + await waitForLayout(); + useComposerDraftStore.getState().setProjectDraftThreadId(PROJECT_ID, reusableDraftThreadId, { + entryPoint: "chat", + envMode: "local", + branch: null, + worktreePath: null, + workspaceOrigin: "default", + }); + useComposerDraftStore.getState().setPrompt(reusableDraftThreadId, "later draft prompt"); + + await dispatchThreadShortcut("c"); + await dispatchThreadShortcut("c"); + await vi.waitFor(() => expect(refreshProviders).toHaveBeenCalledOnce()); + + await page.getByTestId("new-thread-button").click(); + useComposerDraftStore.getState().addFiles(reusableDraftThreadId, [ + { + type: "file", + id: "provider-race-file", + name: "provider-race.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: new File(["notes"], "provider-race.txt", { type: "text/plain" }), + }, + ]); + resolveProviderRefresh({ providers: [availableProvider] }); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + + expect(mounted.router.state.location.pathname).toBe(`/${reusableDraftThreadId}`); + expect( + useComposerDraftStore.getState().draftsByThreadId[reusableDraftThreadId], + ).toMatchObject({ + prompt: "later draft prompt", + files: [expect.objectContaining({ id: "provider-race-file" })], + }); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectId(PROJECT_ID, "chat")?.threadId, + ).toBe(reusableDraftThreadId); + } finally { + resolveProviderRefresh({ providers: [availableProvider] }); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + await mounted.cleanup(); + } + }); + + it("starts one fresh thread in the exact worktree from repeated context-menu actions", async () => { + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/exact-worktree", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/exact-worktree", + }, + ], + }; + const branchLookupDeferred = (() => { + let resolve!: (value: Awaited>) => void; + const promise = new Promise>>( + (nextResolve) => { + resolve = nextResolve; }, - { timeout: 20_000, interval: 16 }, ); + return { promise, resolve }; + })(); + const branchLookup = vi.fn( + async () => exactWorktreeBranchResult, + ); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-context-worktree-test" as MessageId, + targetText: "context worktree test", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/exact-worktree", + worktreePath: "/repo/worktrees/exact-worktree", + })), + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: branchLookup, + }, + }), + }); + const defaultNavigationBlocker = (() => { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; + })(); + let defaultNavigationOperation: Promise | null = null; - // The shortcut persists terminal-first presentation separately from the - // server thread row. Observe that state before simulating promotion so - // clearing the draft cannot race the shortcut's async UI update. - await vi.waitFor( - () => { - expect( - useTerminalStateStore.getState().terminalStateByThreadId[newThreadId]?.entryPoint, - ).toBe("terminal"); + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + useStore.getState().setProjectExpanded(PROJECT_ID, true); + await waitForLayout(); + branchLookup.mockClear(); + branchLookup.mockImplementation(() => branchLookupDeferred.promise); + const projectValidationCallCount = () => + branchLookup.mock.calls.filter(([input]) => input.cwd === "/repo/project").length; + defaultNavigationOperation = runDraftNavigationOnce( + draftNavigationSlotKey(), + newThreadNavigationRequestKey({ projectId: PROJECT_ID, entryPoint: "chat" }), + () => defaultNavigationBlocker.promise, + ); + const threadRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the current thread row.", + ); + const openContextMenu = () => + threadRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), + ); + + openContextMenu(); + await vi.waitFor(() => expect(contextMenuShow).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(projectValidationCallCount()).toBe(1)); + openContextMenu(); + await vi.waitFor(() => expect(contextMenuShow).toHaveBeenCalledTimes(2)); + expect(contextMenuShow.mock.calls[0]?.[0]?.[0]).toMatchObject({ + id: "new-thread-in-workspace", + label: "New thread in worktree (feature/exact-worktree)", + }); + + expect(projectValidationCallCount()).toBe(1); + branchLookupDeferred.resolve(exactWorktreeBranchResult); + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "The explicit worktree action should open a fresh draft.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + expect(projectValidationCallCount()).toBe(1); + expect(useComposerDraftStore.getState().getDraftThread(newThreadId)).toMatchObject({ + branch: "feature/exact-worktree", + worktreePath: "/repo/worktrees/exact-worktree", + envMode: "worktree", + workspaceOrigin: "intentional", + }); + } finally { + defaultNavigationBlocker.resolve(); + branchLookupDeferred.resolve(exactWorktreeBranchResult); + await defaultNavigationOperation; + await mounted.cleanup(); + } + }); + + it("keeps a newer direct Kanban route ahead of delayed exact-workspace validation", async () => { + const otherThreadId = ThreadId.makeUnsafe("thread-existing-navigation-wins"); + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/delayed-exact", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/delayed-exact", }, - { timeout: 8_000, interval: 16 }, + ], + }; + let resolveValidation!: (value: Awaited>) => void; + const delayedValidation = new Promise>>( + (resolve) => { + resolveValidation = resolve; + }, + ); + const branchLookup = vi.fn(() => delayedValidation); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-existing-navigation-wins" as MessageId, + targetText: "existing navigation wins", + }); + const sourceThread = baseSnapshot.threads[0]!; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: [ + { + ...sourceThread, + envMode: "worktree", + branch: "feature/delayed-exact", + worktreePath: "/repo/worktrees/delayed-exact", + }, + { + ...sourceThread, + id: otherThreadId, + title: "Existing destination thread", + messages: [], + envMode: "local", + branch: "main", + worktreePath: null, + }, + ], + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: branchLookup, + }, + }), + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + useStore.getState().setProjectExpanded(PROJECT_ID, true); + await waitForLayout(); + const sourceRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the source thread row.", + ); + sourceRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), ); + await vi.waitFor(() => expect(branchLookup).toHaveBeenCalledOnce()); - useStore.getState().syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); + await mounted.router.navigate({ to: "/kanban" }); + await waitForURL( + mounted.router, + (path) => path === "/kanban", + "The newer direct Kanban route should take control of the route.", + ); + resolveValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + + expect(mounted.router.state.location.pathname).toBe("/kanban"); + expect( + Object.values(useComposerDraftStore.getState().draftThreadsByThreadId).some( + (draft) => draft.worktreePath === "/repo/worktrees/delayed-exact", + ), + ).toBe(false); + } finally { + resolveValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + await mounted.cleanup(); + } + }); + + it("lets a renewed exact-workspace action win after an intervening default action", async () => { + const reusableDraftThreadId = ThreadId.makeUnsafe("thread-exact-default-exact-race"); + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/exact-default-exact", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/exact-default-exact", + }, + ], + }; + let resolveFirstValidation!: ( + value: Awaited>, + ) => void; + const firstValidation = new Promise>>( + (resolve) => { + resolveFirstValidation = resolve; + }, + ); + const branchLookup = vi.fn( + async () => exactWorktreeBranchResult, + ); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-exact-default-exact-race" as MessageId, + targetText: "exact default exact race", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/exact-default-exact", + worktreePath: "/repo/worktrees/exact-default-exact", + })), + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: branchLookup, + }, + }), + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); useStore.getState().setProjectExpanded(PROJECT_ID, true); - useComposerDraftStore.getState().clearDraftThread(newThreadId); + await waitForLayout(); + const threadRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the current thread row.", + ); + const openContextMenu = () => + threadRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), + ); + useComposerDraftStore.getState().setProjectDraftThreadId(PROJECT_ID, reusableDraftThreadId, { + entryPoint: "chat", + envMode: "local", + branch: null, + worktreePath: null, + workspaceOrigin: "default", + }); + branchLookup.mockClear(); + branchLookup.mockImplementationOnce(() => firstValidation); + branchLookup.mockImplementation(async () => exactWorktreeBranchResult); + + openContextMenu(); + await vi.waitFor(() => expect(branchLookup).toHaveBeenCalledTimes(1)); + + await page.getByTestId("new-thread-button").click(); + await waitForURL( + mounted.router, + (path) => path === `/${reusableDraftThreadId}`, + "The intervening ordinary action should reuse the default draft.", + ); + + openContextMenu(); + await vi.waitFor(() => expect(contextMenuShow).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(branchLookup).toHaveBeenCalledTimes(2)); + const renewedExactPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "The renewed exact-workspace action should become the latest route.", + ); + const renewedExactThreadId = renewedExactPath.slice(1) as ThreadId; + expect(useComposerDraftStore.getState().getDraftThread(renewedExactThreadId)).toMatchObject({ + branch: "feature/exact-default-exact", + worktreePath: "/repo/worktrees/exact-default-exact", + envMode: "worktree", + workspaceOrigin: "intentional", + }); + + resolveFirstValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + expect(mounted.router.state.location.pathname).toBe(`/${renewedExactThreadId}`); + } finally { + resolveFirstValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + await mounted.cleanup(); + } + }); + + it("preserves a reused draft when it supersedes a waiting exact-workspace request", async () => { + const reusableDraftThreadId = ThreadId.makeUnsafe("thread-reused-draft-race"); + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/exact-worktree", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/exact-worktree", + }, + ], + }; + let resolveExactValidation!: ( + value: Awaited>, + ) => void; + const exactValidation = new Promise>>( + (resolve) => { + resolveExactValidation = resolve; + }, + ); + const branchLookup = vi.fn( + async () => exactWorktreeBranchResult, + ); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-reused-draft-race" as MessageId, + targetText: "reused draft race", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/exact-worktree", + worktreePath: "/repo/worktrees/exact-worktree", + })), + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: branchLookup, + }, + }), + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + useStore.getState().setProjectExpanded(PROJECT_ID, true); + await waitForLayout(); + const threadRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the current thread row.", + ); + useComposerDraftStore.getState().setProjectDraftThreadId(PROJECT_ID, reusableDraftThreadId, { + entryPoint: "chat", + envMode: "local", + branch: null, + worktreePath: null, + workspaceOrigin: "default", + }); + useComposerDraftStore.getState().setPrompt(reusableDraftThreadId, "preserve this draft"); + branchLookup.mockClear(); + branchLookup.mockImplementation(() => exactValidation); + + threadRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), + ); + await vi.waitFor(() => expect(branchLookup).toHaveBeenCalledTimes(1)); + + await page.getByTestId("new-thread-button").click(); + useComposerDraftStore + .getState() + .setPrompt(reusableDraftThreadId, "newer prompt with attachment"); + useComposerDraftStore.getState().addFiles(reusableDraftThreadId, [ + { + type: "file", + id: "reused-draft-notes", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: new File(["notes"], "notes.txt", { type: "text/plain" }), + }, + ]); + + resolveExactValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + await waitForURL( + mounted.router, + (path) => path === `/${reusableDraftThreadId}`, + "The later ordinary New Thread action should keep the reusable draft selected.", + ); + + expect(useComposerDraftStore.getState().getDraftThread(reusableDraftThreadId)).toMatchObject({ + branch: null, + worktreePath: null, + envMode: "local", + workspaceOrigin: "default", + }); + expect( + useComposerDraftStore.getState().draftsByThreadId[reusableDraftThreadId], + ).toMatchObject({ + prompt: "newer prompt with attachment", + files: [expect.objectContaining({ id: "reused-draft-notes", name: "notes.txt" })], + }); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectId(PROJECT_ID, "chat")?.threadId, + ).toBe(reusableDraftThreadId); + expect(document.body.textContent).not.toContain("Unable to start thread"); + } finally { + resolveExactValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + await mounted.cleanup(); + } + }); + + it("does not adopt a staged route draft before its older navigation settles", async () => { + const reusableDraftThreadId = ThreadId.makeUnsafe("thread-staged-route-reuse-race"); + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/staged-route-race", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/staged-route-race", + }, + ], + }; + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-staged-route-reuse-race" as MessageId, + targetText: "staged route reuse race", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/staged-route-race", + worktreePath: "/repo/worktrees/staged-route-race", + })), + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: vi.fn(async () => exactWorktreeBranchResult), + }, + }), + }); + let releaseFirstNavigation!: () => void; + const firstNavigationBlocker = new Promise((resolve) => { + releaseFirstNavigation = resolve; + }); + const originalNavigate = mounted.router.navigate.bind(mounted.router); + let navigationCount = 0; + const navigateSpy = vi.spyOn(mounted.router, "navigate").mockImplementation((options) => { + navigationCount += 1; + const navigation = originalNavigate(options); + return navigationCount === 1 ? navigation.then(() => firstNavigationBlocker) : navigation; + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + useStore.getState().setProjectExpanded(PROJECT_ID, true); + await waitForLayout(); + const threadRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the current thread row.", + ); + useComposerDraftStore.getState().setProjectDraftThreadId(PROJECT_ID, reusableDraftThreadId, { + entryPoint: "chat", + envMode: "local", + branch: null, + worktreePath: null, + workspaceOrigin: "default", + }); + useComposerDraftStore.getState().setPrompt(reusableDraftThreadId, "keep staged-race prompt"); + useComposerDraftStore.getState().addFiles(reusableDraftThreadId, [ + { + type: "file", + id: "staged-route-race-notes", + name: "staged-route-race.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: new File(["notes"], "staged-route-race.txt", { type: "text/plain" }), + }, + ]); + + threadRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), + ); + await vi.waitFor(() => expect(contextMenuShow).toHaveBeenCalledOnce()); + const stagedThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "The older exact-workspace draft should become visible before navigation settles.", + ); + const stagedThreadId = stagedThreadPath.slice(1) as ThreadId; + expect(stagedThreadId).not.toBe(reusableDraftThreadId); + + await page.getByTestId("new-thread-button").click(); + await waitForURL( + mounted.router, + (path) => path === `/${reusableDraftThreadId}`, + "The later ordinary action should reuse the durable mapped draft.", + ); + releaseFirstNavigation(); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + + expect( + useComposerDraftStore.getState().getDraftThreadByProjectId(PROJECT_ID, "chat")?.threadId, + ).toBe(reusableDraftThreadId); + expect( + useComposerDraftStore.getState().draftsByThreadId[reusableDraftThreadId], + ).toMatchObject({ + prompt: "keep staged-race prompt", + files: [ + expect.objectContaining({ + id: "staged-route-race-notes", + name: "staged-route-race.txt", + }), + ], + }); + expect(useComposerDraftStore.getState().getDraftThread(stagedThreadId)).toBeNull(); + expect(mounted.router.state.location.pathname).toBe(`/${reusableDraftThreadId}`); + } finally { + releaseFirstNavigation(); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + navigateSpy.mockRestore(); + await mounted.cleanup(); + } + }); + + it("keeps a later terminal intent and preserves chat attachments across entry points", async () => { + const reusableChatDraftId = ThreadId.makeUnsafe("thread-cross-entry-chat-draft"); + const reusableTerminalDraftId = ThreadId.makeUnsafe("thread-cross-entry-terminal-draft"); + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/exact-worktree", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/exact-worktree", + }, + ], + }; + let resolveExactValidation!: ( + value: Awaited>, + ) => void; + const exactValidation = new Promise>>( + (resolve) => { + resolveExactValidation = resolve; + }, + ); + const branchLookup = vi.fn( + async () => exactWorktreeBranchResult, + ); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-cross-entry-race" as MessageId, + targetText: "cross entry race", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => + Object.assign({}, thread, { + envMode: "worktree" as const, + branch: "feature/exact-worktree", + worktreePath: "/repo/worktrees/exact-worktree", + }), + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.newTerminal", + shortcut: { + key: "t", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: branchLookup, + }, + }), + }); + + try { + await waitForServerConfigToApply(); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + useStore.getState().setProjectExpanded(PROJECT_ID, true); + await waitForLayout(); + const threadRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the current thread row.", + ); + useComposerDraftStore.getState().setProjectDraftThreadId(PROJECT_ID, reusableChatDraftId, { + entryPoint: "chat", + envMode: "local", + branch: null, + worktreePath: null, + workspaceOrigin: "default", + }); + useComposerDraftStore.getState().setPrompt(reusableChatDraftId, "cross-entry prompt"); + useComposerDraftStore.getState().addFiles(reusableChatDraftId, [ + { + type: "file", + id: "cross-entry-notes", + name: "cross-entry.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: new File(["notes"], "cross-entry.txt", { type: "text/plain" }), + }, + ]); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(PROJECT_ID, reusableTerminalDraftId, { + entryPoint: "terminal", + envMode: "local", + branch: null, + worktreePath: null, + workspaceOrigin: "default", + }); + branchLookup.mockClear(); + branchLookup.mockImplementation(() => exactValidation); + + threadRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), + ); + await vi.waitFor(() => expect(branchLookup).toHaveBeenCalledTimes(1)); + + await dispatchTerminalThreadShortcut(); + resolveExactValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + + expect( + useComposerDraftStore.getState().projectDraftThreadIdByProjectId[`${PROJECT_ID}::terminal`], + ).toBe(reusableTerminalDraftId); + expect( + useComposerDraftStore.getState().getDraftThread(reusableTerminalDraftId), + ).toMatchObject({ + entryPoint: "terminal", + promotedTo: reusableTerminalDraftId, + }); + expect(mounted.router.state.location.pathname).toBe(`/${reusableTerminalDraftId}`); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectId(PROJECT_ID, "chat")?.threadId, + ).toBe(reusableChatDraftId); + expect(useComposerDraftStore.getState().draftsByThreadId[reusableChatDraftId]).toMatchObject({ + prompt: "cross-entry prompt", + files: [expect.objectContaining({ id: "cross-entry-notes", name: "cross-entry.txt" })], + }); + } finally { + resolveExactValidation(exactWorktreeBranchResult); + await waitForDraftNavigationIdle(draftNavigationSlotKey()); + await mounted.cleanup(); + } + }); + + it("fails closed with a visible error when an exact worktree moved", async () => { + const contextMenuShow = vi.fn( + async (_items: Parameters[0]) => "new-thread-in-workspace", + ); + const exactWorktreeBranchResult: Awaited> = { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/exact-worktree", + current: false, + isDefault: false, + worktreePath: "/repo/worktrees/exact-worktree", + }, + ], + }; + const movedWorktreeBranchResult: Awaited> = { + ...exactWorktreeBranchResult, + branches: [ + { + ...exactWorktreeBranchResult.branches[0]!, + worktreePath: "/repo/worktrees/moved-worktree", + }, + ], + }; + const branchLookup = vi.fn( + async () => exactWorktreeBranchResult, + ); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-context-worktree-moved" as MessageId, + targetText: "context worktree moved", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/exact-worktree", + worktreePath: "/repo/worktrees/exact-worktree", + })), + }, + configureNativeApi: (api) => ({ + ...api, + contextMenu: { + ...api.contextMenu, + show: contextMenuShow as NativeApi["contextMenu"]["show"], + }, + git: { + ...api.git, + listBranches: branchLookup, + }, + }), + }); + + try { + await vi.waitFor(() => expect(branchLookup).toHaveBeenCalled()); + branchLookup.mockClear(); + branchLookup.mockResolvedValue(movedWorktreeBranchResult); + const startingPath = mounted.router.state.location.pathname; + const startingDraftIds = Object.keys( + useComposerDraftStore.getState().draftThreadsByThreadId, + ).toSorted(); + const threadRow = await waitForElement( + () => + Array.from(document.querySelectorAll("[data-thread-entry-point]")).find( + (row) => row.textContent?.includes(THREAD_TITLE), + ) ?? null, + "Unable to find the current thread row.", + ); + + threadRow.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 24, + }), + ); + + await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="toast-title"]')).find( + (element) => element.textContent === "Workspace changed", + ) ?? null, + "A moved exact worktree should show a visible Workspace changed error.", + ); + expect(mounted.router.state.location.pathname).toBe(startingPath); + expect( + Object.keys(useComposerDraftStore.getState().draftThreadsByThreadId).toSorted(), + ).toEqual(startingDraftIds); + } finally { + await mounted.cleanup(); + } + }); + + it("promotes terminal-first shortcut threads so they render as terminal rows", async () => { + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-terminal-shortcut-test" as MessageId, + targetText: "terminal shortcut test", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => ({ + ...thread, + envMode: "worktree" as const, + branch: "feature/viewed-terminal-worktree", + worktreePath: "/repo/worktrees/viewed-terminal-worktree", + })), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.newTerminal", + shortcut: { + key: "t", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + configureNativeApi: (api) => ({ + ...api, + server: { + ...api.server, + getSettings: async () => ({ + ...DEFAULT_SERVER_SETTINGS, + defaultThreadEnvMode: "worktree", + }), + }, + }), + }); + + try { + await waitForServerConfigToApply(); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + await waitForLayout(); + const newThreadPath = await triggerTerminalThreadShortcutUntilPath( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new terminal-first draft thread UUID from the shortcut.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof request.command === "object" && + request.command !== null && + "type" in request.command && + "threadId" in request.command && + request.command.type === "thread.create" && + request.command.threadId === newThreadId, + ), + ).toBe(true); + }, + { timeout: 20_000, interval: 16 }, + ); + const terminalCreateRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof request.command === "object" && + request.command !== null && + "type" in request.command && + "threadId" in request.command && + request.command.type === "thread.create" && + request.command.threadId === newThreadId, + ); + expect(terminalCreateRequest?.command).toMatchObject({ + envMode: "local", + branch: null, + worktreePath: null, + }); + + // The shortcut persists terminal-first presentation separately from the + // server thread row. Observe that state before simulating promotion so + // clearing the draft cannot race the shortcut's async UI update. + await vi.waitFor( + () => { + expect( + useTerminalStateStore.getState().terminalStateByThreadId[newThreadId]?.entryPoint, + ).toBe("terminal"); + }, + { timeout: 8_000, interval: 16 }, + ); + + useStore.getState().syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); + useStore.getState().setProjectExpanded(PROJECT_ID, true); + useComposerDraftStore.getState().clearDraftThread(newThreadId); + + await vi.waitFor( + () => { + expect( + wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalOpen && request.threadId === newThreadId, + ), + ).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + threadId: newThreadId, + cwd: "/repo/project", + }); + }, + { timeout: 8_000, interval: 16 }, + ); await vi.waitFor( () => { @@ -5611,6 +6764,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branch: "feature/terminal-title", worktreePath: "/repo/project/.worktrees/terminal-title", envMode: "worktree", + workspaceOrigin: "intentional", }, }, projectDraftThreadIdByProjectId: { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0ef5c78a0..ab29c6e22 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -32,6 +32,7 @@ import { resolveEnvironmentPanelPreferenceAfterFirstSend, resolveEnvironmentPanelPreferenceUpdate, resolveEnvironmentPanelVisible, + resolveGitRepoUiState, resolveProjectScriptTerminalTarget, resolveQueuedSteerGateTransition, resolveComposerRuntimeMode, @@ -44,6 +45,7 @@ import { shouldEnableComposerPastedTextCollapse, shouldHandlePromptHistoryNavigationKey, shouldRenderProviderHealthBanner, + shouldShowGitActions, shouldRenderComposerFooter, shouldRouteComposerSendToPendingInput, shouldShowComposerModelBootstrapSkeleton, @@ -957,6 +959,60 @@ describe("environment panel visibility", () => { }); }); +describe("Studio Git UI", () => { + it("waits for positive repository detection in Studio", () => { + expect(resolveGitRepoUiState({ isStudioContainer: true, queriedIsRepo: undefined })).toBe( + false, + ); + expect(resolveGitRepoUiState({ isStudioContainer: true, queriedIsRepo: false })).toBe(false); + expect(resolveGitRepoUiState({ isStudioContainer: true, queriedIsRepo: true })).toBe(true); + }); + + it("keeps normal project Git UI stable while discovery is pending", () => { + expect(resolveGitRepoUiState({ isStudioContainer: false, queriedIsRepo: undefined })).toBe( + true, + ); + }); + + it("never offers Git actions for a non-repository Studio folder", () => { + expect( + shouldShowGitActions({ + isStudioContainer: true, + isContainerLandingProject: false, + hasResolvedWorktreePath: true, + isGitRepo: false, + }), + ).toBe(false); + expect( + shouldShowGitActions({ + isStudioContainer: true, + isContainerLandingProject: false, + hasResolvedWorktreePath: true, + isGitRepo: true, + }), + ).toBe(true); + }); + + it("preserves existing non-Studio action visibility", () => { + expect( + shouldShowGitActions({ + isStudioContainer: false, + isContainerLandingProject: false, + hasResolvedWorktreePath: false, + isGitRepo: false, + }), + ).toBe(true); + expect( + shouldShowGitActions({ + isStudioContainer: false, + isContainerLandingProject: true, + hasResolvedWorktreePath: false, + isGitRepo: true, + }), + ).toBe(false); + }); +}); + describe("resolveCycledModelSlug", () => { const options = [{ slug: "a" }, { slug: "b" }, { slug: "c" }, { slug: "d" }]; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 997d10514..c1eaf23b1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -451,6 +451,27 @@ export function resolveEnvironmentPanelVisible(input: { return input.environmentEnabled && input.environmentPanelOpen; } +// Normal project toolbars stay stable while repository discovery is pending. Studio folders are +// casual context, so they opt into Git UI only after a positive repository result. +export function resolveGitRepoUiState(input: { + isStudioContainer: boolean; + queriedIsRepo: boolean | undefined; +}): boolean { + return input.queriedIsRepo ?? !input.isStudioContainer; +} + +export function shouldShowGitActions(input: { + isStudioContainer: boolean; + isContainerLandingProject: boolean; + hasResolvedWorktreePath: boolean; + isGitRepo: boolean; +}): boolean { + if (input.isStudioContainer) { + return input.hasResolvedWorktreePath && input.isGitRepo; + } + return !input.isContainerLandingProject || input.hasResolvedWorktreePath; +} + // The composer live strip prefers the turn's computed diff (the // `thread.turn-diff-completed` event) so it can show real per-file +/- stats. // Before that lands, it falls back to mid-turn file-edit work-log activity so diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c76845b03..2d9820e6b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -188,12 +188,14 @@ import { resolveEnvironmentPanelPreferenceAfterFirstSend, resolveEnvironmentPanelPreferenceUpdate, resolveEnvironmentPanelVisible, + resolveGitRepoUiState, resolveProjectScriptTerminalTarget, resolvePromptHistoryNavigation, shouldHandlePromptHistoryNavigationKey, shouldEnableComposerPastedTextCollapse, shouldConsumePendingCustomBinaryConfirmation, shouldShowComposerModelBootstrapSkeleton, + shouldShowGitActions, } from "./ChatView.logic"; import { createRelevantWorkLogThreadsSelector, @@ -3392,7 +3394,6 @@ export default function ChatView({ }) : null; const gitCwd = threadWorkspaceCwd; - const showGitActions = !isContainerLandingProject || Boolean(resolvedThreadWorktreePath); const gitBranchSourceCwd = activeProject ? resolveThreadBranchSourceCwd({ projectCwd: activeProject.cwd, @@ -3404,6 +3405,10 @@ export default function ChatView({ const isMentionTrigger = composerTriggerKind === "mention"; const platform = typeof navigator === "undefined" ? "" : navigator.platform; const branchesQuery = useQuery(gitBranchesQueryOptions(gitBranchSourceCwd)); + const isGitRepo = resolveGitRepoUiState({ + isStudioContainer, + queriedIsRepo: branchesQuery.data?.isRepo, + }); const localFolderBrowseRootPath = getLocalFolderBrowseRootPath( serverConfigQuery.data?.homeDir ?? null, isMacPlatform(platform), @@ -3552,7 +3557,7 @@ export default function ChatView({ ? stripComposerTriggerText(prompt, composerTrigger) : prompt; const canOfferReviewCommand = - (branchesQuery.data?.isRepo ?? true) && + isGitRepo && canOfferReviewSlashCommand({ prompt: composerPromptWithoutActiveSlashTrigger, imageCount: composerImages.length, @@ -3860,8 +3865,12 @@ export default function ChatView({ worktreePath: activeThreadWorktreePath, }); }, [activeProjectCwd, activeThreadWorktreePath]); - // Default true while loading to avoid toolbar flicker. - const isGitRepo = branchesQuery.data?.isRepo ?? true; + const showGitActions = shouldShowGitActions({ + isStudioContainer, + isContainerLandingProject, + hasResolvedWorktreePath: Boolean(resolvedThreadWorktreePath), + isGitRepo, + }); const repoDiffTotals = useRepoDiffTotals({ gitCwd: threadWorkspaceCwd, isGitRepo, @@ -9782,7 +9791,7 @@ export default function ChatView({ }); return; } - await handleNewThread(activeProject.id, { entryPoint: "chat" }); + await handleNewThread(activeProject.id); }, handleInteractionModeChange, openForkTargetPicker: () => { @@ -10606,6 +10615,7 @@ export default function ChatView({ activeThreadId: activeThread.id, activeProvider: activeThread.session?.provider ?? activeThread.modelSelection.provider, isStudioChat: isStudioContainer, + studioFolderPath: isStudioContainer ? resolvedThreadWorktreePath : null, showGitActions, diffOpen: resolvedDiffOpen, threadAutomations: threadAutomationItems, diff --git a/apps/web/src/components/DiffPanel.logic.test.ts b/apps/web/src/components/DiffPanel.logic.test.ts index a4afbaf69..16a2b20c8 100644 --- a/apps/web/src/components/DiffPanel.logic.test.ts +++ b/apps/web/src/components/DiffPanel.logic.test.ts @@ -69,6 +69,7 @@ function makeDraftThread(overrides: Partial = {}): DraftThread branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "default", ...overrides, }; } diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index 14af5e33c..c73a04bd9 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -1056,6 +1056,7 @@ describe("EventRouter scoped orchestration sync", () => { branch: null, worktreePath: null, envMode: "local", + workspaceOrigin: "default", isTemporary: false, }, }, diff --git a/apps/web/src/components/LocalImagePreview.tsx b/apps/web/src/components/LocalImagePreview.tsx index 9fccd31b3..041350b2c 100644 --- a/apps/web/src/components/LocalImagePreview.tsx +++ b/apps/web/src/components/LocalImagePreview.tsx @@ -17,7 +17,14 @@ import { } from "react"; import { downloadUrlAsBlob } from "~/lib/browserDownload"; -import { DownloadIcon, Loader2Icon, TriangleAlertIcon } from "~/lib/icons"; +import { + DownloadIcon, + Loader2Icon, + Maximize2, + MinusIcon, + PlusIcon, + TriangleAlertIcon, +} from "~/lib/icons"; import { buildLocalImageUrl, localImageFileName } from "~/lib/localImageUrls"; import { cn } from "~/lib/utils"; @@ -113,7 +120,7 @@ export function LocalImageErrorCard(props: { downloadError?: string | null | undefined; }) { return ( - + @@ -124,7 +131,6 @@ export function LocalImageErrorCard(props: { "local-image-error__subtitle", props.downloadError ? "text-destructive" : undefined, )} - role={props.downloadError ? "alert" : undefined} > {props.downloadError ?? "The file may have moved or be unavailable."} @@ -150,6 +156,8 @@ export function LocalImagePreview(props: { alt: string; className?: string; imageClassName?: string; + /** Enables fit, actual-size, and zoom controls in the dedicated viewer. */ + interactive?: boolean; }) { const { downloadUrl, downloadName, status, imgProps } = useLocalImagePreview({ src: props.src, @@ -157,7 +165,15 @@ export function LocalImagePreview(props: { previewGrant: props.previewGrant, }); const [downloadError, setDownloadError] = useState(null); - useEffect(() => setDownloadError(null), [downloadUrl]); + const [fit, setFit] = useState(true); + const [zoom, setZoom] = useState(1); + const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null); + useEffect(() => { + setDownloadError(null); + setFit(true); + setZoom(1); + setNaturalSize(null); + }, [downloadUrl]); const handleDownloadClick = useLocalImageDownloadClick({ downloadUrl, downloadName, @@ -186,9 +202,85 @@ export function LocalImagePreview(props: { ) : null} { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + imgProps.onLoad?.(event); + }} alt={props.alt} className={cn("local-image-preview__img", props.imageClassName)} + style={ + props.interactive && !fit && naturalSize + ? { + width: `${Math.max(1, Math.round(naturalSize.width * zoom))}px`, + height: `${Math.max(1, Math.round(naturalSize.height * zoom))}px`, + maxWidth: "none", + maxHeight: "none", + } + : undefined + } /> + {props.interactive && status === "ready" ? ( +
+ + + + +
+ ) : null} + {props.interactive && status === "ready" && naturalSize ? ( + + {naturalSize.width} × {naturalSize.height} + + ) : null}
+ buildLocalImageUrl({ + src: props.src, + cwd: props.cwd ?? undefined, + grant: props.previewGrant, + }), + [props.cwd, props.previewGrant, props.src], + ); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => setStatus("loading"), [previewUrl]); + + const sharedProps = { + src: previewUrl, + controls: true, + preload: "metadata" as const, + onLoadedMetadata: () => setStatus("ready"), + onCanPlay: () => setStatus("ready"), + onError: () => setStatus("error"), + }; + + if (status === "error") { + return ( +
+
+
+
+ ); + } + + return ( +
+ {status === "loading" ? ( +
+
+ ) : null} + {props.kind === "video" ? ( +
+ ); +} diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 14df1e47b..673012a3b 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -208,7 +208,7 @@ describe("ProviderConnectionDialog", () => { ).filter((button) => button.getAttribute("aria-label") !== "Close"); expect(popup, "Expected the provider dialog popup.").toBeTruthy(); - expect(buttons).toHaveLength(3); + expect(buttons).toHaveLength(4); const popupRect = popup!.getBoundingClientRect(); for (const button of buttons) { @@ -840,6 +840,208 @@ describe("ProviderConnectionDialog", () => { } }); + it("keeps a device-code recovery action when normal browser opening fails", async () => { + const authorizationUrl = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; + const active = { + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-active", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Finish signing in to ChatGPT in the browser window.", + authorizationUrl, + }, + } satisfies ServerProviderStatus; + const openExternal = vi.fn().mockRejectedValue(new Error("browser unavailable")); + const cancelled = { + ...active, + connectionState: { + ...active.connectionState, + status: "cancelled", + finishedAt: checkedAt, + message: "Sign in was cancelled.", + }, + } satisfies ServerProviderStatus; + const device = { + ...active, + connectionState: { + operationId: "connect-codex-device", + method: "codex_device_code", + status: "waiting_for_browser", + startedAt: active.connectionState.startedAt, + finishedAt: null, + message: "Enter the one-time code shown here.", + }, + } satisfies ServerProviderStatus; + const cancelProviderConnection = vi.fn().mockResolvedValue({ providers: [cancelled] }); + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [device] }); + const restoreNativeApi = installNativeApi({ + openExternal, + cancelProviderConnection, + startProviderConnection, + }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await page.getByRole("button", { name: "Open browser again" }).click(); + await expect.element(page.getByText("browser unavailable")).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Open browser again" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Use device code instead" })) + .toBeVisible(); + await page.getByRole("button", { name: "Use device code instead" }).click(); + await vi.waitFor(() => { + expect(cancelProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + operationId: "connect-codex-active", + }); + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_device_code", + }); + }); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("shows Codex's official device code without exposing provider credentials", async () => { + const authorizationUrl = "https://auth.openai.com/codex/device"; + const active = { + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + requiresProviderAccount: true, + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-device", + method: "codex_device_code", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Enter the one-time code shown here.", + authorizationUrl, + userCode: "ABCD-EFGH", + }, + } satisfies ServerProviderStatus; + const openExternal = vi.fn().mockResolvedValue(undefined); + const restoreNativeApi = installNativeApi({ openExternal }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledWith(authorizationUrl)); + await expect.element(page.getByText("ABCD-EFGH")).toBeVisible(); + const deviceCodeStatus = page.getByRole("status", { name: "Codex device code" }); + await expect.element(deviceCodeStatus).toHaveAttribute("aria-live", "polite"); + await expect.element(deviceCodeStatus).toHaveAttribute("aria-atomic", "true"); + await expect.element(page.getByText(/Automatic timeout in (?:16:00|15:59)/u)).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Copy code" })).toBeVisible(); + await page.getByRole("button", { name: "Copy code" }).click(); + await expect.element(page.getByRole("button", { name: "Copied" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Use device code instead" })) + .not.toBeInTheDocument(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + + it("ignores a stale automatic-browser failure after the device operation changes", async () => { + let rejectFirstOpen: (error: Error) => void = () => undefined; + const firstOpen = new Promise((_resolve, reject) => { + rejectFirstOpen = reject; + }); + const authorizationUrl = "https://auth.openai.com/codex/device"; + const active = { + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + connectionState: { + operationId: "connect-codex-device-first", + method: "codex_device_code", + status: "waiting_for_browser", + startedAt: new Date().toISOString(), + finishedAt: null, + message: "Enter the one-time code shown here.", + authorizationUrl, + userCode: "ABCD-EFGH", + }, + } satisfies ServerProviderStatus; + const openExternal = vi + .fn() + .mockImplementationOnce(() => firstOpen) + .mockResolvedValue(undefined); + const restoreNativeApi = installNativeApi({ openExternal }); + const queryClient = createQueryClient(active); + useProviderConnectionDialogStore.getState().openDialog("codex", "provider_picker"); + + const screen = await render( + + + , + ); + + try { + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledTimes(1)); + applyProviderStatusesToCache(queryClient, [ + { + ...active, + connectionState: { + ...active.connectionState, + operationId: "connect-codex-device-successor", + userCode: "IJKL-MNOP", + }, + }, + ]); + await vi.waitFor(() => expect(openExternal).toHaveBeenCalledTimes(2)); + rejectFirstOpen(new Error("stale browser failure")); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + await expect + .element( + page.getByText( + "Scient could not open the browser automatically. Use Open browser again below.", + ), + ) + .not.toBeInTheDocument(); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it("reopens the validated Google authorization page without terminal use", async () => { const authorizationUrl = "https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256"; @@ -1148,11 +1350,12 @@ describe("ProviderConnectionDialog", () => { await expect.element(page.getByText("Ready to install version 1.1.5")).toBeVisible(); expect(installProvider).not.toHaveBeenCalled(); - await page.getByRole("button", { name: "Download and install" }).click(); + await page.getByRole("button", { name: "Download, install and sign in" }).click(); await vi.waitFor(() => { expect(installProvider).toHaveBeenCalledWith({ provider: "antigravity", planToken: "trusted-plan-1", + connectionMethod: "antigravity_browser", }); }); await expect.element(page.getByText("Downloading Antigravity 1.1.5.")).toBeVisible(); @@ -1252,6 +1455,130 @@ describe("ProviderConnectionDialog", () => { } }); + it("shows a persisted installation failure after restart and keeps retry available", async () => { + const failureMessage = + "Installation failed while downloading the provider: the connection was reset."; + const antigravity = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + installationState: { + operationId: "install-antigravity-failed", + operation: "install", + status: "failed", + startedAt: checkedAt, + finishedAt: "2026-07-19T12:01:00.000Z", + message: failureMessage, + }, + } satisfies ServerProviderStatus; + const queryClient = createQueryClient(antigravity); + useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByText(failureMessage)).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Try installation again" })) + .toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + } + }); + + it("retries sign-in instead of reinstalling after an automatic handoff failure", async () => { + const failureMessage = "Installation succeeded, but sign in could not start."; + const codex = { + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "managed", + managedVersion: "0.145.0", + canInstall: false, + canRepair: true, + canRollback: false, + canRemove: true, + message: null, + }, + installationState: { + operationId: "install-codex-1", + operation: "install", + status: "installed", + startedAt: checkedAt, + finishedAt: "2026-07-19T12:01:00.000Z", + message: "Codex is installed and verified.", + }, + connectionState: { + operationId: "handoff-install-codex-1", + method: "codex_browser", + status: "failed", + startedAt: "2026-07-19T12:01:00.000Z", + finishedAt: "2026-07-19T12:01:01.000Z", + message: failureMessage, + }, + } satisfies ServerProviderStatus; + const retrying = { + ...codex, + connectionState: { + operationId: "connect-codex-retry", + method: "codex_browser", + status: "waiting_for_browser", + startedAt: "2026-07-19T12:02:00.000Z", + finishedAt: null, + message: "Finish the provider sign-in in your browser.", + }, + } satisfies ServerProviderStatus; + const startProviderConnection = vi.fn().mockResolvedValue({ providers: [retrying] }); + const refreshProviders = vi.fn().mockResolvedValue({ providers: [codex] }); + const restoreNativeApi = installNativeApi({ refreshProviders, startProviderConnection }); + const queryClient = createQueryClient(codex); + useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); + + const screen = await render( + + + , + ); + + try { + await expect.element(page.getByText(failureMessage)).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open installation guide" })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Try again" }).click(); + await vi.waitFor(() => + expect(startProviderConnection).toHaveBeenCalledWith({ + provider: "codex", + method: "codex_browser", + }), + ); + } finally { + await screen.unmount(); + queryClient.clear(); + restoreNativeApi(); + } + }); + it("keeps managed installation available after a complete provider refresh", async () => { const antigravity = { provider: "antigravity", diff --git a/apps/web/src/components/ProviderConnectionDialog.tsx b/apps/web/src/components/ProviderConnectionDialog.tsx index 711a8a6d3..cf952af07 100644 --- a/apps/web/src/components/ProviderConnectionDialog.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.tsx @@ -7,6 +7,7 @@ import { compareSemverVersions } from "@synara/shared/providerVersions"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CLAUDE_CONNECTION_METHOD_OPTIONS, describeProviderConnection, @@ -34,6 +35,7 @@ import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; const CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; +const CODEX_DEVICE_CODE_CONNECTION_TIMEOUT_MS = 16 * 60 * 1_000; const ANTIGRAVITY_CONNECTION_TIMEOUT_MS = 10 * 60 * 1_000; function formatRemainingTime(startedAt: string, nowMs: number, timeoutMs: number): string { @@ -62,6 +64,9 @@ export function ProviderConnectionDialog() { const activeAuthorizationCodeSubmissionRef = useRef<{ readonly operationId: string; } | null>(null); + const openedAuthorizationUrlsRef = useRef(new Set()); + const { copyToClipboard: copyDeviceCode, isCopied: isDeviceCodeCopied } = + useCopyToClipboard(); const activeConnectionOperationIdRef = useRef(null); const status = provider ? configQuery.data?.providers.find((entry) => entry.provider === provider) @@ -95,7 +100,14 @@ export function ProviderConnectionDialog() { ["starting", "waiting_for_browser", "verifying"].includes(status.connectionState.status) ? status.connectionState : null; - activeConnectionOperationIdRef.current = activeConnection?.operationId ?? null; + const activeConnectionOperationId = activeConnection?.operationId ?? null; + const activeConnectionMethod = activeConnection?.method ?? null; + const activeConnectionAuthorizationUrl = activeConnection?.authorizationUrl ?? null; + const activeCodexDeviceCode = + provider === "codex" && activeConnectionMethod === "codex_device_code" + ? (activeConnection?.userCode ?? null) + : null; + activeConnectionOperationIdRef.current = activeConnectionOperationId; useEffect(() => { setRuntimeReconnectBaselineOperationId(undefined); @@ -156,6 +168,39 @@ export function ProviderConnectionDialog() { if (!isOpen) setAuthorizationCode(""); }, [isOpen]); + useEffect(() => { + if ( + !isOpen || + activeConnectionMethod !== "codex_device_code" || + !activeConnectionOperationId || + !activeConnectionAuthorizationUrl + ) { + return; + } + const key = `${activeConnectionOperationId}:${activeConnectionAuthorizationUrl}`; + if (openedAuthorizationUrlsRef.current.has(key)) return; + openedAuthorizationUrlsRef.current.add(key); + let disposed = false; + void ensureNativeApi() + .shell.openExternal(activeConnectionAuthorizationUrl) + .catch(() => { + if (disposed || activeConnectionOperationIdRef.current !== activeConnectionOperationId) { + return; + } + setActionError( + "Scient could not open the browser automatically. Use Open browser again below.", + ); + }); + return () => { + disposed = true; + }; + }, [ + isOpen, + activeConnectionOperationId, + activeConnectionMethod, + activeConnectionAuthorizationUrl, + ]); + if (!provider || !presentation || !Icon) return null; const startsProviderSignIn = status?.available === true && providerConnectionMethod(provider) !== null; @@ -242,6 +287,20 @@ export function ProviderConnectionDialog() { }); }; + const switchToCodexDeviceCode = () => { + const operation = status?.connectionState; + if (provider !== "codex" || !operation) return Promise.resolve(); + invalidateAuthorizationCodeSubmission(operation.operationId); + return runAction(async () => { + const cancelled = await ensureNativeApi().server.cancelProviderConnection({ + provider, + operationId: operation.operationId, + }); + applyProviderStatusesToCache(queryClient, cancelled.providers); + await performStartSignIn("codex_device_code"); + }); + }; + const reopenAuthorization = () => { const authorizationUrl = activeConnection?.authorizationUrl; if (!authorizationUrl) return Promise.resolve(); @@ -303,9 +362,11 @@ export function ProviderConnectionDialog() { setInstallPlan(plan); return; } + const connectionMethod = managedUpdateFlow ? null : providerConnectionMethod(provider); const result = await ensureNativeApi().server.installProvider({ provider, planToken: installPlan.planToken, + ...(connectionMethod ? { connectionMethod } : {}), }); if (managedUpdateFlow) setManagedUpdateStarted(true); setInstallPlan(null); @@ -384,7 +445,9 @@ export function ProviderConnectionDialog() { clockMs, provider === "antigravity" ? ANTIGRAVITY_CONNECTION_TIMEOUT_MS - : CONNECTION_TIMEOUT_MS, + : activeConnectionMethod === "codex_device_code" + ? CODEX_DEVICE_CODE_CONNECTION_TIMEOUT_MS + : CONNECTION_TIMEOUT_MS, )}

) : null} @@ -394,8 +457,7 @@ export function ProviderConnectionDialog() { role="group" aria-label="Sign-in progress actions" > - {(provider === "grok" || provider === "antigravity") && - activeConnection.authorizationUrl ? ( + {activeConnection.authorizationUrl ? ( + ) : null} {presentation.canRestart ? ( +
+
+ ) : null} + {provider === "claudeAgent" && presentation.primaryAction === "sign_in" ? (

Other sign-in methods

@@ -550,7 +648,9 @@ export function ProviderConnectionDialog() { {installPlan && managedUpdateFlow ? "Download and update" : installPlan - ? "Download and install" + ? providerConnectionMethod(provider) + ? "Download, install and sign in" + : "Download and install" : presentation.primaryLabel} ) : null} diff --git a/apps/web/src/components/RecentViewSwitcher.tsx b/apps/web/src/components/RecentViewSwitcher.tsx index b2c744807..61e937bac 100644 --- a/apps/web/src/components/RecentViewSwitcher.tsx +++ b/apps/web/src/components/RecentViewSwitcher.tsx @@ -98,7 +98,10 @@ export function RecentViewSwitcher(props: { : 0; return ( -
+
compareVersions(entry.version, defaultExpandedVersion) <= 0), + ); return ( Release history - - Every curated release, newest first. - + Scient updates, newest first. diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0d9b0062c..ccefcd3c3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -36,12 +36,14 @@ import { resolveSidebarThreadListPaging, resolveProjectEmptyState, resolvePendingSidebarViewSelection, + resolveNewThreadInWorkspaceAction, resolveSettingsBackTarget, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadHoverCardMetadata, resolveThreadRowClassName, resolveThreadStatusPill, + validateNewThreadInWorkspaceAction, shouldShowDebugFeatureFlagsMenu, shouldPrunePinnedThreads, shouldClearThreadSelectionOnMouseDown, @@ -103,6 +105,149 @@ describe("resolvePullRequestReviewBadge", () => { }); }); +describe("new thread in workspace actions", () => { + it("names exact local and worktree destinations and hides incomplete targets", () => { + expect( + resolveNewThreadInWorkspaceAction({ + branch: "feature/safe-new-thread", + envMode: "local", + worktreePath: null, + }), + ).toEqual({ + id: "new-thread-in-workspace", + label: "New thread on branch (feature/safe-new-thread)", + workspace: { kind: "existing-local", branch: "feature/safe-new-thread" }, + }); + expect( + resolveNewThreadInWorkspaceAction({ + branch: "feature/worktree", + envMode: "worktree", + worktreePath: "/repo/.scient/worktrees/feature-worktree", + }), + ).toEqual({ + id: "new-thread-in-workspace", + label: "New thread in worktree (feature/worktree)", + workspace: { + kind: "existing-worktree", + branch: "feature/worktree", + worktreePath: "/repo/.scient/worktrees/feature-worktree", + }, + }); + expect( + resolveNewThreadInWorkspaceAction({ + branch: null, + envMode: "local", + worktreePath: null, + }), + ).toBeNull(); + expect( + resolveNewThreadInWorkspaceAction({ + branch: "feature/pending", + envMode: "worktree", + worktreePath: null, + }), + ).toBeNull(); + }); + + it("fails closed when the local branch is missing or no longer checked out", () => { + const action = resolveNewThreadInWorkspaceAction({ + branch: "feature/local", + envMode: "local", + worktreePath: null, + }); + expect(action).not.toBeNull(); + if (!action) return; + + expect( + validateNewThreadInWorkspaceAction({ + action, + branches: [], + isRepo: false, + projectCwd: "/repo/project", + }), + ).toEqual({ + ok: false, + description: "This project folder is no longer a Git repository.", + }); + expect( + validateNewThreadInWorkspaceAction({ + action, + branches: [], + isRepo: true, + projectCwd: "/repo/project", + }), + ).toEqual({ + ok: false, + description: "Branch feature/local no longer exists locally.", + }); + expect( + validateNewThreadInWorkspaceAction({ + action, + branches: [ + { + name: "feature/local", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + isRepo: true, + projectCwd: "/repo/project", + }), + ).toEqual({ + ok: false, + description: "Branch feature/local is no longer checked out in the project folder.", + }); + }); + + it("accepts exact workspace mappings and normalizes Windows path differences", () => { + const localAction = resolveNewThreadInWorkspaceAction({ + branch: "main", + envMode: "local", + worktreePath: null, + }); + const worktreeAction = resolveNewThreadInWorkspaceAction({ + branch: "feature/windows", + envMode: "worktree", + worktreePath: "C:\\Repo\\Worktrees\\Windows\\", + }); + expect(localAction).not.toBeNull(); + expect(worktreeAction).not.toBeNull(); + if (!localAction || !worktreeAction) return; + + expect( + validateNewThreadInWorkspaceAction({ + action: localAction, + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo/project", + }, + ], + isRepo: true, + projectCwd: "/repo/project/", + }), + ).toEqual({ ok: true }); + expect( + validateNewThreadInWorkspaceAction({ + action: worktreeAction, + branches: [ + { + name: "feature/windows", + current: false, + isDefault: false, + worktreePath: "c:/repo/worktrees/windows", + }, + ], + isRepo: true, + projectCwd: "C:\\Repo", + }), + ).toEqual({ ok: true }); + }); +}); + describe("pullRequestRepositoryConfigFingerprint", () => { it("changes for repository-affecting project edits but not sidebar ordering or expansion", () => { const first = makeProject({ id: ProjectId.makeUnsafe("project-1"), cwd: "/repo/one" }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 541f1ce8e..831a391ac 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -4,6 +4,7 @@ import { MAX_PINNED_PROJECTS, + type GitBranch, type KeybindingCommand, type ProjectId, type PullRequestReviewRequestCountResult, @@ -13,6 +14,7 @@ import { pluralize } from "@synara/shared/text"; import { resolveThreadEnvironmentMode } from "@synara/shared/threadEnvironment"; import { isWorkspaceRootWithin, workspaceRootsEqual } from "@synara/shared/threadWorkspace"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { NewThreadWorkspaceIntent } from "../lib/threadBootstrap"; import { resolveRestorableThreadRoute, type LastThreadRoute } from "../chatRouteRestore"; import type { ChatMessage, Project, SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; @@ -47,6 +49,86 @@ export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread- export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export const DEBUG_FEATURE_FLAGS_MENU_STORAGE_KEY = "scient:show-debug-feature-flags-menu"; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export interface NewThreadInWorkspaceAction { + readonly id: "new-thread-in-workspace"; + readonly label: string; + readonly workspace: Extract< + NewThreadWorkspaceIntent, + { readonly kind: "existing-local" | "existing-worktree" } + >; +} + +export function resolveNewThreadInWorkspaceAction(input: { + readonly branch: string | null; + readonly envMode?: "local" | "worktree" | undefined; + readonly worktreePath: string | null; +}): NewThreadInWorkspaceAction | null { + const branch = input.branch?.trim() ?? ""; + if (!branch) return null; + const worktreePath = input.worktreePath?.trim() ?? ""; + if (worktreePath) { + return { + id: "new-thread-in-workspace", + label: `New thread in worktree (${branch})`, + workspace: { kind: "existing-worktree", branch, worktreePath }, + }; + } + if (input.envMode === "worktree") return null; + return { + id: "new-thread-in-workspace", + label: `New thread on branch (${branch})`, + workspace: { kind: "existing-local", branch }, + }; +} + +export type NewThreadInWorkspaceValidation = + | { readonly ok: true } + | { readonly ok: false; readonly description: string }; + +export function validateNewThreadInWorkspaceAction(input: { + readonly action: NewThreadInWorkspaceAction; + readonly branches: readonly GitBranch[]; + readonly isRepo: boolean; + readonly projectCwd: string; +}): NewThreadInWorkspaceValidation { + if (!input.isRepo) { + return { + ok: false, + description: "This project folder is no longer a Git repository.", + }; + } + const branch = input.branches.find( + (candidate) => candidate.name === input.action.workspace.branch && candidate.isRemote !== true, + ); + if (!branch) { + return { + ok: false, + description: `Branch ${input.action.workspace.branch} no longer exists locally.`, + }; + } + if (input.action.workspace.kind === "existing-worktree") { + if ( + !branch.worktreePath || + !workspaceRootsEqual(branch.worktreePath, input.action.workspace.worktreePath) + ) { + return { + ok: false, + description: `The worktree for ${input.action.workspace.branch} was removed or moved.`, + }; + } + return { ok: true }; + } + if ( + !branch.current || + (branch.worktreePath !== null && !workspaceRootsEqual(branch.worktreePath, input.projectCwd)) + ) { + return { + ok: false, + description: `Branch ${input.action.workspace.branch} is no longer checked out in the project folder.`, + }; + } + return { ok: true }; +} export type SidebarView = "threads" | "studio" | "workspace"; export type SidebarActionBadge = { readonly text: string; @@ -396,9 +478,10 @@ export function pruneProjectThreadListPagingForCollapsedProjects< * - The relative time now lives in the row hover card, so an idle row with no * status/jump glyph and no meta chips reserves almost nothing — the title runs * to the row edge instead of truncating against permanently reserved space. - * - A status/loader (or keyboard-jump) glyph occupies a ~2.25rem slot, and each - * fork/worktree/handoff meta chip adds width; the reserve grows only for the - * badges that are present. + * - Status and keyboard-jump hints have independent width. When both are present, + * reserve the sum of both slots rather than treating them as one glyph. + * - Each fork/worktree/handoff meta chip adds width; the reserve grows only for + * the badges that are present. * - The wider reserve that clears the hover pin/archive actions is applied only * on hover/focus (mirroring the project header row), so the title gives up that * width exactly when those actions appear and not a moment sooner. @@ -407,23 +490,85 @@ export function pruneProjectThreadListPagingForCollapsedProjects< */ export function resolveThreadRowTrailingReserveClass(input: { metaChipCount: number; - hasTrailingGlyph: boolean; + jumpHintParts: readonly string[]; + hasStatus: boolean; }): string { // Hover/focus reveals the pin/archive actions; the meta chips + glyph fade out // at the same time, so the hover reserve is constant regardless of rest content. const hoverReserve = "transition-[padding] duration-150 ease-out group-hover/thread-row:pr-[4.75rem] group-focus-within/thread-row:pr-[4.75rem]"; - const { metaChipCount, hasTrailingGlyph } = input; + const { metaChipCount, jumpHintParts, hasStatus } = input; + const jumpKind = + jumpHintParts.length === 0 + ? "none" + : jumpHintParts.some((part) => part.length > 1) + ? "wide" + : "compact"; + const indicatorKind = + jumpKind === "none" ? (hasStatus ? "status" : "none") : hasStatus ? "both" : "jump"; + const hasWideJumpHint = jumpKind === "wide"; if (metaChipCount <= 0) { - return cn(hasTrailingGlyph ? "pr-[1.75rem]" : "pr-2", hoverReserve); + return cn( + indicatorKind === "both" + ? hasWideJumpHint + ? "pr-[6rem]" + : "pr-[5rem]" + : indicatorKind === "jump" + ? hasWideJumpHint + ? "pr-[4rem]" + : "pr-[3rem]" + : indicatorKind === "status" + ? "pr-[1.75rem]" + : "pr-2", + hoverReserve, + ); } if (metaChipCount === 1) { - return cn(hasTrailingGlyph ? "pr-[3rem]" : "pr-[1.75rem]", hoverReserve); + return cn( + indicatorKind === "both" + ? hasWideJumpHint + ? "pr-[7.25rem]" + : "pr-[6.25rem]" + : indicatorKind === "jump" + ? hasWideJumpHint + ? "pr-[5.25rem]" + : "pr-[4.25rem]" + : indicatorKind === "status" + ? "pr-[3rem]" + : "pr-[1.75rem]", + hoverReserve, + ); } if (metaChipCount === 2) { - return cn(hasTrailingGlyph ? "pr-[4rem]" : "pr-[3rem]", hoverReserve); + return cn( + indicatorKind === "both" + ? hasWideJumpHint + ? "pr-[8.25rem]" + : "pr-[7.25rem]" + : indicatorKind === "jump" + ? hasWideJumpHint + ? "pr-[6.25rem]" + : "pr-[5.25rem]" + : indicatorKind === "status" + ? "pr-[4rem]" + : "pr-[3rem]", + hoverReserve, + ); } - return cn(hasTrailingGlyph ? "pr-[4.5rem]" : "pr-[4.25rem]", hoverReserve); + return cn( + indicatorKind === "both" + ? hasWideJumpHint + ? "pr-[8.75rem]" + : "pr-[7.75rem]" + : indicatorKind === "jump" + ? hasWideJumpHint + ? "pr-[6.75rem]" + : "pr-[5.75rem]" + : indicatorKind === "status" + ? "pr-[4.5rem]" + : "pr-[4.25rem]", + hoverReserve, + ); } export function resolveThreadRowClassName(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 32affecb8..72728fd8e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -88,7 +88,7 @@ import { type ServerLocalServerProcess, } from "@synara/contracts"; import { isGenericChatThreadTitle } from "@synara/shared/chatThreads"; -import { getDefaultModel, getRecommendedDefaultModelSelection } from "@synara/shared/model"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { pluralize } from "@synara/shared/text"; import { localServerAddressLabel, localServerMatchesRun } from "@synara/shared/localServers"; import { resolveThreadWorkspaceCwd } from "@synara/shared/threadEnvironment"; @@ -203,7 +203,10 @@ import { SidebarRowHoverActions } from "./SidebarRowHoverActions"; import { SidebarSectionToolbar } from "./SidebarSectionToolbar"; import { SidebarGlyph, sidebarGlyphClass, SIDEBAR_TRAILING_ICON_CLASS } from "./sidebarGlyphs"; import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; -import { ThreadRunningSpinner } from "./ThreadRunningSpinner"; +import { + SidebarStatusTrailingGlyph, + SidebarThreadTrailingIndicators, +} from "./SidebarThreadTrailingIndicators"; import { RenameDialog } from "./RenameDialog"; import { RenameThreadDialog } from "./RenameThreadDialog"; import { ScientProjectInitializationDialog } from "./ScientProjectInitializationDialog"; @@ -228,7 +231,7 @@ import { import { projectScriptRuntimeEnv } from "../projectScripts"; import { transientAlertManager } from "../notifications/transientAlert"; import { showUndoSnackbar } from "./ui/undoSnackbar"; -import { ActivityCenter } from "../notifications/ActivityCenter"; +import { SidebarFooterControls } from "./SidebarFooterControls"; import { activityManager, type PublishActivityInput } from "../notifications/activityStore"; import { normalizeSidebarProjectThreadListCwd, @@ -277,7 +280,6 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, - SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, @@ -312,6 +314,7 @@ import { isLatestPinnedThreadMutation, pruneProjectThreadListPagingForCollapsedProjects, recoverExistingAddProjectTarget, + resolveNewThreadInWorkspaceAction, resolvePullRequestReviewBadge, resolveSidebarThreadListPaging, DEBUG_FEATURE_FLAGS_MENU_STORAGE_KEY, @@ -324,7 +327,7 @@ import { resolveThreadRowClassName, resolveThreadRowTrailingReserveClass, resolveThreadStatusPill, - type ThreadStatusPill, + validateNewThreadInWorkspaceAction, type SidebarDerivedProjectData, type SidebarActionBadge, type SidebarView, @@ -678,28 +681,6 @@ function WorktreeBadgeGlyph({ className }: { className?: string }) { return
) : null} - {input.threadJumpLabel ? ( - - {input.threadJumpLabelParts.map((part) => ( - {part} - ))} - - ) : null} - {!input.threadJumpLabel && input.threadStatus ? ( - // The relative time now lives in the row hover card, so the trailing - // slot only carries the live status/loader glyph; when idle it - // collapses and the hover action icons sit flush at the end. - - - - ) : null} + {input.hoverActions}
); @@ -5523,7 +5507,8 @@ export default function Sidebar() { leadingPrStatus && "pl-8", resolveThreadRowTrailingReserveClass({ metaChipCount: rightMetaChips.length, - hasTrailingGlyph: hasTrailingStatusGlyph, + jumpHintParts: threadJumpLabel ? threadJumpLabelParts : EMPTY_SHORTCUT_PARTS, + hasStatus: Boolean(threadStatus), }), isActive ? SIDEBAR_ROW_ACTIVE_CLASS_NAME @@ -5735,12 +5720,11 @@ export default function Sidebar() { isSelected, }), leadingPrStatus ? "pl-8" : topLevel && !isSubagentThread ? "pl-2" : null, - isSubagentThread - ? "pr-7.5" - : resolveThreadRowTrailingReserveClass({ - metaChipCount: showCompactMeta ? rightMetaChips.length : 0, - hasTrailingGlyph: Boolean(threadStatus) || Boolean(threadJumpLabel), - }), + resolveThreadRowTrailingReserveClass({ + metaChipCount: showCompactMeta ? rightMetaChips.length : 0, + jumpHintParts: threadJumpLabel ? threadJumpLabelParts : EMPTY_SHORTCUT_PARTS, + hasStatus: Boolean(threadStatus), + }), )} draggable onDragStart={(event) => { @@ -6123,12 +6107,7 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void handleNewThread(project.id, { - envMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - entryPoint: "terminal", - }); + void handleNewThread(project.id, { entryPoint: "terminal" }); }} /> @@ -7453,72 +7428,69 @@ export default function Sidebar() { ) : null} - - - -
- {DebugFeatureFlagsMenu && showDebugFeatureFlagsMenu && !isOnSettings ? ( - - - - ) : null} - -
- {!isOnSettings && ( - void navigate({ to: "/settings" })} - > - - - - Settings - + + + + ) : null + } + settingsAndUpdate={ +
+ {!isOnSettings && ( + - - - - {desktopUpdateButtonPresentation.label} - - {desktopUpdateButtonPresentation.secondaryLabel ? ( - - {desktopUpdateButtonPresentation.secondaryLabel} - - ) : null} + onClick={() => void navigate({ to: "/settings" })} + > + + + + Settings + + )} + {showDesktopUpdateButton ? ( + + + + + {desktopUpdateButtonPresentation.label} + + {desktopUpdateButtonPresentation.secondaryLabel ? ( + + {desktopUpdateButtonPresentation.secondaryLabel} - {desktopUpdateDownloadPercent !== null ? ( - - {desktopUpdateDownloadPercent}% - - ) : null} - - } - /> - {desktopUpdateTooltip} - - ) : null} -
-
- - - + ) : null} + + {desktopUpdateDownloadPercent !== null ? ( + + {desktopUpdateDownloadPercent}% + + ) : null} + + } + /> + {desktopUpdateTooltip} + + ) : null} +
+ } + /> {projectContextMenuState && projectContextMenuProject && projectContextMenuAnchor ? (
+ + +
+ {beforeReleaseNote} + + + {settingsAndUpdate} +
+
+
+ + ); +} diff --git a/apps/web/src/components/SidebarThreadTrailingIndicators.browser.tsx b/apps/web/src/components/SidebarThreadTrailingIndicators.browser.tsx new file mode 100644 index 000000000..e618e1d97 --- /dev/null +++ b/apps/web/src/components/SidebarThreadTrailingIndicators.browser.tsx @@ -0,0 +1,137 @@ +// FILE: SidebarThreadTrailingIndicators.browser.tsx +// Purpose: Prove thread-jump hints coexist with durable sidebar status semantics and geometry. +// Layer: Vitest browser tests + +import "../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import type { ThreadStatusPill } from "./Sidebar.logic"; +import { resolveThreadRowTrailingReserveClass } from "./Sidebar.logic"; +import { SidebarThreadTrailingIndicators } from "./SidebarThreadTrailingIndicators"; + +function status(label: "Working" | "Pending Approval" | "Completed"): ThreadStatusPill { + return { + label, + colorClass: "text-sky-600", + dotClass: "bg-sky-500", + pulse: label === "Working", + dismissible: false, + }; +} + +async function renderIndicators(label: "Working" | "Pending Approval" | "Completed") { + await render( +
+ +
, + ); +} + +async function renderRepresentativeRow(input: { + jumpLabel: string; + jumpParts: readonly string[]; + threadStatus: ThreadStatusPill | null; +}) { + await render( +
+ + A long thread title with project context + +
+ +
+
, + ); +} + +function expectRowContentBeforeTrailingCluster() { + const contentBox = page.getByTestId("row-content").element().getBoundingClientRect(); + const trailingBox = page.getByTestId("trailing-cluster").element().getBoundingClientRect(); + + expect(contentBox.right).toBeLessThanOrEqual(trailingBox.left + 0.5); +} + +describe("SidebarThreadTrailingIndicators", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(["Working", "Pending Approval", "Completed"] as const)( + "renders the jump hint and %s status together", + async (label) => { + await renderIndicators(label); + + await expect.element(page.getByLabelText("Jump to thread: ⌘+1")).toBeVisible(); + await expect.element(page.getByLabelText(`Thread status: ${label}`)).toBeVisible(); + await expect.element(page.getByText("⌘", { exact: true })).toBeVisible(); + await expect.element(page.getByText("1", { exact: true })).toBeVisible(); + }, + ); + + it("[geometry:linux] keeps the status beside the complete hint without overlap", async () => { + await renderIndicators("Pending Approval"); + + const jumpBox = page.getByLabelText("Jump to thread: ⌘+1").element().getBoundingClientRect(); + const statusBox = page + .getByLabelText("Thread status: Pending Approval") + .element() + .getBoundingClientRect(); + + expect(jumpBox.right).toBeLessThanOrEqual(statusBox.left + 0.5); + }); + + it("[geometry:linux] keeps row content clear of a simultaneous hint and status", async () => { + await renderRepresentativeRow({ + jumpLabel: "⌘+1", + jumpParts: ["⌘", "1"], + threadStatus: status("Pending Approval"), + }); + + expectRowContentBeforeTrailingCluster(); + }); + + it("[geometry:linux] keeps row content clear of a wide Ctrl jump hint", async () => { + await renderRepresentativeRow({ + jumpLabel: "Ctrl+1", + jumpParts: ["Ctrl", "1"], + threadStatus: null, + }); + + expectRowContentBeforeTrailingCluster(); + }); + + it("[geometry:linux] keeps row content clear of Ctrl jump and status together", async () => { + await renderRepresentativeRow({ + jumpLabel: "Ctrl+1", + jumpParts: ["Ctrl", "1"], + threadStatus: status("Pending Approval"), + }); + + expectRowContentBeforeTrailingCluster(); + }); +}); diff --git a/apps/web/src/components/SidebarThreadTrailingIndicators.tsx b/apps/web/src/components/SidebarThreadTrailingIndicators.tsx new file mode 100644 index 000000000..4da6dd673 --- /dev/null +++ b/apps/web/src/components/SidebarThreadTrailingIndicators.tsx @@ -0,0 +1,83 @@ +// FILE: SidebarThreadTrailingIndicators.tsx +// Purpose: Render keyboard-jump hints and durable thread status together in sidebar rows. +// Layer: Sidebar UI component + +import { CheckCircle2Icon } from "~/lib/icons"; +import { cn } from "~/lib/utils"; +import { sidebarHoverRevealHideClassName } from "~/sidebarRowStyles"; + +import type { ThreadStatusPill } from "./Sidebar.logic"; +import { SIDEBAR_TRAILING_ICON_CLASS } from "./sidebarGlyphs"; +import { ThreadRunningSpinner } from "./ThreadRunningSpinner"; +import { Kbd, KbdGroup } from "./ui/kbd"; + +const THREAD_ROW_INDICATOR_HOVER_FADE_CLASS_NAME = cn( + "flex shrink-0 items-center", + sidebarHoverRevealHideClassName("thread-row"), +); + +function threadStatusSlotClassName(isSubagentThread: boolean, toneClassName?: string): string { + return cn( + "flex shrink-0 items-center justify-end leading-none tabular-nums", + sidebarHoverRevealHideClassName("thread-row"), + isSubagentThread + ? "w-[1.2rem] text-[10px]" + : "w-[1.625rem] text-[length:calc(var(--app-font-size-ui-meta,11px)+0.5px)]", + toneClassName ?? (isSubagentThread ? "text-muted-foreground/26" : "text-muted-foreground/38"), + ); +} + +export function SidebarStatusTrailingGlyph({ status }: { status: ThreadStatusPill }) { + if (status.label === "Completed") { + return ( +
- -
+ } > - {/* Close button. `stopPropagation` so dismissing doesn't also fire - the card's onOpen handler. */} - - - {/* Hero band: screenshot when the entry supplies one, otherwise a - branded gradient + icon so every release still gets a polished - visual. */} -
- {entry.heroImage !== undefined ? ( +
+ {entry.heroImage ? ( - +
)} - {/* Subtle bottom gradient so text below the band always reads. */} -
-

New · v{currentVersion}

-

- {primaryFeatureTitle ?? `What's new in v${currentVersion}`} -

-

- Find out what’s new -

-
-
+ + + New in Scient · v{currentVersion} + + + {entry.headline} + + + See what improved + + + + + +
); } diff --git a/apps/web/src/whatsNew/WhatsNewProvider.tsx b/apps/web/src/whatsNew/WhatsNewProvider.tsx new file mode 100644 index 000000000..7855d0435 --- /dev/null +++ b/apps/web/src/whatsNew/WhatsNewProvider.tsx @@ -0,0 +1,41 @@ +// FILE: whatsNew/WhatsNewProvider.tsx +// Purpose: Share one release-note state machine between the sidebar card and +// the root-owned dialog without coupling the sidebar to release persistence. + +import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; + +import { DialogCreateHandle } from "../components/ui/dialog"; +import { useWhatsNew, type UseWhatsNewResult } from "./useWhatsNew"; +import type { WhatsNewEntry } from "./logic"; + +export type WhatsNewDialogHandle = ReturnType; +export type WhatsNewContextValue = UseWhatsNewResult & { + readonly dialogHandle: WhatsNewDialogHandle; +}; + +const WhatsNewContext = createContext(null); + +// Base UI supports a detached trigger and dialog root through a shared handle. +// The sidebar card and root dialog intentionally live in different subtrees. +export function WhatsNewProvider({ + children, + entries, + currentVersion, +}: { + readonly children: ReactNode; + readonly entries?: readonly WhatsNewEntry[]; + readonly currentVersion?: string; +}) { + const whatsNew = useWhatsNew({ entries, currentVersion }); + const [dialogHandle] = useState(() => DialogCreateHandle()); + const value = useMemo(() => ({ ...whatsNew, dialogHandle }), [dialogHandle, whatsNew]); + return {children}; +} + +export function useWhatsNewContext(): WhatsNewContextValue { + const context = useContext(WhatsNewContext); + if (context === null) { + throw new Error("useWhatsNewContext must be used within WhatsNewProvider."); + } + return context; +} diff --git a/apps/web/src/whatsNew/WhatsNewSidebarCard.tsx b/apps/web/src/whatsNew/WhatsNewSidebarCard.tsx new file mode 100644 index 000000000..95e8148d4 --- /dev/null +++ b/apps/web/src/whatsNew/WhatsNewSidebarCard.tsx @@ -0,0 +1,30 @@ +// FILE: whatsNew/WhatsNewSidebarCard.tsx +// Purpose: Connect the sidebar placement to the root-owned release-note state. + +import { useWhatsNewContext } from "./WhatsNewProvider"; +import { WhatsNewPopoutCard } from "./WhatsNewPopoutCard"; + +export function WhatsNewSidebarCard() { + const { + currentEntry, + currentVersion, + isPopoutVisible, + openDialog, + dismissPopout, + markPopoutPresented, + dialogHandle, + } = useWhatsNewContext(); + + if (!currentEntry || !isPopoutVisible) return null; + + return ( + + ); +} diff --git a/apps/web/src/whatsNew/entries.json b/apps/web/src/whatsNew/entries.json new file mode 100644 index 000000000..4d9d1301d --- /dev/null +++ b/apps/web/src/whatsNew/entries.json @@ -0,0 +1,29 @@ +[ + { + "version": "0.5.13", + "date": "2026-07-25", + "headline": "A calmer, safer way to work in every project", + "features": [ + { + "id": "workspace-intent", + "title": "New threads stay in the right project", + "description": "Start a conversation in the project's default workspace, or choose a specific branch or worktree when you want to continue focused work." + }, + { + "id": "folder-drop", + "title": "Add projects with a simple drop", + "description": "Drop a folder into the project picker to add it safely, with clear feedback when the drop is outside the picker." + }, + { + "id": "open-code-reliability", + "title": "More reliable provider sessions", + "description": "OpenCode and Kilo sessions recover more predictably when a turn finishes, a server exits, or a connection needs attention." + }, + { + "id": "local-file-viewer", + "title": "Open local files with confidence", + "description": "Preview supported local files in a protected viewer while keeping existing Markdown previews and project boundaries intact." + } + ] + } +] diff --git a/apps/web/src/whatsNew/entries.ts b/apps/web/src/whatsNew/entries.ts index 1904045d1..288c0ddb3 100644 --- a/apps/web/src/whatsNew/entries.ts +++ b/apps/web/src/whatsNew/entries.ts @@ -3,7 +3,8 @@ // Layer: Web application release communication. import type { WhatsNewEntry } from "./logic"; +import entries from "./entries.json"; -// Upstream Synara release notes are intentionally not user-facing Scient -// content. Populate this list only when Scient ships its own reviewed release. -export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[] = []; +// Donor release notes are never user-facing Scient content. Populate this list +// only with Scient-owned copy approved for the exact release candidate. +export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[] = entries; diff --git a/apps/web/src/whatsNew/logic.test.ts b/apps/web/src/whatsNew/logic.test.ts index 93537cb65..edafee574 100644 --- a/apps/web/src/whatsNew/logic.test.ts +++ b/apps/web/src/whatsNew/logic.test.ts @@ -11,6 +11,7 @@ import { const entry = (version: string, overrides?: Partial): WhatsNewEntry => ({ version, date: "Jan 1", + headline: `What improved in ${version}`, features: [ { id: `feature-${version}`, @@ -51,6 +52,13 @@ describe("compareVersions", () => { expect(compareVersions("2.0.0", "1.99.99")).toBeGreaterThan(0); expect(compareVersions("1.2.0", "1.1.99")).toBeGreaterThan(0); }); + + it("orders prereleases before stable releases and compares their identifiers", () => { + expect(compareVersions("1.0.0-beta.1", "1.0.0-beta.2")).toBeLessThan(0); + expect(compareVersions("1.0.0-beta.2", "1.0.0")).toBeLessThan(0); + expect(compareVersions("1.0.0", "1.0.0-rc.1")).toBeGreaterThan(0); + expect(compareVersions("1.0.0+build.2", "1.0.0+build.1")).toBe(0); + }); }); describe("sortEntriesByVersionDesc", () => { @@ -77,17 +85,17 @@ describe("resolveWhatsNewState", () => { const state = resolveWhatsNewState({ entries, currentVersion: "0.0.29", - lastSeenVersion: null, + lastHandledVersion: null, }); - expect(state).toEqual({ kind: "silent-bootstrap", nextLastSeenVersion: "0.0.29" }); + expect(state).toEqual({ kind: "silent-bootstrap", nextLastHandledVersion: "0.0.29" }); }); it("returns noop when the user is already up to date", () => { const state = resolveWhatsNewState({ entries, currentVersion: "0.0.29", - lastSeenVersion: "0.0.29", + lastHandledVersion: "0.0.29", }); expect(state).toEqual({ kind: "noop" }); @@ -97,7 +105,7 @@ describe("resolveWhatsNewState", () => { const state = resolveWhatsNewState({ entries, currentVersion: "0.0.28", - lastSeenVersion: "0.0.29", + lastHandledVersion: "0.0.29", }); expect(state).toEqual({ kind: "noop" }); @@ -107,27 +115,36 @@ describe("resolveWhatsNewState", () => { const state = resolveWhatsNewState({ entries, currentVersion: "0.0.29", - lastSeenVersion: "0.0.27", + lastHandledVersion: "0.0.27", }); if (state.kind !== "show") { throw new Error("expected show state"); } expect(state.currentEntry.version).toBe("0.0.29"); - expect(state.nextLastSeenVersion).toBe("0.0.29"); + expect(state.nextLastHandledVersion).toBe("0.0.29"); // Accordion view shows everything we know about, newest first — including - // releases that come *after* the installed build so users can see what's - // coming next if the team chose to preview it. - expect(state.allEntries.map((e) => e.version)).toEqual(["0.1.0", "0.0.29", "0.0.28", "0.0.27"]); + // Staged future notes stay hidden until that version is actually installed. + expect(state.allEntries.map((e) => e.version)).toEqual(["0.0.29", "0.0.28", "0.0.27"]); }); it("silent-bootstraps when the user upgraded but there's no curated entry", () => { const state = resolveWhatsNewState({ entries: [entry("0.0.10")], currentVersion: "0.0.29", - lastSeenVersion: "0.0.28", + lastHandledVersion: "0.0.28", }); - expect(state).toEqual({ kind: "silent-bootstrap", nextLastSeenVersion: "0.0.29" }); + expect(state).toEqual({ kind: "silent-bootstrap", nextLastHandledVersion: "0.0.29" }); + }); + + it("requires an exact prerelease entry instead of borrowing stable copy", () => { + expect( + resolveWhatsNewState({ + entries: [entry("1.0.0")], + currentVersion: "1.0.0-beta.2", + lastHandledVersion: "1.0.0-beta.1", + }), + ).toEqual({ kind: "silent-bootstrap", nextLastHandledVersion: "1.0.0-beta.2" }); }); }); diff --git a/apps/web/src/whatsNew/logic.ts b/apps/web/src/whatsNew/logic.ts index b691dc35b..face1c8a1 100644 --- a/apps/web/src/whatsNew/logic.ts +++ b/apps/web/src/whatsNew/logic.ts @@ -8,9 +8,8 @@ // and keeps the hook thin. /** - * A single feature highlight inside a release. Modelled after the - * IndieDevs "feature card" format so each bullet can carry a screenshot and - * a longer technical blurb, not just a title. + * A single user-facing highlight inside a release. Each highlight can carry + * approved artwork and a little more context, not just a title. * * `image`, `imageAlt`, and `details` are optional — a release can still ship * text-only notes when visuals aren't available yet. @@ -30,17 +29,18 @@ export interface WhatsNewFeature { * into `import.meta.env.APP_VERSION`). `date` is a human-readable label * rendered verbatim (e.g. `"Apr 18"`), so authors control the format. * - * `heroImage` / `heroImageAlt` are optional artwork shown on the post-update - * popout card (the little "New: ..." pill in the bottom-left corner). When - * omitted, the card falls back to a gradient + icon — so a release without a - * screenshot still gets a polished entry point. + * `heroImage` is optional decorative artwork shown on the post-update sidebar + * card (the small "New in Scient" card above Activity). When omitted, the + * card falls back to a gradient + icon — so a release without a screenshot + * still gets a polished entry point. */ export interface WhatsNewEntry { readonly version: string; readonly date: string; + /** Short, benefit-led summary used on the one-time sidebar card. */ + readonly headline: string; readonly features: readonly WhatsNewFeature[]; readonly heroImage?: string; - readonly heroImageAlt?: string; } /** @@ -49,7 +49,8 @@ export interface WhatsNewEntry { * dialog — it just sorts as the lowest possible value. */ export function parseVersion(version: string): readonly [number, number, number] { - const [rawMajor = "0", rawMinor = "0", rawPatch = "0"] = version.split("."); + const normalized = version.trim().replace(/^v/, "").split(/[+-]/, 1)[0] ?? ""; + const [rawMajor = "0", rawMinor = "0", rawPatch = "0"] = normalized.split("."); const major = Number.parseInt(rawMajor, 10); const minor = Number.parseInt(rawMinor, 10); const patch = Number.parseInt(rawPatch, 10); @@ -69,7 +70,36 @@ export function compareVersions(a: string, b: string): number { const [majorB, minorB, patchB] = parseVersion(b); if (majorA !== majorB) return majorA - majorB; if (minorA !== minorB) return minorA - minorB; - return patchA - patchB; + if (patchA !== patchB) return patchA - patchB; + + const prereleaseA = parsePrerelease(a); + const prereleaseB = parsePrerelease(b); + if (prereleaseA === null && prereleaseB === null) return 0; + if (prereleaseA === null) return 1; + if (prereleaseB === null) return -1; + + const length = Math.max(prereleaseA.length, prereleaseB.length); + for (let index = 0; index < length; index += 1) { + const identifierA = prereleaseA[index]; + const identifierB = prereleaseB[index]; + if (identifierA === undefined) return -1; + if (identifierB === undefined) return 1; + if (identifierA === identifierB) continue; + + const numberA = /^\d+$/.test(identifierA) ? Number(identifierA) : null; + const numberB = /^\d+$/.test(identifierB) ? Number(identifierB) : null; + if (numberA !== null && numberB !== null) return numberA - numberB; + if (numberA !== null) return -1; + if (numberB !== null) return 1; + return identifierA.localeCompare(identifierB); + } + return 0; +} + +function parsePrerelease(version: string): readonly string[] | null { + const withoutBuild = version.trim().replace(/^v/, "").split("+", 1)[0] ?? ""; + const separatorIndex = withoutBuild.indexOf("-"); + return separatorIndex === -1 ? null : withoutBuild.slice(separatorIndex + 1).split("."); } /** @@ -94,12 +124,10 @@ export interface WhatsNewInputs { /** The currently installed app version (`import.meta.env.APP_VERSION`). */ readonly currentVersion: string; /** - * The last version the user acknowledged. `null` means "never dismissed a - * What's New dialog", which on the very first launch we treat as a fresh - * install — we silently mark the current version as seen instead of showing - * the entire historical changelog. + * The last version this installation handled, either silently during first + * run/no-note bootstrap or through a genuinely visible release card. */ - readonly lastSeenVersion: string | null; + readonly lastHandledVersion: string | null; } /** @@ -107,10 +135,10 @@ export interface WhatsNewInputs { * * - `show`: there's a curated release entry matching the current version. * `currentEntry` drives the default "What's new?" view; `allEntries` is - * the full history for the "Complete changelog" secondary view. On - * dismiss, persist `nextLastSeenVersion`. + * the eligible history for the "Release history" secondary view. Once the + * card is presented, persist `nextLastHandledVersion`. * - `silent-bootstrap`: first launch or no curated entry for this upgrade — - * no dialog, just record `nextLastSeenVersion` so we don't dump the + * no dialog, just record `nextLastHandledVersion` so we don't dump the * backlog on the user or re-evaluate on every launch. * - `noop`: nothing to do. Either the user is already up to date or the * current version is older than what they've seen (e.g. a downgrade). @@ -120,54 +148,54 @@ export type WhatsNewState = readonly kind: "show"; readonly currentEntry: WhatsNewEntry; readonly allEntries: readonly WhatsNewEntry[]; - readonly nextLastSeenVersion: string; + readonly nextLastHandledVersion: string; } | { readonly kind: "silent-bootstrap"; - readonly nextLastSeenVersion: string; + readonly nextLastHandledVersion: string; } | { readonly kind: "noop" }; /** * Compute what the dialog should do given the current version, the user's - * last-seen version, and the known changelog entries. This is the single + * last-handled version, and the known release entries. This is the single * place the rules live; the hook and the tests both go through here. * - * The IndieDevs-style dialog always anchors on the *current* release entry + * The Scient dialog always anchors on the *current* release entry * (the one matching `currentVersion`), then offers the full changelog as a * secondary view. So here we don't try to batch up "all skipped releases" * into the main view — we just confirm the current release has curated * notes and surface them, letting the accordion handle history. */ export function resolveWhatsNewState(inputs: WhatsNewInputs): WhatsNewState { - const { entries, currentVersion, lastSeenVersion } = inputs; + const { entries, currentVersion, lastHandledVersion } = inputs; // First-ever launch: record the current version and stay quiet. Showing a // "What's new" dialog to a brand-new user on their first boot would feel // like marketing spam. - if (lastSeenVersion === null) { - return { kind: "silent-bootstrap", nextLastSeenVersion: currentVersion }; + if (lastHandledVersion === null) { + return { kind: "silent-bootstrap", nextLastHandledVersion: currentVersion }; } // Already up to date, or the user somehow downgraded. Either way, don't // surface anything — we only move the marker forward, never backward. - if (compareVersions(currentVersion, lastSeenVersion) <= 0) { + if (compareVersions(currentVersion, lastHandledVersion) <= 0) { return { kind: "noop" }; } - const currentEntry = entries.find( - (entry) => compareVersions(entry.version, currentVersion) === 0, - ); + const currentEntry = entries.find((entry) => entry.version === currentVersion); if (!currentEntry) { // No curated notes for the installed build — silently advance so we // don't re-evaluate on every launch. - return { kind: "silent-bootstrap", nextLastSeenVersion: currentVersion }; + return { kind: "silent-bootstrap", nextLastHandledVersion: currentVersion }; } return { kind: "show", currentEntry, - allEntries: sortEntriesByVersionDesc(entries), - nextLastSeenVersion: currentVersion, + allEntries: sortEntriesByVersionDesc( + entries.filter((entry) => compareVersions(entry.version, currentVersion) <= 0), + ), + nextLastHandledVersion: currentVersion, }; } diff --git a/apps/web/src/whatsNew/useWhatsNew.ts b/apps/web/src/whatsNew/useWhatsNew.ts index 60f63cd84..2643d9d6c 100644 --- a/apps/web/src/whatsNew/useWhatsNew.ts +++ b/apps/web/src/whatsNew/useWhatsNew.ts @@ -1,6 +1,6 @@ // FILE: whatsNew/useWhatsNew.ts // Purpose: React hook that drives the two-stage "What's new" surface: -// 1. A small popout card (bottom-left) advertising the new release. +// 1. A small inline sidebar card announcing the new release. // 2. A dialog with the release notes, opened only when the user taps the card. // Persists the "already seen this version" marker in localStorage so the popout // doesn't reappear after dismissal. @@ -27,10 +27,18 @@ const WHATS_NEW_STORAGE_KEY = "scient:whats-new:v1"; // through JSON; `null` stays faithful across reloads. const WhatsNewStorageSchema = Schema.Struct({ lastSeenVersion: Schema.NullOr(Schema.String), + // Optional additions keep existing v1 storage readable. `lastHandledVersion` + // drives eligibility; `lastPresentedVersion` is honest presentation evidence. + lastHandledVersion: Schema.optional(Schema.NullOr(Schema.String)), + lastPresentedVersion: Schema.optional(Schema.NullOr(Schema.String)), }); type WhatsNewStorage = typeof WhatsNewStorageSchema.Type; -const INITIAL_STORAGE: WhatsNewStorage = { lastSeenVersion: null }; +const INITIAL_STORAGE: WhatsNewStorage = { + lastSeenVersion: null, + lastHandledVersion: null, + lastPresentedVersion: null, +}; export interface UseWhatsNewResult { /** @@ -43,10 +51,12 @@ export interface UseWhatsNewResult { readonly allEntries: readonly WhatsNewEntry[]; /** Version the popout / dialog is announcing (the installed build). */ readonly currentVersion: string; - /** Whether the bottom-left "New: ..." card should be rendered. */ + /** Whether the inline "New in Scient" card should be rendered. */ readonly isPopoutVisible: boolean; /** Whether the post-update release-notes dialog should be rendered open. */ readonly isDialogOpen: boolean; + /** Record that the card was genuinely visible, not merely mounted offscreen. */ + readonly markPopoutPresented: () => void; /** * Open the dialog in response to the user tapping the popout card. We don't * mark the update as seen here — acknowledging the card is not the same as @@ -56,7 +66,7 @@ export interface UseWhatsNewResult { /** * Dismiss the popout via its ✕ button. This marks the release as seen and * never re-prompts — even if the user never opens the dialog. Matches - * IndieDevs behaviour: hitting the X is a deliberate "I don't care". + * Hitting the X is a deliberate acknowledgement. */ readonly dismissPopout: () => void; /** @@ -79,8 +89,8 @@ export interface UseWhatsNewResult { * The dialog is never opened automatically — only via the popout card click. */ export function useWhatsNew(options?: { - readonly entries?: readonly WhatsNewEntry[]; - readonly currentVersion?: string; + readonly entries?: readonly WhatsNewEntry[] | undefined; + readonly currentVersion?: string | undefined; }): UseWhatsNewResult { const entries = options?.entries ?? WHATS_NEW_ENTRIES; const currentVersion = options?.currentVersion ?? APP_VERSION; @@ -100,7 +110,8 @@ export function useWhatsNew(options?: { resolveWhatsNewState({ entries, currentVersion, - lastSeenVersion: initialStorageRef.current.lastSeenVersion, + lastHandledVersion: + initialStorageRef.current.lastHandledVersion ?? initialStorageRef.current.lastSeenVersion, } satisfies WhatsNewInputs), [entries, currentVersion], ); @@ -114,7 +125,11 @@ export function useWhatsNew(options?: { // detected. Done in an effect so we only touch storage once per mount. useEffect(() => { if (initialState.kind === "silent-bootstrap") { - setStorage({ lastSeenVersion: initialState.nextLastSeenVersion }); + setStorage({ + lastSeenVersion: initialState.nextLastHandledVersion, + lastHandledVersion: initialState.nextLastHandledVersion, + lastPresentedVersion: initialStorageRef.current.lastPresentedVersion ?? null, + }); } }, [initialState, setStorage]); @@ -128,9 +143,24 @@ export function useWhatsNew(options?: { [initialState], ); + const markPresented = useCallback(() => { + if (initialState.kind === "show") { + setStorage((current) => ({ + ...current, + lastSeenVersion: initialState.nextLastHandledVersion, + lastHandledVersion: initialState.nextLastHandledVersion, + lastPresentedVersion: initialState.nextLastHandledVersion, + })); + } + }, [initialState, setStorage]); + const markSeen = useCallback(() => { if (initialState.kind === "show") { - setStorage({ lastSeenVersion: initialState.nextLastSeenVersion }); + setStorage({ + lastSeenVersion: initialState.nextLastHandledVersion, + lastHandledVersion: initialState.nextLastHandledVersion, + lastPresentedVersion: initialState.nextLastHandledVersion, + }); } }, [initialState, setStorage]); @@ -143,6 +173,7 @@ export function useWhatsNew(options?: { const dismissPopout = useCallback(() => { // X on the card: treat as "I've acknowledged this update". Mark as seen // and hide the popout forever (for this version). + focusActivityTrigger(); setIsPopoutVisible(false); markSeen(); }, [markSeen]); @@ -167,8 +198,13 @@ export function useWhatsNew(options?: { currentVersion, isPopoutVisible, isDialogOpen, + markPopoutPresented: markPresented, openDialog, dismissPopout, onDialogOpenChange, }; } + +function focusActivityTrigger() { + document.querySelector("[data-activity-center-trigger]")?.focus(); +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 9663536c8..0d95e4180 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -821,7 +821,7 @@ describe("wsNativeApi", () => { expect(requestMock).not.toHaveBeenCalled(); }); - it("keeps a blank fallback browser tab after closing the last tab", async () => { + it("closes the fallback browser after closing the last tab", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); const threadId = ThreadId.makeUnsafe("thread-1"); @@ -831,10 +831,9 @@ describe("wsNativeApi", () => { expect(tabId).toBeTruthy(); const nextState = await api.browser.closeTab({ threadId, tabId: tabId ?? "" }); - expect(nextState.open).toBe(true); - expect(nextState.tabs).toHaveLength(1); - expect(nextState.activeTabId).toBe(nextState.tabs[0]?.id); - expect(nextState.tabs[0]?.url).toBe("about:blank"); + expect(nextState.open).toBe(false); + expect(nextState.tabs).toEqual([]); + expect(nextState.activeTabId).toBeNull(); }); it("forwards context menu metadata to desktop bridge", async () => { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index eb1e98c4f..0f9a251c5 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -931,9 +931,8 @@ export function createWsNativeApi(): NativeApi { } state.tabs = nextTabs; if (nextTabs.length === 0) { - const replacementTab = createFallbackTab(); - state.tabs = [replacementTab]; - state.activeTabId = replacementTab.id; + state.open = false; + state.activeTabId = null; state.lastError = null; } else if (!state.tabs.some((tab) => tab.id === state.activeTabId)) { state.activeTabId = state.tabs[0]?.id ?? null; diff --git a/bun.lock b/bun.lock index 750c52fd7..929e9ae6d 100644 --- a/bun.lock +++ b/bun.lock @@ -223,6 +223,7 @@ "@synara/contracts": "workspace:*", "@synara/shared": "workspace:*", "effect": "catalog:", + "pngjs": "7.0.0", }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/design-qa.md b/design-qa.md index 408e4e597..eb07c1a29 100644 --- a/design-qa.md +++ b/design-qa.md @@ -4,6 +4,8 @@ Project-source intake adapted to Scient's existing command-dialog primitives and project-initialization flow. The intentionally supported sources are Local folder, Git URL, GitHub repository, and GitLab repository. +This follow-up adds a compact folder-drop affordance to the existing Local folder browser. It does not replace the dialog, hide the directory list, or change Scient's project-initialization policy. + ## Visual evidence - Reference source chooser: `docs/pr-screenshots/add-project-dialog/reference-source-chooser.png` @@ -12,9 +14,18 @@ Project-source intake adapted to Scient's existing command-dialog primitives and - Implemented source chooser: `docs/pr-screenshots/add-project-dialog/after-source-chooser.png` - Implemented path browser: `docs/pr-screenshots/add-project-dialog/after-path-browser.png` - Implemented persistent sidebar action: `docs/pr-screenshots/add-project-dialog/after-sidebar.png` +- User-approved resting row crop: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png` +- User-approved drag-over row crop: `docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png` +- Native Scient resting row: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png` +- Sanitized component-harness resting state: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png` +- Sanitized component-harness drag-over state: `docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png` +- Resting row comparison: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png` +- Drag-over row comparison: `docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png` The reference and implementation were compared together twice. Final measurements at the 1011 x 654 CSS-pixel viewport were 576 x 352 pixels for the source chooser and 576 x 420 pixels for the path browser. A 600 x 480 compact viewport was also checked with no horizontal overflow. +The new row was compared in both resting and drag-over states. The implementation row is 48 CSS pixels tall and its 36-pixel icon tile is vertically centered within one pixel. The native Electron capture confirms the resting row in the real Scient dialog. The sanitized component harness supplies the active-state comparison without recording local project names or paths; the browser harness darkens its whole modal surface, so color judgment for that state is based on the isolated user-approved row plus computed component colors, while behavior and geometry are proved by browser tests. + ## Findings resolved - Reduced the initial 672-pixel dialog to the reference's 576-pixel width. @@ -23,6 +34,10 @@ The reference and implementation were compared together twice. Final measurement - Removed the command input's default search icon where it overlapped the back arrow. - Preserved Enter navigation for the highlighted parent-directory row while retaining modifier-Enter submission for a highlighted folder. - Kept unsupported Azure DevOps and Bitbucket rows out of the production flow instead of exposing non-functional options. +- Kept the drop row outside the scrollable listbox, so folder names remain visible and listbox semantics stay valid. +- Kept the row white and compact at rest, with only the small folder-plus tile using Scient's blue accent. +- Changed only the tile and copy to green during a valid drag-over; the directory browser remains visible underneath. +- Limited the advertised drop affordance to Electron surfaces that expose the native path resolver, avoiding a dead control in ordinary browsers. ## Functional evidence @@ -33,16 +48,47 @@ The reference and implementation were compared together twice. Final measurement - A live local folder was added through the complete dialog-to-project-initialization journey. - Clone failures remove only the newly reserved destination and do not create or delete unrelated parent folders. - The final browser state has no current application errors; historical connection warnings occurred only while the isolated development server was intentionally stopped. +- The browser harness proves that any part of the open Local folder popup card captures a compatible single-folder drag, sets copy feedback, and changes the row copy to “Release to add this folder.” File drops that miss the card are neutrally prevented on the backdrop so Chromium cannot navigate away. +- When Electron supplies that compatible payload, the handler resolves the item through the native bridge and routes its absolute path through the same existing submission path as browse/manual selection. +- File drops, multiple-item drops, unreadable native paths, and terminal-whitespace paths fail closed with focused guidance. +- A synchronous single-flight guard prevents rapid duplicate drops or a simultaneous picker action from submitting the same project twice. +- The row remains visible after scrolling the directory list to the bottom, and its polite status announcement is outside the listbox. + +## Upstream lineage and disposition + +- Donor concept: Synara commit `3eb5b1088f3189513115e389bf3b19eaffc7e821`, `apps/web/src/components/CreateProjectDialog.tsx`. +- Disposition: **Adapt / Reimplement** the single-folder drag-and-drop concept on Scient's existing Local folder seam. +- Not imported: Synara's broader Spaces model, replacement dialog architecture, provider/runtime changes, or project lifecycle assumptions. +- Synara's full unreviewed range was not advanced by this bounded adaptation. `reviewedThrough` remains `3a5720bdd0ae4ace444379cabf0a634941d232fd`. ## Verification -- Full repository test suite: 12/12 tasks passed. +- Exact current-main integration tested product head: `ce42a2f90d97f2603c16c81a3ea8bfed76d97348` on base `ca615ee765e29c46a973ef3dd93543d1cccf7cb5`. +- The final evidence-only candidate and its complete repository/hosted verification are recorded in PR #115; this file does not attempt to self-reference the commit that contains it. +- Focused verification on the exact product head: folder-drop logic 11/11, Add Project Chromium behavior 22/22, web typecheck, and `git diff --check` passed. +- Full repository test suite on `9cf26b2b462610335988c2659c25e253a0386f84`: 12/12 tasks passed; the server suite reported 2207 passed and 7 skipped. +- Historical current-main integration `5bc3a84e726abc5327fd25a2d2562ac17bd0dd0a` on base `d399509d1835882c4e13b2b01820e649f31adbfd`: 12/12 tasks passed. +- Historical current-main integration `9dd30f221328510781bc84189e10ea2661138b6f` on base `4144022c2f1d127583b9a3a9a3ddfda635ffb10d`: 12/12 tasks passed. +- Full repository test suite on the previously certified feature head `e118d0a33ec65890f79a490d56bf1134e8b851e0`: 12/12 tasks passed. +- Historical context: the full suite on earlier post-merge feature head `3a8d5616cdc984609cc41dc0bd23552fb363f1ca` passed 11/12 tasks. The sole failure was `apps/server/src/localServerMonitor.platform.test.ts`, whose native macOS listener discovery returned no processes. The same isolated test failed identically on exact then-current `origin/main` `fffd9e7797b8df34d19ac2bb4439740fb29f11f6`; neither that test nor its implementation differed in the feature branch. That test passed in the exact `9dd30f221328510781bc84189e10ea2661138b6f` full-suite run. - Server project-source tests: 10/10 passed. -- Web focused unit tests: 32/32 passed. -- Add-project browser tests: 3/3 passed. +- Folder-drop logic tests: 11/11 passed. +- Add-project browser tests: 22/22 passed. +- Stable browser certification on `9cf26b2b462610335988c2659c25e253a0386f84`: 336 passed, 19 skipped across all four isolated groups. +- Geometry validation on `9cf26b2b462610335988c2659c25e253a0386f84`: 19 passed, 336 skipped. - Repository typecheck: 9/9 packages passed. - Repository lint: 0 errors (existing warnings remain outside this feature). -- Production web build, server bundle, and full serialized repository build passed. +- Brand and identity check passed. +- Production web build, desktop bundle, server bundle, and CLI bundle passed (5/5 tasks). +- Release smoke passed, including a native `node-pty` spawn. +- Native Electron browser-overlay lifecycle acceptance passed after the current-main merge, including 31.25 seconds held occluded and successful recovery. +- Current-main universal local-file viewer commit `0428e693417b031529befc2b7498a2635011becb` merged without overlap in the folder-drop implementation or its focused tests. +- The folder-plus tile opened the native macOS directory picker from the exact isolated build and used the same picker/submission action as the footer control. +- User visual review approved the row, icon, hierarchy, and drag-active presentation. +- Real Finder-to-Electron drag was explicitly waived by Yaacov as a residual acceptance limitation, + not a merge gate. Automated cross-window pointer drags did not produce renderer drag events, so + this record does not claim native drop proof. +- Sanitized evidence scan found no local user names, private paths, or embedded image text. - `git diff --check` passed. -final result: passed +final result: affected feature and intake gates passed on the current-main integration; the earlier platform-listener failure is retained only as historical context, and literal Finder drag delivery remains an explicitly accepted, unperformed limitation diff --git a/docs/manual-testing/html-preview/README.md b/docs/manual-testing/html-preview/README.md index 0e8266f7a..7626d9652 100644 --- a/docs/manual-testing/html-preview/README.md +++ b/docs/manual-testing/html-preview/README.md @@ -1,20 +1,19 @@ # HTML preview manual smoke test -These fixtures provide an obvious pass/fail check for the isolated HTML preview implementation. +These fixtures provide an obvious pass/fail check for the full local HTML browser. They contain no external network resources and are safe to inspect directly. ## Local desktop development -Turbo only forwards explicitly allowlisted environment variables. Start the normal desktop -development stack with the rollout switch set before the Bun command: +Start the normal desktop development stack: ```sh -SCIENT_EXECUTABLE_HTML_PREVIEW=1 bun run dev:desktop +bun run dev:desktop ``` -Then preview `interactive-preview.html`. The counter must increment, reset must work, and external -network access must remain unavailable. The `scripts/dev-runner.test.ts` regression test verifies -that Turbo forwards this rollout switch. +Then preview `interactive-preview.html`. The counter must increment and reset must work. Local +scripts, modules, workers, fetches, styles, images, and linked pages should load from the fixture +directory without a feature flag. ## Windows @@ -25,16 +24,8 @@ that Turbo forwards this rollout switch. 4. Select **Preview** for `static-preview.html`. 5. Confirm that the page says **Static preview passed** and that the four checks are green. -To test the isolated JavaScript path, start the test build from PowerShell with the rollout switch -enabled: - -```powershell -$env:SCIENT_EXECUTABLE_HTML_PREVIEW = "1" -& "$env:LOCALAPPDATA\Programs\Scient\Scient.exe" -``` - -Then preview `interactive-preview.html`. The counter must increment, reset must work, and external -network access must remain unavailable. +Then preview `interactive-preview.html`. The counter must increment and reset must work, and the +local module, fetch, worker, and linked-page checks must all report success. The Windows installer may be unsigned when it is produced by a build-only workflow. Windows SmartScreen can therefore require an explicit **More info** then **Run anyway** confirmation. Do diff --git a/docs/manual-testing/html-preview/fixture-pack/README.md b/docs/manual-testing/html-preview/fixture-pack/README.md new file mode 100644 index 000000000..f48f3d200 --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/README.md @@ -0,0 +1,18 @@ +# HTML preview fixtures + +Open these files through Scient to test its HTML and Markdown preview behavior. + +- `static-brochure.html`: static layout, local image, and table. +- `interactive-demo.html`: buttons, forms, modules, fetch, workers, and linked-page navigation. It should be fully interactive whenever the file is opened. +- `long-report.html`: scrolling, table, and code-block layout. +- `broken-page.html`: a missing local image; the preview should remain usable. +- `preview-links.md`: rendered Markdown and file links. + +The small supporting files beside the pages are intentional. They verify that a local HTML file can use the exact static assets, literal runtime dependencies, and linked pages it declares without turning its containing directory into a general-purpose file server. + +Safe-preview boundaries to verify: + +- Parent-directory references are reported and denied unless the user instead opens the actual site root through a confirmed development-server flow. +- Interactive local HTML can use its granted local graph but cannot navigate away, call local/private services, or send arbitrary network requests. +- Static documents may load only the exact public asset URLs declared in their markup; hostname resolution to a local/private address is denied. +- **Open original in default app** opens the file itself. Scient never exports its localhost capability URL to an unmanaged browser. diff --git a/docs/manual-testing/html-preview/fixture-pack/broken-page.html b/docs/manual-testing/html-preview/fixture-pack/broken-page.html new file mode 100644 index 000000000..a5a1a7799 --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/broken-page.html @@ -0,0 +1,27 @@ + + + + + Graceful failure fixture + + + +

Graceful failure fixture

+

+ This page intentionally references an image that does not exist. The text should still render + and the preview should not crash. +

+ Intentionally missing image +
Visible after the missing resource
+ + diff --git a/docs/manual-testing/html-preview/fixture-pack/fixture-data.json b/docs/manual-testing/html-preview/fixture-pack/fixture-data.json new file mode 100644 index 000000000..1e5bf0655 --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/fixture-data.json @@ -0,0 +1,3 @@ +{ + "status": "ready" +} diff --git a/docs/manual-testing/html-preview/fixture-pack/fixture-worker.js b/docs/manual-testing/html-preview/fixture-pack/fixture-worker.js new file mode 100644 index 000000000..7ebe7b01e --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/fixture-worker.js @@ -0,0 +1,3 @@ +self.addEventListener("message", () => { + self.postMessage("ready"); +}); diff --git a/docs/manual-testing/html-preview/fixture-pack/interactive-demo.html b/docs/manual-testing/html-preview/fixture-pack/interactive-demo.html new file mode 100644 index 000000000..86739a26a --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/interactive-demo.html @@ -0,0 +1,77 @@ + + + + + Interactive preview fixture + + + +

Interactive preview fixture

+

This page should be fully interactive whenever it is opened in Scient.

+

Count: 0

+ + + +

Validation

+
+ +
+

+

Local site capabilities

+
    +
  • Module: waiting
  • +
  • Fetch: waiting
  • +
  • Worker: waiting
  • +
+

Open the linked local page

+ + + + diff --git a/docs/manual-testing/html-preview/fixture-pack/interactive-module.js b/docs/manual-testing/html-preview/fixture-pack/interactive-module.js new file mode 100644 index 000000000..4b8099ff5 --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/interactive-module.js @@ -0,0 +1,17 @@ +document.querySelector("#moduleStatus").textContent = "Module: ready"; + +fetch("fixture-data.json") + .then((response) => response.json()) + .then((data) => { + document.querySelector("#fetchStatus").textContent = `Fetch: ${data.status}`; + }) + .catch(() => { + document.querySelector("#fetchStatus").textContent = "Fetch: failed"; + }); + +const worker = new Worker("fixture-worker.js", { type: "module" }); +worker.addEventListener("message", (event) => { + document.querySelector("#workerStatus").textContent = `Worker: ${event.data}`; + worker.terminate(); +}); +worker.postMessage("start"); diff --git a/docs/manual-testing/html-preview/fixture-pack/linked-page.html b/docs/manual-testing/html-preview/fixture-pack/linked-page.html new file mode 100644 index 000000000..527cc536e --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/linked-page.html @@ -0,0 +1,24 @@ + + + + + Linked local page + + + +

Linked local page

+

Success: navigation between local HTML files is working.

+

Return to the interactive demo

+ + diff --git a/docs/manual-testing/html-preview/fixture-pack/local-illustration.svg b/docs/manual-testing/html-preview/fixture-pack/local-illustration.svg new file mode 100644 index 000000000..a5dee84e6 --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/local-illustration.svg @@ -0,0 +1 @@ + diff --git a/docs/manual-testing/html-preview/fixture-pack/long-report.html b/docs/manual-testing/html-preview/fixture-pack/long-report.html new file mode 100644 index 000000000..35e8c61af --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/long-report.html @@ -0,0 +1,85 @@ + + + + + Long report fixture + + + +

Long report fixture

+

Use this page to check scrolling, long tables, and code blocks.

+

Summary

+

+ This deliberately has enough content to require a scrollable preview. Each section should stay + readable and the page should not be clipped. +

+

Data

+ + + + + + + + + + + + + + + + + + + + + +
WeekValue
114
222
319
431
+

Example code

+
const preview = { mode: 'static', status: 'expected to scroll' };
+

Discussion

+

+ Scroll through this paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed + posuere, metus in convallis sollicitudin, ante massa finibus turpis, in semper nisl ante sit + amet eros. +

+

+ More long-form content: a preview should preserve readable margins, text contrast, table + width, and code-block overflow. +

+

Conclusion

+

You reached the end of the page.

+ + diff --git a/docs/manual-testing/html-preview/fixture-pack/preview-links.md b/docs/manual-testing/html-preview/fixture-pack/preview-links.md new file mode 100644 index 000000000..153dab9c3 --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/preview-links.md @@ -0,0 +1,10 @@ +# Preview test links + +Open each linked file through Scient and inspect the expected behavior. + +- [Static brochure](static-brochure.html) +- [Interactive demo](interactive-demo.html) +- [Long report](long-report.html) +- [Graceful failure page](broken-page.html) + +This Markdown page itself should open as rendered Markdown rather than raw source. diff --git a/docs/manual-testing/html-preview/fixture-pack/static-brochure.html b/docs/manual-testing/html-preview/fixture-pack/static-brochure.html new file mode 100644 index 000000000..b9a9e9a6f --- /dev/null +++ b/docs/manual-testing/html-preview/fixture-pack/static-brochure.html @@ -0,0 +1,95 @@ + + + + + Static brochure fixture + + + +
+

Northwind Field Notes

+

A static HTML preview fixture

+
+
+
+

What this checks

+

+ Typography, color, layout, a local image, and a table should all render without any + interaction. +

+
+
+ Abstract blue and green landscape illustration +
+
+

Sample milestones

+ + + + + + + + + + + + + + + + + + + + + +
StageStatus
DiscoveryComplete
PrototypeReady
ReviewScheduled
+
+
+ + diff --git a/docs/manual-testing/html-preview/interactive-preview.html b/docs/manual-testing/html-preview/interactive-preview.html index 1d23689ae..b941e11f1 100644 --- a/docs/manual-testing/html-preview/interactive-preview.html +++ b/docs/manual-testing/html-preview/interactive-preview.html @@ -93,9 +93,9 @@
-
Scient · isolated execution test
+
Scient · local HTML browser test

Interactive preview passed

-

The counter below is driven by inline JavaScript inside the isolated artifact session.

+

The counter below is driven by inline JavaScript inside the local HTML browser.

0
JavaScript initialized inside the preview origin.
@@ -118,7 +118,7 @@

Interactive preview passed

reset.addEventListener("click", () => { value = 0; render(); - status.textContent = "Reset worked. The isolated page remains interactive."; + status.textContent = "Reset worked. The local page remains interactive."; }); window.addEventListener("pagehide", () => window.clearInterval(timer), { once: true }); diff --git a/docs/manual-testing/html-preview/static-preview.html b/docs/manual-testing/html-preview/static-preview.html index 87a1047b5..315085f01 100644 --- a/docs/manual-testing/html-preview/static-preview.html +++ b/docs/manual-testing/html-preview/static-preview.html @@ -99,8 +99,8 @@
Scient · Windows smoke test

Static preview passed

- If this card is visible inside Scient instead of a black page, the inert HTML preview origin - loaded successfully. + If this card is visible inside Scient instead of a black page, the local HTML browser loaded + successfully.

  • HTML document rendered
  • diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png b/docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png new file mode 100644 index 000000000..ef307e481 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png b/docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png new file mode 100644 index 000000000..54d91ca4b Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png b/docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png new file mode 100644 index 000000000..dec913b08 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png new file mode 100644 index 000000000..f135e71f6 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png new file mode 100644 index 000000000..00e19bbb5 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png new file mode 100644 index 000000000..89edbd22a Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png new file mode 100644 index 000000000..58340dc37 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png differ diff --git a/docs/pr-screenshots/release-notes/design-qa.md b/docs/pr-screenshots/release-notes/design-qa.md new file mode 100644 index 000000000..63420eaa4 --- /dev/null +++ b/docs/pr-screenshots/release-notes/design-qa.md @@ -0,0 +1,89 @@ +# Scient release-note visual and manual evidence + +Captured on 2026-07-24 from the PR branch after merging `origin/main` at +`e6af16d98c503c2b15ba2dff55837a4179a780a0`. The isolated evidence page mounted +the production `WhatsNewProvider`, `WhatsNewSidebarCard`, `SidebarFooterControls`, +`ActivityCenter`, mobile `Sidebar`, and `WhatsNewDialog` components. The page was +removed after capture and is not part of the product diff. + +## 1. Desktop card and notification placement + +Viewport: 1200 x 800. The 208px sidebar keeps the compact card directly above +Activity and Settings without covering either control or overflowing the footer. + +![Desktop release-note card above Activity and Settings](desktop-card.jpg) + +## 2. Full release note + +Starting state: the card was visible after a simulated upgrade from 1.2.2 to +1.2.3. Action: activate the card. Observed: a solid, labelled dialog opened only +after the click; focus moved to the dialog heading; the three concise user +benefits and both footer actions remained visible. + +![Full Scient release-note dialog](dialog-open.jpg) + +## 3. Dismissal and recovery + +Action: select **Done**. Observed: the dialog and one-time card disappeared and +keyboard focus returned to Activity, which retained a visible focus outline. + +![Dismissed release note with focus returned to Activity](dialog-dismissed-focus-return.jpg) + +## 4. Mobile and narrow layout + +Viewport: 390 x 844. Starting state: the real mobile sidebar Sheet was closed, +so the offscreen card was not consumed. Action: open the sidebar. Observed: the +card filled the Sheet width without horizontal clipping and retained the same +card, Activity, Settings hierarchy. + +![Release-note card in the mobile sidebar](mobile-sidebar.jpg) + +Action: activate the card from inside the Sheet. Observed after the transition +settled: the release dialog was the topmost modal; focus moved to its heading; +all three standard-release highlights were readable; and the header and actions +remained inside the viewport. Automated acceptance separately closes this nested +path with both Escape and **Done**, verifies that only the topmost dialog closes, +and confirms coherent focus recovery to Activity. + +![Full release-note dialog opened from the mobile sidebar](mobile-dialog.jpg) + +## 5. Short-height layout + +Viewport: 900 x 360. The card, Activity state, and Settings remained visible and +usable together without footer scrolling or overlap. + +![Release-note card in a short-height desktop layout](short-height.jpg) + +Action: activate the card. Observed: the full three-highlight note stayed within +the viewport; its header and footer remained fixed and reachable while the +middle content panel supplied the bounded scrolling region. The geometry suite +asserts the popup bounds, header/footer containment, and actual panel scrolling. + +![Full release-note dialog in a short-height viewport](short-height-dialog.jpg) + +## 6. Reduced motion + +A still image cannot prove motion timing. The focused browser acceptance test +emulates `prefers-reduced-motion: reduce`, opens the dialog through the real card, +and asserts that both the popup and backdrop have a computed transition duration +of `0s`; it then dismisses the dialog and verifies removal. This is repeated in +the full stable browser suite. + +## 7. Failure states + +The release note uses local catalog data, so it has no runtime loading/network +failure state. Its relevant failure boundary is the release preflight. Manual CLI +proof exercises the exact release command with an unexpected unreferenced leaf in +`apps/web/public/release-notes`; the command fails closed before a native build. +Focused regression tests additionally reject corrupted and truncated PNG data, +unreferenced non-PNG/undecodable files, file and directory symlinks, and a FIFO. + +## Evidence limits + +These captures prove the release-note components, placement, responsive reflow, +nested-dialog layering, focus result, and visible states in the browser +implementation. Browser console warning/error logs were empty during the added +mobile and short-height dialog captures. They are not a +packaged-updater acceptance test and do not claim that a real release note ships: +the production catalog remains intentionally empty until a release owner approves +the exact release entry. diff --git a/docs/pr-screenshots/release-notes/desktop-card.jpg b/docs/pr-screenshots/release-notes/desktop-card.jpg new file mode 100644 index 000000000..71c1649af Binary files /dev/null and b/docs/pr-screenshots/release-notes/desktop-card.jpg differ diff --git a/docs/pr-screenshots/release-notes/dialog-dismissed-focus-return.jpg b/docs/pr-screenshots/release-notes/dialog-dismissed-focus-return.jpg new file mode 100644 index 000000000..61e65a769 Binary files /dev/null and b/docs/pr-screenshots/release-notes/dialog-dismissed-focus-return.jpg differ diff --git a/docs/pr-screenshots/release-notes/dialog-open.jpg b/docs/pr-screenshots/release-notes/dialog-open.jpg new file mode 100644 index 000000000..dd93f863b Binary files /dev/null and b/docs/pr-screenshots/release-notes/dialog-open.jpg differ diff --git a/docs/pr-screenshots/release-notes/mobile-dialog.jpg b/docs/pr-screenshots/release-notes/mobile-dialog.jpg new file mode 100644 index 000000000..4ebc6fef8 Binary files /dev/null and b/docs/pr-screenshots/release-notes/mobile-dialog.jpg differ diff --git a/docs/pr-screenshots/release-notes/mobile-sidebar.jpg b/docs/pr-screenshots/release-notes/mobile-sidebar.jpg new file mode 100644 index 000000000..7fb8d1c9b Binary files /dev/null and b/docs/pr-screenshots/release-notes/mobile-sidebar.jpg differ diff --git a/docs/pr-screenshots/release-notes/short-height-dialog.jpg b/docs/pr-screenshots/release-notes/short-height-dialog.jpg new file mode 100644 index 000000000..1aacc6b46 Binary files /dev/null and b/docs/pr-screenshots/release-notes/short-height-dialog.jpg differ diff --git a/docs/pr-screenshots/release-notes/short-height.jpg b/docs/pr-screenshots/release-notes/short-height.jpg new file mode 100644 index 000000000..de26d67a1 Binary files /dev/null and b/docs/pr-screenshots/release-notes/short-height.jpg differ diff --git a/docs/pr-screenshots/upstream-workflow-ux/after-native-context-cancel.png b/docs/pr-screenshots/upstream-workflow-ux/after-native-context-cancel.png new file mode 100644 index 000000000..5c78ce37e Binary files /dev/null and b/docs/pr-screenshots/upstream-workflow-ux/after-native-context-cancel.png differ diff --git a/docs/pr-screenshots/upstream-workflow-ux/after-native-context-copy-success.png b/docs/pr-screenshots/upstream-workflow-ux/after-native-context-copy-success.png new file mode 100644 index 000000000..37bf168e2 Binary files /dev/null and b/docs/pr-screenshots/upstream-workflow-ux/after-native-context-copy-success.png differ diff --git a/docs/pr-screenshots/upstream-workflow-ux/before-native-context-copy-failure.png b/docs/pr-screenshots/upstream-workflow-ux/before-native-context-copy-failure.png new file mode 100644 index 000000000..5adda9f06 Binary files /dev/null and b/docs/pr-screenshots/upstream-workflow-ux/before-native-context-copy-failure.png differ diff --git a/docs/pr-screenshots/upstream-workflow-ux/design-qa.md b/docs/pr-screenshots/upstream-workflow-ux/design-qa.md new file mode 100644 index 000000000..6af5b2ca7 --- /dev/null +++ b/docs/pr-screenshots/upstream-workflow-ux/design-qa.md @@ -0,0 +1,56 @@ +# Workflow UX native acceptance + +## Environment + +- macOS arm64, Scient (Dev), current PR source after merging `origin/main` +- Isolated backend home: `/tmp/scient-pr111-native-home-2` +- Isolated Electron profile under `/tmp/scient-pr111-electron-appdata` +- Project: this PR's clean Git worktree +- Starting branch: `maintenance/upstream-workflow-ux-20260724` + +## Native branch-copy acceptance + +1. Opened the branch picker in a new local chat. +2. Right-clicked the listed `main` branch and selected Electron's native + **Copy branch name** menu item. +3. Before the native text bridge fix, the renderer showed **Failed to copy + branch name** and `pbpaste` retained the previous branch name. See + `before-native-context-copy-failure.png`. +4. After the fix, the picker reported **Branch name copied**, `pbpaste` + returned exactly `main`, and `git rev-parse --abbrev-ref HEAD` still returned + `maintenance/upstream-workflow-ux-20260724`. See + `after-native-context-copy-success.png`. +5. Right-clicked another listed branch and cancelled the native menu. The + clipboard remained `main`, the active branch remained unchanged, and no + error notification appeared. See `after-native-context-cancel.png`. +6. Reopened the picker, filtered to `main`, and used keyboard navigation. Two + Tab presses moved accessibility focus from the filter to the branch list and + then to `Copy branch name: main`; Enter invoked the same native bridge path. + Accessibility reported `Branch name copied`, `pbpaste` returned exactly + `main`, and the Git branch and status were still unchanged. + +No branch checkout, branch creation, thread workspace mutation, repository +write, or provider turn occurred during this acceptance pass. + +## Representative state proof + +The native Electron app was also exercised against fixture records written only +to the isolated `/tmp` state database. A renderer reload projected those records +through the normal desktop server snapshot; no production profile or user data +was read or changed. + +- The real sidebar announced three status labels: `Working`, `Pending +Approval`, and `Completed`. Holding Command displayed the compact `⌘1`, + `⌘2`, and `⌘3` jump hints in those same native rows. +- The real Automations route grouped the fixture records under **Needs + review**, **Current**, and **Paused**. Its accessible rows announced + `Running`, `Waiting for approval`, `Last run failed`, `New result`, `Paused`, + and the resolved enabled automation's ordinary `Hourly` cadence. The route + and sidebar badge both reported three automations needing attention. + +Focused browser coverage independently renders and checks `Running`, `Waiting +for approval`, `Last run failed`, `New result`, `Paused`, and `Every 1h`. +Sidebar browser and geometry coverage renders shortcut hints beside `Working`, +`Pending Approval`, and `Completed`, including compact macOS and wide `Ctrl` +layouts. These fixtures exercise the exact extracted row and indicator +components used by the product. diff --git a/docs/pr-screenshots/voice-cancel-trash-icon/after.png b/docs/pr-screenshots/voice-cancel-trash-icon/after.png new file mode 100644 index 000000000..dd7236a1c Binary files /dev/null and b/docs/pr-screenshots/voice-cancel-trash-icon/after.png differ diff --git a/docs/pr-screenshots/voice-cancel-trash-icon/before.png b/docs/pr-screenshots/voice-cancel-trash-icon/before.png new file mode 100644 index 000000000..b28b335df Binary files /dev/null and b/docs/pr-screenshots/voice-cancel-trash-icon/before.png differ diff --git a/docs/pr-screenshots/voice-cancel-trash-icon/design-qa.md b/docs/pr-screenshots/voice-cancel-trash-icon/design-qa.md new file mode 100644 index 000000000..19844a49c --- /dev/null +++ b/docs/pr-screenshots/voice-cancel-trash-icon/design-qa.md @@ -0,0 +1,31 @@ +# Voice cancel trash icon design QA + +## Scope + +Replace only the active voice recorder's circular `X` cancel treatment with a small standalone trash icon. Preserve the composer layout, recorder waveform, duration, stop/insert action, send action, click target, labels, and callbacks. + +## Visual evidence + +- Before: `docs/pr-screenshots/voice-cancel-trash-icon/before.png` +- Browser-rendered implementation: `docs/pr-screenshots/voice-cancel-trash-icon/after.png` + +The approved visual and browser-rendered component were compared together twice. The implementation keeps the 26-to-28-pixel cancel hit target while rendering only a 15-pixel `trash-can-simple` glyph, with no circular fill, border, or hover background. + +## Functional evidence + +- The cancel action retains its recording and transcription-specific accessible labels. +- Clicking cancel still invokes the caller-owned cancel callback. +- Stop-and-insert and send remain independent and unchanged. +- Recording and transcription states retain their existing disabled and loading behavior. + +## Verification + +- Focused component unit tests: 4/4 passed. +- Focused Chromium browser tests: 4/4 passed on an isolated test port. +- Browser-rendered visual QA at an 804 x 440 CSS-pixel viewport passed. +- Repository formatting, lint, and typecheck passed; lint reports only pre-existing warnings. +- Full repository test suite: 12/12 tasks passed. +- Desktop and bundled web build: 5/5 tasks passed. +- `git diff --check` passed. + +final result: passed diff --git a/docs/release.md b/docs/release.md index 908a6ec0a..3cfa04d9e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -111,6 +111,55 @@ Checklist: - `SCIENT_PUBLISH_CLI=1` - `SCIENT_FINALIZE_RELEASE=1` +## Prepare the in-app release note + +Every release candidate must include one Scient-owned, user-friendly entry in +`apps/web/src/whatsNew/entries.json` whose version exactly matches the candidate. +The release workflow fails before native builds when that entry is absent, +malformed, duplicated, or names a different stable or prerelease version. +The automated gate verifies the catalog structure, exact version, highlight +count, and every file in the bundled release-note asset tree. It deliberately +does not attempt to judge tone, technical language, or claim accuracy; the +product-owner approval below is the mandatory editorial authority. + +The release owner prepares and approves the entry before promotion: + +1. List only improvements that are present and verified in the exact candidate. +2. Write a short, benefit-led headline for the small sidebar card. +3. Add three to five highlights. A focused hotfix may use one or two only when + the entry explicitly sets `"kind": "hotfix"`. Explain what became easier, + clearer, safer, or more reliable for the user. +4. Keep the language warm, concise, and nontechnical. Do not mention commits, + pull requests, frameworks, internal components, protocols, migrations, or + implementation details. +5. Add only Scient-owned, non-interlaced PNG artwork under + `apps/web/public/release-notes/`. The release gate decodes every PNG in that + tree, including unreferenced files, and rejects interlaced images, oversized + dimensions, files, palettes or chunk tables, symlinks, special files, and + non-PNG leaves before Vite can stage them. Use an exact `/release-notes/...` + bundled path. Feature images need useful alt text; the compact card hero is + decorative. Verify artwork at normal, minimum, mobile, and short-height layouts. +6. Have the product owner approve the claims, order, and wording. This human + review is the authority for tone and accuracy; automated checks cannot infer it. +7. Run `bun run release:notes:check -- X.Y.Z` and commit the approved entry with + the candidate before opening the promotion PR. + +Acceptance on a packaged upgrade must prove all of the following: + +- A clean first installation stays quiet. +- After upgrading, the small branded card appears inline above Activity and + Settings only once for that release; it never opens the larger dialog by itself. +- A hidden desktop sidebar or closed mobile drawer does not consume the one-time + presentation. The marker advances only when the card is genuinely visible. +- The card never overlaps or blocks Activity, Settings, or the update control. +- Opening it shows the matching version, date, approved highlights, and release + history. Keyboard focus, Escape, close, reduced motion, and screen-reader labels work. +- Dismissing or ignoring the visible card prevents it from returning on the next launch. +- The previous packaged release can update to the candidate and see this exact note. + +Release history remains available from both the release-note dialog and +Settings. + ## 1) Promote and validate a release candidate Use this before publication to validate the exact protected-branch commit on the diff --git a/package.json b/package.json index 991a4af3c..29482486e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "brand:check": "node scripts/check-brand-identity.ts", "workflow:check": "bun scripts/check-workflow-actions.ts", "test:desktop-smoke": "turbo run smoke-test --filter=@synara/desktop", + "test:desktop-browser-overlay-lifecycle": "bun run --cwd apps/desktop test:browser-overlay-lifecycle", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "build:contracts": "turbo run build --filter=@synara/contracts", @@ -49,6 +50,7 @@ "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", + "release:notes:check": "node scripts/verify-release-notes.ts", "scient:upstream-check": "bun scripts/scient-upstream-check.ts", "release:smoke:mac-update": "node scripts/smoke-mac-update-artifact.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules scripts/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo" diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ef187c78a..ae9d9a6b4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -298,7 +298,7 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } -export type BrowserTabKind = "web" | "artifact" | "local-app"; +export type BrowserTabKind = "web" | "artifact" | "local-app" | "local-html"; export interface BrowserTabState { id: string; @@ -313,6 +313,7 @@ export interface BrowserTabState { faviconUrl: string | null; lastCommittedUrl: string | null; lastError: string | null; + allowedExternalUrls?: readonly string[]; } export interface ThreadBrowserState { @@ -329,6 +330,8 @@ export interface BrowserOpenInput { initialUrl?: string; kind?: BrowserTabKind; displayUrl?: string; + /** Exact public subresource URLs declared by a capability-backed local HTML document. */ + allowedExternalUrls?: readonly string[]; } export interface BrowserThreadInput { @@ -352,6 +355,7 @@ export interface BrowserNewTabInput { kind?: BrowserTabKind; displayUrl?: string; activate?: boolean; + allowedExternalUrls?: readonly string[]; } export interface BrowserPanelBounds { @@ -365,6 +369,7 @@ export interface BrowserSetPanelBoundsInput { threadId: ThreadId; bounds: BrowserPanelBounds | null; surface?: "native" | "renderer"; + occluded?: boolean; } export interface BrowserAttachWebviewInput extends BrowserTabInput { @@ -491,6 +496,7 @@ export interface DesktopBridge { openLogsDirectory: () => Promise; }; clipboard?: { + writeText: (text: string) => Promise; writeImagePngDataUrl: (dataUrl: string) => Promise; }; windowControls?: { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index e2d68a58c..e830be9d7 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -183,6 +183,7 @@ export type ProjectHtmlArtifactMode = typeof ProjectHtmlArtifactMode.Type; export const ProjectHtmlArtifactWarningCode = Schema.Literals([ "external-resource-blocked", + "local-resource-denied", "missing-local-resource", "unsupported-local-resource", "inspection-truncated", @@ -217,13 +218,19 @@ export const ProjectInspectHtmlArtifactResult = Schema.Struct({ }); export type ProjectInspectHtmlArtifactResult = typeof ProjectInspectHtmlArtifactResult.Type; -export const ProjectPrepareHtmlArtifactPreviewInput = ProjectInspectHtmlArtifactInput; +export const ProjectPrepareHtmlArtifactPreviewInput = Schema.Struct({ + ...ProjectInspectHtmlArtifactInput.fields, + thumbnail: Schema.optional(Schema.Boolean), +}); export type ProjectPrepareHtmlArtifactPreviewInput = typeof ProjectPrepareHtmlArtifactPreviewInput.Type; export const ProjectPrepareHtmlArtifactPreviewResult = Schema.Struct({ ...ProjectInspectHtmlArtifactResult.fields, previewUrl: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(8_192))), + allowedExternalUrls: Schema.optional( + Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(8_192))), + ), expiresAt: Schema.optional(TrimmedNonEmptyString), }); export type ProjectPrepareHtmlArtifactPreviewResult = diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 41de62b10..b8d7d222b 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -5,6 +5,7 @@ import { ServerProviderConnectionCancelInput, ServerProviderConnectionStartInput, ServerProviderConnectionSubmitAuthorizationCodeInput, + ServerProviderInstallInput, ServerProviderStatus, ServerVoiceTranscriptionInput, ServerVoiceTranscriptionResult, @@ -17,6 +18,10 @@ describe("provider connection contracts", () => { provider: "codex", method: "codex_browser", }); + expect(decode({ provider: "codex", method: "codex_device_code" })).toEqual({ + provider: "codex", + method: "codex_device_code", + }); expect(decode({ provider: "codex", method: "codex_browser", mode: "reauthenticate" })).toEqual({ provider: "codex", method: "codex_browser", @@ -40,6 +45,20 @@ describe("provider connection contracts", () => { }); }); + it("decodes an explicit install-to-sign-in handoff", () => { + expect( + Schema.decodeUnknownSync(ServerProviderInstallInput)({ + provider: "codex", + planToken: "plan-1", + connectionMethod: "codex_browser", + }), + ).toEqual({ + provider: "codex", + planToken: "plan-1", + connectionMethod: "codex_browser", + }); + }); + it("rejects unknown connection methods and blank operation ids", () => { expect(() => Schema.decodeUnknownSync(ServerProviderConnectionStartInput)({ @@ -119,11 +138,13 @@ describe("provider connection contracts", () => { message: "Finish signing in in the browser.", authorizationUrl: "https://auth.x.ai/oauth2/authorize?response_type=code&state=transient-test-state", + userCode: "ABCD-EFGH", }, }); expect(decoded.connectionState?.status).toBe("waiting_for_browser"); expect(decoded.connectionState?.authorizationUrl).toContain("https://auth.x.ai/"); + expect(decoded.connectionState?.userCode).toBe("ABCD-EFGH"); expect(Object.keys(decoded.connectionState ?? {})).not.toContain("token"); expect(Object.keys(decoded.connectionState ?? {})).not.toContain("output"); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 157d44961..98f0edf93 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -94,6 +94,7 @@ export type ServerProviderRuntimeState = typeof ServerProviderRuntimeState.Type; export const ServerProviderConnectionMethod = Schema.Literals([ "codex_browser", + "codex_device_code", "claude_account", "claude_sso", "claude_console", @@ -122,6 +123,9 @@ export const ServerProviderConnectionState = Schema.Struct({ finishedAt: Schema.NullOr(IsoDateTime), message: TrimmedNonEmptyString, authorizationUrl: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(8_192))), + userCode: Schema.optionalKey( + TrimmedNonEmptyString.check(Schema.isMaxLength(64), Schema.isPattern(/^[A-Za-z0-9-]+$/)), + ), }); export type ServerProviderConnectionState = typeof ServerProviderConnectionState.Type; @@ -550,6 +554,7 @@ export type ServerProviderUpdateResult = typeof ServerProviderUpdateResult.Type; export const ServerProviderInstallInput = Schema.Struct({ provider: ProviderKind, planToken: TrimmedNonEmptyString, + connectionMethod: Schema.optional(ServerProviderConnectionMethod), }); export type ServerProviderInstallInput = typeof ServerProviderInstallInput.Type; diff --git a/packages/shared/src/browserSession.test.ts b/packages/shared/src/browserSession.test.ts index 263373901..732384869 100644 --- a/packages/shared/src/browserSession.test.ts +++ b/packages/shared/src/browserSession.test.ts @@ -4,6 +4,7 @@ import { BROWSER_SEARCH_URL_PREFIX, buildAcceptLanguageHeader, buildChromeClientHints, + browserSessionPartition, chromeMajorVersionFromUserAgent, classifyBrowserWindowOpen, deriveChromeUserAgent, @@ -16,6 +17,32 @@ import { const ELECTRON_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Synara/0.3.1 Chrome/124.0.6367.91 Electron/30.0.1 Safari/537.36"; +describe("browserSessionPartition", () => { + it("keeps executable local HTML ephemeral and isolated per tab", () => { + expect(browserSessionPartition("local-html", "thread-1", "tab-1")).toBe( + "scient-local-html-preview-thread-1-tab-1", + ); + expect(browserSessionPartition("local-html", "thread-1", "tab-2")).not.toBe( + browserSessionPartition("local-html", "thread-1", "tab-1"), + ); + }); + + it("keeps trusted local apps in the thread-local persistent browser session", () => { + expect(browserSessionPartition("local-app", "thread-1", "tab-2")).toBe( + "persist:scient-local-preview-thread-1", + ); + }); + + it("keeps static artifact previews ephemeral and tab-isolated", () => { + expect(browserSessionPartition("artifact", "thread-1", "tab-1")).toBe( + "scient-artifact-preview-thread-1-tab-1", + ); + expect(browserSessionPartition("artifact", "thread-1", "tab-2")).not.toBe( + browserSessionPartition("artifact", "thread-1", "tab-1"), + ); + }); +}); + describe("deriveChromeUserAgent", () => { it("strips Electron and app product tokens to leave a vanilla Chrome UA", () => { expect(deriveChromeUserAgent(ELECTRON_UA, ["Synara"])).toBe( @@ -88,7 +115,10 @@ describe("resolveCopyableBrowserTabUrl", () => { it("prefers a non-blank live url over cached tab urls", () => { expect( resolveCopyableBrowserTabUrl( - { url: "https://current.example/", lastCommittedUrl: "https://committed.example/" }, + { + url: "https://current.example/", + lastCommittedUrl: "https://committed.example/", + }, "https://live.example/", ), ).toBe("https://live.example/"); @@ -101,7 +131,12 @@ describe("resolveCopyableBrowserTabUrl", () => { lastCommittedUrl: "about:blank", }), ).toBe("https://current.example/"); - expect(resolveCopyableBrowserTabUrl({ url: "about:blank", lastCommittedUrl: null })).toBeNull(); + expect( + resolveCopyableBrowserTabUrl({ + url: "about:blank", + lastCommittedUrl: null, + }), + ).toBeNull(); }); }); diff --git a/packages/shared/src/browserSession.ts b/packages/shared/src/browserSession.ts index b1754536f..2346499e3 100644 --- a/packages/shared/src/browserSession.ts +++ b/packages/shared/src/browserSession.ts @@ -13,7 +13,7 @@ export const BROWSER_WEB_SESSION_PARTITION = "persist:scient-browser"; export const BROWSER_ARTIFACT_SESSION_PARTITION = "scient-artifact-preview"; export function browserSessionPartition( - kind: "web" | "artifact" | "local-app", + kind: "web" | "artifact" | "local-app" | "local-html", threadId: string, tabId?: string, ): string { @@ -22,6 +22,8 @@ export function browserSessionPartition( return `${BROWSER_ARTIFACT_SESSION_PARTITION}-${threadId}-${tabId ?? "pending"}`; case "local-app": return `persist:scient-local-preview-${threadId}`; + case "local-html": + return `scient-local-html-preview-${threadId}-${tabId ?? "pending"}`; case "web": return BROWSER_WEB_SESSION_PARTITION; } diff --git a/packages/shared/src/localPreviewFiles.test.ts b/packages/shared/src/localPreviewFiles.test.ts index 65da30b60..d3534d67e 100644 --- a/packages/shared/src/localPreviewFiles.test.ts +++ b/packages/shared/src/localPreviewFiles.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { isSupportedLocalHtmlPath } from "./localPreviewFiles"; +import { + isSupportedLocalAudioPath, + isSupportedLocalHtmlPath, + isSupportedLocalPreviewFilePath, + isSupportedLocalVideoPath, + localFileViewerKindForPath, +} from "./localPreviewFiles"; describe("isSupportedLocalHtmlPath", () => { it.each(["lesson.html", "lesson.HTM", "/study/topics/heart.failure.html"])( @@ -17,3 +23,39 @@ describe("isSupportedLocalHtmlPath", () => { }, ); }); + +describe("localFileViewerKindForPath", () => { + it.each([ + ["README.md", "markdown"], + ["diagram.svg", "svg"], + ["photo.png", "image"], + ["paper.pdf", "pdf"], + ["report.html", "html"], + ["recording.wav", "audio"], + ["experiment.webm", "video"], + ["analysis.py", "source"], + ["archive.bin", "source"], + ] as const)("routes %s to %s", (path, kind) => { + expect(localFileViewerKindForPath(path)).toBe(kind); + }); +}); + +describe("browser-native media previews", () => { + it.each(["talk.mp3", "voice.M4A", "recording.flac", "sound.ogg"])( + "recognizes audio: %s", + (path) => { + expect(isSupportedLocalAudioPath(path)).toBe(true); + expect(isSupportedLocalPreviewFilePath(path)).toBe(true); + }, + ); + + it.each(["demo.mp4", "clip.WEBM", "movie.mov"])("recognizes video: %s", (path) => { + expect(isSupportedLocalVideoPath(path)).toBe(true); + expect(isSupportedLocalPreviewFilePath(path)).toBe(true); + }); + + it("does not misclassify source files as media", () => { + expect(isSupportedLocalAudioPath("audio.ts")).toBe(false); + expect(isSupportedLocalVideoPath("video.json")).toBe(false); + }); +}); diff --git a/packages/shared/src/localPreviewFiles.ts b/packages/shared/src/localPreviewFiles.ts index ae208a23c..78bfa1a65 100644 --- a/packages/shared/src/localPreviewFiles.ts +++ b/packages/shared/src/localPreviewFiles.ts @@ -48,6 +48,40 @@ export function isSupportedLocalPdfPath(filePath: string): boolean { return lowerCaseExtensionOf(filePath) === SUPPORTED_LOCAL_PDF_EXTENSION; } +export const SUPPORTED_LOCAL_AUDIO_EXTENSIONS = [ + ".aac", + ".flac", + ".m4a", + ".mp3", + ".oga", + ".ogg", + ".opus", + ".wav", +] as const; + +export const SUPPORTED_LOCAL_VIDEO_EXTENSIONS = [".m4v", ".mov", ".mp4", ".ogv", ".webm"] as const; + +const SUPPORTED_LOCAL_AUDIO_EXTENSIONS_SET: ReadonlySet = new Set( + SUPPORTED_LOCAL_AUDIO_EXTENSIONS, +); +const SUPPORTED_LOCAL_VIDEO_EXTENSIONS_SET: ReadonlySet = new Set( + SUPPORTED_LOCAL_VIDEO_EXTENSIONS, +); + +export function isSupportedLocalAudioPath(filePath: string): boolean { + const extension = lowerCaseExtensionOf(filePath); + return extension !== null && SUPPORTED_LOCAL_AUDIO_EXTENSIONS_SET.has(extension); +} + +export function isSupportedLocalVideoPath(filePath: string): boolean { + const extension = lowerCaseExtensionOf(filePath); + return extension !== null && SUPPORTED_LOCAL_VIDEO_EXTENSIONS_SET.has(extension); +} + +export function isSupportedLocalMediaPath(filePath: string): boolean { + return isSupportedLocalAudioPath(filePath) || isSupportedLocalVideoPath(filePath); +} + const SUPPORTED_LOCAL_HTML_EXTENSIONS: ReadonlySet = new Set([".htm", ".html"]); export function isSupportedLocalHtmlPath(filePath: string): boolean { @@ -55,11 +89,44 @@ export function isSupportedLocalHtmlPath(filePath: string): boolean { return extension !== null && SUPPORTED_LOCAL_HTML_EXTENSIONS.has(extension); } +export type LocalFileViewerKind = + | "markdown" + | "source" + | "image" + | "svg" + | "pdf" + | "html" + | "audio" + | "video"; + +const MARKDOWN_EXTENSIONS: ReadonlySet = new Set([".markdown", ".md", ".mdx"]); + +/** + * The single routing decision used by click-to-open surfaces and the file + * preview itself. `source` is deliberately the fallback: the reader will show + * ordinary text and replace binary-read failures with a useful file-info view. + */ +export function localFileViewerKindForPath(filePath: string): LocalFileViewerKind { + const extension = lowerCaseExtensionOf(filePath); + if (extension !== null && MARKDOWN_EXTENSIONS.has(extension)) return "markdown"; + if (isSupportedLocalHtmlPath(filePath)) return "html"; + if (extension === ".svg") return "svg"; + if (isSupportedLocalImagePath(filePath)) return "image"; + if (isSupportedLocalPdfPath(filePath)) return "pdf"; + if (isSupportedLocalAudioPath(filePath)) return "audio"; + if (isSupportedLocalVideoPath(filePath)) return "video"; + return "source"; +} + // Full allowlist for the /api/local-image serving route. Markdown image source // detection (below) intentionally stays image-only: a `.pdf` link in chat // markdown must never be inlined as an . export function isSupportedLocalPreviewFilePath(filePath: string): boolean { - return isSupportedLocalImagePath(filePath) || isSupportedLocalPdfPath(filePath); + return ( + isSupportedLocalImagePath(filePath) || + isSupportedLocalPdfPath(filePath) || + isSupportedLocalMediaPath(filePath) + ); } // Built from the canonical extensions list so the web regex never drifts from the diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 57d3ebcfe..d0104fa47 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,7 @@ import { isLocalAbsolutePath, isWorkspaceRelativePathSafe, joinWorkspaceRelativePath, + parentDirectoryOfLocalPath, workspaceRelativePathOf, } from "./path"; @@ -85,3 +86,20 @@ describe("joinWorkspaceRelativePath", () => { expect(workspaceRelativePathOf(joined, "/repo/app")).toBe("src/page.tsx"); }); }); + +describe("parentDirectoryOfLocalPath", () => { + it("returns POSIX and Windows parent directories", () => { + expect(parentDirectoryOfLocalPath("/Users/dev/Downloads/report.html")).toBe( + "/Users/dev/Downloads", + ); + expect(parentDirectoryOfLocalPath("C:\\Users\\dev\\Downloads\\report.html")).toBe( + "C:\\Users\\dev\\Downloads", + ); + }); + + it("preserves filesystem roots and rejects relative paths", () => { + expect(parentDirectoryOfLocalPath("/report.html")).toBe("/"); + expect(parentDirectoryOfLocalPath("C:\\report.html")).toBe("C:\\"); + expect(parentDirectoryOfLocalPath("docs/report.html")).toBeNull(); + }); +}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 0fd666edd..3ae70e3bd 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -70,6 +70,32 @@ export function joinWorkspaceRelativePath(workspaceRoot: string, relativePath: s return `${normalizedRoot}${separator}${normalizedRelativePath}`; } +/** + * Returns the parent directory of an absolute local path without depending on + * the host operating system. This matters when a macOS Scient instance is + * displaying a Windows path from a remote/provider transcript (and vice + * versa): node:path would otherwise apply only the host's separator rules. + */ +export function parentDirectoryOfLocalPath(value: string): string | null { + const trimmed = value.trim().replace(/[\\/]+$/, ""); + if (!isLocalAbsolutePath(trimmed)) { + return null; + } + + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (separatorIndex < 0) { + return null; + } + if (separatorIndex === 0) { + return "/"; + } + // Preserve a Windows drive root (`C:\\`) instead of returning `C:`. + if (separatorIndex === 2 && isWindowsDrivePath(trimmed)) { + return trimmed.slice(0, 3); + } + return trimmed.slice(0, separatorIndex); +} + // True for workspace-relative paths that cannot escape the workspace root: // rejects absolute paths (POSIX and Windows) and any "." / ".." segments. export function isWorkspaceRelativePathSafe(value: string): boolean { diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index de4b1dd20..d32b21fb1 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -9,6 +9,7 @@ import { readPathFromLaunchctl, readPathFromLoginShell, readWindowsPersistentEnvironment, + readWindowsPersistentEnvironmentAsync, resolveLoginShell, } from "./shell"; @@ -262,3 +263,23 @@ describe("readWindowsPersistentEnvironment", () => { expect(readWindowsPersistentEnvironment(execFile)).toEqual({}); }); }); + +describe("readWindowsPersistentEnvironmentAsync", () => { + it("reads and merges the registry dump without a synchronous child process", async () => { + const run = vi.fn(async () => ({ + stdout: JSON.stringify({ + machine: { Path: "C:\\Windows\\system32" }, + user: { Path: "C:\\Users\\ramar\\.local\\bin" }, + }), + })); + + await expect(readWindowsPersistentEnvironmentAsync(run)).resolves.toMatchObject({ + PATH: "C:\\Windows\\system32;C:\\Users\\ramar\\.local\\bin", + }); + expect(run).toHaveBeenCalledWith( + expect.stringMatching(/powershell\.exe$/iu), + ["-NoProfile", "-NonInteractive", "-Command", expect.any(String)], + { encoding: "utf8", timeout: 5000, windowsHide: true }, + ); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 92548323c..5372ddd48 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,7 +3,8 @@ // Exports: shell candidate resolution plus PATH/environment capture utilities. import * as OS from "node:os"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; const PATH_CAPTURE_START = "__SYNARA_PATH_START__"; const PATH_CAPTURE_END = "__SYNARA_PATH_END__"; @@ -15,6 +16,14 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +type ExecFileAsyncLike = ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number; windowsHide: true }, +) => Promise<{ stdout: string }>; + +const execFileAsync = promisify(execFile) as ExecFileAsyncLike; + function trimNonEmpty(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -260,6 +269,20 @@ export type WindowsEnvironmentReader = ( execFile?: ExecFileSyncLike, ) => Partial>; +function parseWindowsPersistentEnvironment(output: string): Partial> { + let parsed: { + machine?: Partial>; + user?: Partial>; + }; + try { + parsed = JSON.parse(output.trim()); + } catch { + return {}; + } + + return mergeWindowsScopes(parsed.machine ?? {}, parsed.user ?? {}); +} + // NOTE: keep this on Windows PowerShell 5.1 (`powershell.exe`), not `pwsh`. WinPS 5.1's // `ConvertTo-Json` escapes non-ASCII as `\uXXXX`, so the stdout stays pure ASCII and // survives the OEM-codepage console encoding. `pwsh` emits raw UTF-8 and would corrupt @@ -288,15 +311,16 @@ export const readWindowsPersistentEnvironment: WindowsEnvironmentReader = ( { encoding: "utf8", timeout: 5000 }, ); - let parsed: { - machine?: Partial>; - user?: Partial>; - }; - try { - parsed = JSON.parse(output.trim()); - } catch { - return {}; - } - - return mergeWindowsScopes(parsed.machine ?? {}, parsed.user ?? {}); + return parseWindowsPersistentEnvironment(output); }; + +export async function readWindowsPersistentEnvironmentAsync( + run: ExecFileAsyncLike = execFileAsync, +): Promise>> { + const { stdout } = await run( + resolveWindowsPowerShellPath(), + ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_ENVIRONMENT_SCRIPT], + { encoding: "utf8", timeout: 5000, windowsHide: true }, + ); + return parseWindowsPersistentEnvironment(stdout); +} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 30fb0b0dc..5e33d9a0d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -22,6 +22,7 @@ import { validateDesktopNativeBuildHost, } from "./lib/desktop-platform-build-config.ts"; import { isolateDesktopSigningEnvironment } from "./lib/desktop-signing-environment.ts"; +import { resolvePinnedElectronBuilder } from "./lib/electron-builder-authority.ts"; import { SCIENT_PRODUCTION_BUNDLE_ID } from "@synara/shared/desktopIdentity"; import { parseBooleanEnvValue } from "./lib/env-bool.ts"; import { verifySingleMacDmgSignature } from "./lib/mac-artifact-signature.ts"; @@ -44,7 +45,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -1103,7 +1103,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* Effect.log( `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, ); - const electronBuilderCli = path.join(repoRoot, "node_modules", "electron-builder", "cli.js"); + const electronBuilderCli = yield* Effect.try({ + try: () => resolvePinnedElectronBuilder(repoRoot).cliPath, + catch: (cause) => + new BuildScriptError({ + message: + "Pinned electron-builder CLI could not be resolved within repository dependency authority.", + cause, + }), + }); if (!(yield* fs.exists(electronBuilderCli))) { return yield* new BuildScriptError({ message: `Pinned electron-builder CLI was not found at ${electronBuilderCli}`, diff --git a/scripts/check-brand-identity.test.ts b/scripts/check-brand-identity.test.ts index 935e1f6f4..81bbff1a3 100644 --- a/scripts/check-brand-identity.test.ts +++ b/scripts/check-brand-identity.test.ts @@ -133,7 +133,7 @@ describe("brand identity guard", () => { }); it("keeps upstream release marketing out of the Scient UI", () => { - const requirements = new Map([["entries.ts", ["WHATS_NEW_ENTRIES = []"]]]); + const requirements = new Map([["entries.ts", ["WHATS_NEW_ENTRIES: readonly WhatsNewEntry[]"]]]); expect( findScientIdentityViolations( [{ path: "entries.ts", contents: "WHATS_NEW_ENTRIES = upstreamEntries" }], @@ -142,7 +142,12 @@ describe("brand identity guard", () => { ).toHaveLength(1); expect( findScientIdentityViolations( - [{ path: "entries.ts", contents: "WHATS_NEW_ENTRIES = []" }], + [ + { + path: "entries.ts", + contents: "export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[] = scientEntries", + }, + ], requirements, ), ).toEqual([]); diff --git a/scripts/check-brand-identity.ts b/scripts/check-brand-identity.ts index 9bf20b56e..eeabb2eb0 100644 --- a/scripts/check-brand-identity.ts +++ b/scripts/check-brand-identity.ts @@ -137,7 +137,7 @@ const requiredScientIdentityText = new Map([ ], [ "apps/web/src/whatsNew/entries.ts", - ["export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[] = [];"], + ["export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[]"], ], [".gitignore", ["/.scient/electron-dev", "/.scient-*/"]], ]); @@ -207,6 +207,8 @@ const scientOnlySurfacePaths = new Set([ "apps/web/src/components/settings/SkillsSettingsPanel.tsx", "apps/web/src/composerSlashCommands.ts", "apps/web/src/lib/automationDraft.ts", + "apps/web/src/whatsNew/entries.ts", + "apps/web/src/whatsNew/entries.json", "apps/web/src/lib/projectCreation.ts", "apps/web/src/routes/-automations.shared.tsx", "apps/web/src/routes/_chat.settings.tsx", diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9730230ef..61ae0460d 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -22,7 +22,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { for (const name of [ "SYNARA_MODE", "SYNARA_PORT", - "SCIENT_EXECUTABLE_HTML_PREVIEW", "SCIENT_HOME", "SYNARA_HOME", "SYNARA_NO_BROWSER", diff --git a/scripts/lib/electron-builder-authority.test.ts b/scripts/lib/electron-builder-authority.test.ts new file mode 100644 index 000000000..1d6f50669 --- /dev/null +++ b/scripts/lib/electron-builder-authority.test.ts @@ -0,0 +1,53 @@ +// FILE: electron-builder-authority.test.ts +// Purpose: Prove packaging dependency resolution stays within repository authority. +// Layer: Release/build helper test + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +import { resolvePinnedElectronBuilder } from "./electron-builder-authority.ts"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("resolvePinnedElectronBuilder", () => { + it("accepts the repository's Bun-hoisted pinned package", () => { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + + const resolved = resolvePinnedElectronBuilder(repoRoot); + + expect(resolved.version).toBe("26.15.3"); + expect(resolved.cliPath).toContain(`${join("node_modules", "electron-builder")}${sep}`); + }); + + it("rejects a matching-version package inherited from an ancestor", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "scient-electron-builder-authority-")); + temporaryRoots.push(fixtureRoot); + const repoRoot = join(fixtureRoot, "repo"); + const scriptsDirectory = join(repoRoot, "scripts"); + const repositoryNodeModules = join(repoRoot, "node_modules"); + const fakePackageDirectory = join(fixtureRoot, "node_modules", "electron-builder"); + mkdirSync(scriptsDirectory, { recursive: true }); + mkdirSync(repositoryNodeModules, { recursive: true }); + mkdirSync(fakePackageDirectory, { recursive: true }); + writeFileSync( + join(scriptsDirectory, "package.json"), + JSON.stringify({ devDependencies: { "electron-builder": "26.15.3" } }), + ); + writeFileSync( + join(fakePackageDirectory, "package.json"), + JSON.stringify({ name: "electron-builder", version: "26.15.3" }), + ); + writeFileSync(join(fakePackageDirectory, "cli.js"), "throw new Error('must not execute');\n"); + + expect(() => resolvePinnedElectronBuilder(repoRoot)).toThrow( + "Resolved electron-builder package escaped repository node_modules", + ); + }); +}); diff --git a/scripts/lib/electron-builder-authority.ts b/scripts/lib/electron-builder-authority.ts new file mode 100644 index 000000000..ce5e7a357 --- /dev/null +++ b/scripts/lib/electron-builder-authority.ts @@ -0,0 +1,63 @@ +// FILE: electron-builder-authority.ts +// Purpose: Resolve the pinned packaging CLI without escaping repository dependency authority. +// Layer: Release/build helper + +import { readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; + +export interface ResolvedElectronBuilder { + readonly cliPath: string; + readonly packageJsonPath: string; + readonly version: string; +} + +function isPathInside(parent: string, child: string): boolean { + const pathFromParent = relative(parent, child); + return ( + pathFromParent.length > 0 && + pathFromParent !== ".." && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +export function resolvePinnedElectronBuilder(repoRoot: string): ResolvedElectronBuilder { + const realRepoRoot = realpathSync(repoRoot); + const repositoryNodeModules = realpathSync(join(realRepoRoot, "node_modules")); + const scriptsPackageJsonPath = realpathSync(join(realRepoRoot, "scripts/package.json")); + const scriptsPackageJson = JSON.parse(readFileSync(scriptsPackageJsonPath, "utf8")) as { + devDependencies?: Record; + }; + const pinnedVersion = scriptsPackageJson.devDependencies?.["electron-builder"]; + if (typeof pinnedVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(pinnedVersion)) { + throw new Error("scripts/package.json must pin electron-builder to an exact version."); + } + + const requireFromScriptsWorkspace = createRequire(scriptsPackageJsonPath); + const packageJsonPath = realpathSync( + requireFromScriptsWorkspace.resolve("electron-builder/package.json"), + ); + const cliPath = realpathSync(requireFromScriptsWorkspace.resolve("electron-builder/cli.js")); + const packageDirectory = dirname(packageJsonPath); + + if (!isPathInside(repositoryNodeModules, packageDirectory)) { + throw new Error( + `Resolved electron-builder package escaped repository node_modules: ${packageDirectory}`, + ); + } + if (!isPathInside(packageDirectory, cliPath)) { + throw new Error( + `Resolved electron-builder CLI does not belong to ${packageDirectory}: ${cliPath}`, + ); + } + + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; + if (packageJson.version !== pinnedVersion) { + throw new Error( + `Resolved electron-builder ${String(packageJson.version)} does not match scripts/package.json pin ${pinnedVersion}.`, + ); + } + + return { cliPath, packageJsonPath, version: pinnedVersion }; +} diff --git a/scripts/package.json b/scripts/package.json index c6180d8a4..00a376d83 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,7 +14,8 @@ "@effect/platform-node": "catalog:", "@synara/contracts": "workspace:*", "@synara/shared": "workspace:*", - "effect": "catalog:" + "effect": "catalog:", + "pngjs": "7.0.0" }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 578e4809f..af70e70bf 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -26,6 +26,7 @@ import { } from "@synara/shared/desktopIdentity"; import { createDesktopPlatformBuildConfig } from "./lib/desktop-platform-build-config.ts"; +import { resolvePinnedElectronBuilder } from "./lib/electron-builder-authority.ts"; import { createReleaseInstallManifest, RELEASE_LOCKFILE_PATH, @@ -106,10 +107,39 @@ function assertNotContains(haystack: string, needle: string, message: string): v } } +function assertNotMatches(haystack: string, pattern: RegExp, message: string): void { + if (pattern.test(haystack)) { + throw new Error(message); + } +} + +function verifyReleaseRepositoryPolicy(): void { + const lockfileAttributes = execFileSync( + "git", + ["check-attr", "text", "eol", "--", RELEASE_LOCKFILE_PATH], + { + cwd: repoRoot, + encoding: "utf8", + }, + ) + .replaceAll("\r\n", "\n") + .trim(); + const expectedAttributes = [ + `${RELEASE_LOCKFILE_PATH}: text: set`, + `${RELEASE_LOCKFILE_PATH}: eol: lf`, + ].join("\n"); + if (lockfileAttributes !== expectedAttributes) { + throw new Error( + `Expected Git to resolve ${RELEASE_LOCKFILE_PATH} as text with LF endings; got:\n${lockfileAttributes}`, + ); + } +} + interface ReleaseWorkflowStep { readonly env?: Record; readonly if?: string; readonly name?: string; + readonly run?: string; } function assertScopedSigningEnvironment( @@ -210,9 +240,11 @@ function verifyReleaseWorkflowSafety(): void { const parsedWorkflow = parseYaml(workflow) as { jobs?: { build?: { steps?: Array }; + preflight?: { steps?: Array }; }; }; const buildSteps = parsedWorkflow.jobs?.build?.steps ?? []; + const preflightSteps = parsedWorkflow.jobs?.preflight?.steps ?? []; const requireBuildStep = (name: string) => { const step = buildSteps.find((candidate) => candidate.name === name); if (!step) { @@ -223,6 +255,39 @@ function verifyReleaseWorkflowSafety(): void { const macBuildStep = requireBuildStep("Build macOS desktop artifact"); const linuxBuildStep = requireBuildStep("Build Linux desktop artifact"); const windowsBuildStep = requireBuildStep("Build Windows desktop artifact"); + const releaseMetaIndex = preflightSteps.findIndex( + (step) => step.name === "Resolve release policy", + ); + const publicationSourceIndex = preflightSteps.findIndex( + (step) => step.name === "Verify publication source", + ); + const installDependenciesIndex = preflightSteps.findIndex( + (step) => step.name === "Install dependencies", + ); + const releaseNoteIndex = preflightSteps.findIndex( + (step) => step.name === "Verify release-note catalog structure", + ); + if ( + publicationSourceIndex < 0 || + installDependenciesIndex <= publicationSourceIndex || + installDependenciesIndex >= releaseMetaIndex + ) { + throw new Error( + "Expected release dependencies to install only after publication-source verification and before release policy resolution.", + ); + } + if (releaseMetaIndex < 0 || releaseNoteIndex !== releaseMetaIndex + 1) { + throw new Error( + "Expected the structural release-note gate immediately after release policy resolution.", + ); + } + if ( + !preflightSteps[releaseNoteIndex]?.run?.includes( + 'scripts/verify-release-notes.ts "${{ steps.release_meta.outputs.version }}"', + ) + ) { + throw new Error("Expected the release-note gate to verify the exact resolved version."); + } const appleSigningNames = [ "CSC_LINK", "CSC_KEY_PASSWORD", @@ -490,9 +555,39 @@ function verifyDesktopStageLockAuthority(): void { "Desktop staging must not retain the fresh production install path.", ); assertContains( + buildScript, + "resolvePinnedElectronBuilder(repoRoot).cliPath", + "Expected desktop packaging to resolve electron-builder through the authority guard.", + ); + assertContains( + buildScript, + "`${process.execPath} ${electronBuilderCli}", + "Expected desktop packaging to invoke electron-builder through Node.", + ); + assertContains( + buildScript, + "Pinned electron-builder CLI could not be resolved within repository dependency authority.", + "Expected desktop packaging to fail closed when electron-builder cannot be resolved.", + ); + assertContains( + buildScript, + "Pinned electron-builder CLI was not found at ${electronBuilderCli}", + "Expected desktop packaging to fail closed when the resolved CLI is absent.", + ); + assertNotContains( buildScript, 'path.join(repoRoot, "node_modules", "electron-builder", "cli.js")', - "Expected packaging to invoke the pinned root electron-builder CLI without platform-specific shims.", + "Desktop packaging must not assume electron-builder is hoisted to the root node_modules directory.", + ); + assertNotContains( + buildScript, + "electron-builder.cmd", + "Desktop packaging must not depend on a platform-specific Windows bin shim.", + ); + assertNotMatches( + buildScript, + /const electronBuilder\w* = path\.join\([\s\S]{0,240}?["']\.bin["']/, + "Desktop packaging must not depend on a hard-coded electron-builder bin directory.", ); assertContains( buildScript, @@ -526,6 +621,31 @@ function verifyDesktopStageLockAuthority(): void { '"node-gyp": "12.4.0"', "Expected native compiler tooling to be pinned.", ); + + const rootPackageJson = JSON.parse(rootPackage) as { + devDependencies?: Record; + }; + const scriptsPackageJson = JSON.parse( + readFileSync(resolve(repoRoot, "scripts/package.json"), "utf8"), + ) as { devDependencies?: Record }; + const rootElectronBuilderPin = rootPackageJson.devDependencies?.["electron-builder"]; + const scriptsElectronBuilderPin = scriptsPackageJson.devDependencies?.["electron-builder"]; + if ( + typeof scriptsElectronBuilderPin !== "string" || + !/^\d+\.\d+\.\d+$/.test(scriptsElectronBuilderPin) || + rootElectronBuilderPin !== scriptsElectronBuilderPin + ) { + throw new Error( + "Expected root and scripts workspaces to use the same exact electron-builder version pin.", + ); + } + + const resolvedElectronBuilder = resolvePinnedElectronBuilder(repoRoot); + if (resolvedElectronBuilder.version !== scriptsElectronBuilderPin) { + throw new Error( + `Expected scripts/package.json to resolve electron-builder ${scriptsElectronBuilderPin}, got ${resolvedElectronBuilder.version} from ${resolvedElectronBuilder.cliPath}.`, + ); + } } function readPackageVersion(root: string, relativePath: string): string { @@ -611,6 +731,7 @@ function verifyFrozenDesktopStageInstall(targetRoot: string, verifyNative = fals const tempRoot = mkdtempSync(join(tmpdir(), "scient-release-smoke-")); try { + verifyReleaseRepositoryPolicy(); verifyCanonicalIdentity(); verifyReleaseWorkflowSafety(); verifyDesktopStageLockAuthority(); diff --git a/scripts/verify-release-notes.test.ts b/scripts/verify-release-notes.test.ts new file mode 100644 index 000000000..07c06388a --- /dev/null +++ b/scripts/verify-release-notes.test.ts @@ -0,0 +1,394 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + isSafeBundledReleaseNoteRasterAsset, + parseReleaseNoteCatalog, + verifyBundledReleaseNoteAssetTree, + verifyReleaseNoteForVersion, + type ReleaseNoteEntry, +} from "./verify-release-notes"; + +const temporaryDirectories: string[] = []; + +const { PNG } = createRequire(import.meta.url)("pngjs") as { + readonly PNG: { + readonly sync: { + write(image: { + readonly width: number; + readonly height: number; + readonly data: Buffer; + }): Buffer; + }; + }; +}; + +const makeValidPng = () => + PNG.sync.write({ width: 1, height: 1, data: Buffer.from([46, 125, 246, 255]) }); + +function makePngChunk(type: string, data = Buffer.alloc(0)): Buffer { + const typeBytes = Buffer.from(type, "ascii"); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBytes.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length); + return chunk; +} + +function crc32(contents: Buffer): number { + let crc = 0xffffffff; + for (const byte of contents) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function insertBeforeIdat(png: Buffer, chunks: readonly Buffer[]): Buffer { + let offset = 8; + while (offset < png.length) { + const chunkLength = png.readUInt32BE(offset); + if (png.toString("ascii", offset + 4, offset + 8) === "IDAT") { + return Buffer.concat([png.subarray(0, offset), ...chunks, png.subarray(offset)]); + } + offset += 12 + chunkLength; + } + throw new Error("Expected generated PNG to contain IDAT."); +} + +afterEach(() => { + for (const path of temporaryDirectories.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +const entry = (overrides: Partial = {}): ReleaseNoteEntry => ({ + version: "1.2.3", + date: "July 24, 2026", + headline: "A calmer, clearer workspace", + features: [ + { + id: "clearer-workspace", + title: "Find your work faster", + description: "The workspace now keeps the things you need easier to reach.", + }, + { + id: "calmer-updates", + title: "Understand each update", + description: "Release notes now focus on what became better for you.", + }, + { + id: "quieter-notices", + title: "Stay in control", + description: "The note appears once and waits for you to open it.", + }, + ], + ...overrides, +}); + +describe("verifyReleaseNoteForVersion", () => { + it("requires exactly one exact release entry and normalizes a target v prefix", () => { + expect(verifyReleaseNoteForVersion("v1.2.3", [entry()])).toEqual({ + version: "1.2.3", + errors: [], + }); + expect(verifyReleaseNoteForVersion("1.2.4", [entry()]).errors).toContain( + "Release 1.2.4 requires exactly one curated Scient release note; found 0.", + ); + }); + + it("keeps stable and prerelease notes distinct", () => { + expect( + verifyReleaseNoteForVersion("1.2.3-beta.1", [entry({ version: "1.2.3" })]).errors, + ).toContain("Release 1.2.3-beta.1 requires exactly one curated Scient release note; found 0."); + }); + + it("rejects malformed and duplicate catalog data", () => { + const malformed = entry({ + version: "01.2.3", + date: " ", + headline: " ", + features: [ + { id: "same", title: "", description: " ", image: "/release-notes/missing.png" }, + { id: "same", title: "Title", description: "Description", details: " " }, + ], + }); + const errors = verifyReleaseNoteForVersion("1.2.3", [malformed, malformed]).errors.join("\n"); + expect(errors).toContain("canonical full semantic version"); + expect(errors).toContain("appears more than once"); + expect(errors).toContain("needs a release date"); + expect(errors).toContain("needs a benefit-led headline"); + expect(errors).toContain("provided together"); + expect(errors).toContain("reuses id same"); + expect(errors).toContain("needs a title"); + expect(errors).toContain("needs a description"); + expect(errors).toContain("details cannot be blank"); + }); + + it("requires three to five standard highlights and permits a concise declared hotfix", () => { + const noFeatures = entry({ features: [] }); + const tooMany = entry({ + version: "1.2.4", + features: Array.from({ length: 6 }, (_, index) => + index === 0 + ? { + id: `feature-${index}`, + title: "Title", + description: "Description", + imageAlt: "Missing image", + } + : { + id: `feature-${index}`, + title: "Title", + description: "Description", + }, + ), + }); + const errors = verifyReleaseNoteForVersion("1.2.3", [noFeatures, tooMany]).errors.join("\n"); + expect(errors).toContain("between 3 and 5"); + expect(errors).toContain("accessible alt text must be provided together"); + + expect( + verifyReleaseNoteForVersion("1.2.3", [ + entry({ kind: "hotfix", features: [entry().features[0]!] }), + ]).errors, + ).toEqual([]); + expect( + verifyReleaseNoteForVersion("1.2.3", [entry({ features: entry().features.slice(0, 2) })]) + .errors, + ).toContain("Entry 1 must contain between 3 and 5 user-facing highlights."); + }); + + it("allows only existing bundled raster artwork under the release-notes directory", () => { + const withHero = (heroImage: string) => entry({ heroImage }); + const verify = (heroImage: string, exists = false) => + verifyReleaseNoteForVersion("1.2.3", [withHero(heroImage)], { + assetExists: () => exists, + }).errors.join("\n"); + + expect(verify("/release-notes/1.2.3/hero.png", true)).toBe(""); + for (const unsafe of [ + "https://example.invalid/pixel.png", + "http://example.invalid/pixel.png", + "//example.invalid/pixel.png", + "data:image/png;base64,AAAA", + "file:///tmp/pixel.png", + "blob:scient", + "/release-notes/../secret.png", + "/release-notes/hero.svg", + "/release-notes/hero.jpg", + "/release-notes/hero.webp", + "/release-notes/hero.avif", + "/release-notes/hero.png?cache=1", + "/release-notes/hero.png#fragment", + ]) { + expect(verify(unsafe)).toContain("must be a bundled PNG asset"); + } + expect(verify("/release-notes/missing.png")).toContain( + "must resolve to a regular, non-symlinked PNG file", + ); + + const blankImageErrors = verifyReleaseNoteForVersion("1.2.3", [ + entry({ + features: [ + { ...entry().features[0]!, image: "", imageAlt: "A release highlight" }, + ...entry().features.slice(1), + ], + }), + ]).errors; + expect( + blankImageErrors.filter((error) => error === "Entry 1, highlight 1 image is blank."), + ).toHaveLength(1); + }); + + it("requires a real, contained raster file instead of accepting names alone", () => { + const publicRoot = mkdtempSync(join(tmpdir(), "scient-release-notes-")); + temporaryDirectories.push(publicRoot); + const releaseNotesRoot = join(publicRoot, "release-notes"); + mkdirSync(join(releaseNotesRoot, "1.2.3"), { recursive: true }); + const png = makeValidPng(); + writeFileSync(join(releaseNotesRoot, "1.2.3", "hero.png"), png); + writeFileSync( + join(releaseNotesRoot, "truncated.png"), + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + writeFileSync(join(releaseNotesRoot, "fake.png"), "not an image"); + const forged = Buffer.from(png); + const forgedByteIndex = forged.length - 13; + forged.writeUInt8(forged.readUInt8(forgedByteIndex) ^ 0xff, forgedByteIndex); + writeFileSync(join(releaseNotesRoot, "forged.png"), forged); + writeFileSync(join(releaseNotesRoot, "cut-off.png"), png.subarray(0, png.length - 8)); + const interlaced = Buffer.from(png); + interlaced[28] = 1; + writeFileSync(join(releaseNotesRoot, "interlaced.png"), interlaced); + const oversizedPath = join(releaseNotesRoot, "oversized.png"); + writeFileSync(oversizedPath, png); + truncateSync(oversizedPath, 10 * 1024 * 1024 + 1); + writeFileSync( + join(releaseNotesRoot, "oversized-palette.png"), + insertBeforeIdat(png, [makePngChunk("PLTE", Buffer.alloc(771))]), + ); + writeFileSync( + join(releaseNotesRoot, "too-many-chunks.png"), + insertBeforeIdat( + png, + Array.from({ length: 256 }, () => makePngChunk("vpAg")), + ), + ); + const corruptAncillaryChunk = Buffer.from(makePngChunk("vpAg", Buffer.from("Scient"))); + const corruptCrcByteIndex = corruptAncillaryChunk.length - 1; + corruptAncillaryChunk.writeUInt8( + corruptAncillaryChunk.readUInt8(corruptCrcByteIndex) ^ 0xff, + corruptCrcByteIndex, + ); + writeFileSync( + join(releaseNotesRoot, "corrupt-ancillary-crc.png"), + insertBeforeIdat(png, [corruptAncillaryChunk]), + ); + mkdirSync(join(releaseNotesRoot, "directory.png")); + + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/1.2.3/hero.png", publicRoot)).toBe( + true, + ); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/fake.png", publicRoot)).toBe(false); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/forged.png", publicRoot)).toBe( + false, + ); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/cut-off.png", publicRoot)).toBe( + false, + ); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/truncated.png", publicRoot)).toBe( + false, + ); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/interlaced.png", publicRoot)).toBe( + false, + ); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/oversized.png", publicRoot)).toBe( + false, + ); + expect( + isSafeBundledReleaseNoteRasterAsset("/release-notes/oversized-palette.png", publicRoot), + ).toBe(false); + expect( + isSafeBundledReleaseNoteRasterAsset("/release-notes/too-many-chunks.png", publicRoot), + ).toBe(false); + expect( + isSafeBundledReleaseNoteRasterAsset("/release-notes/corrupt-ancillary-crc.png", publicRoot), + ).toBe(false); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/directory.png", publicRoot)).toBe( + false, + ); + expect(isSafeBundledReleaseNoteRasterAsset("/release-notes/missing.png", publicRoot)).toBe( + false, + ); + }); + + it("uses a custom public root for referenced artwork as well as full-tree scanning", () => { + const publicRoot = mkdtempSync(join(tmpdir(), "scient-release-notes-reference-")); + temporaryDirectories.push(publicRoot); + const releaseNotesRoot = join(publicRoot, "release-notes", "1.2.3"); + mkdirSync(releaseNotesRoot, { recursive: true }); + writeFileSync(join(releaseNotesRoot, "hero.png"), makeValidPng()); + + expect( + verifyReleaseNoteForVersion( + "1.2.3", + [entry({ heroImage: "/release-notes/1.2.3/hero.png" })], + { publicRoot }, + ).errors, + ).toEqual([]); + }); + + it("scans every release-note asset, including unreferenced leaves", () => { + const publicRoot = mkdtempSync(join(tmpdir(), "scient-release-notes-tree-")); + temporaryDirectories.push(publicRoot); + const releaseNotesRoot = join(publicRoot, "release-notes"); + mkdirSync(join(releaseNotesRoot, "1.2.3"), { recursive: true }); + const png = makeValidPng(); + writeFileSync(join(releaseNotesRoot, "1.2.3", "hero.png"), png); + expect(verifyBundledReleaseNoteAssetTree(publicRoot)).toEqual([]); + + writeFileSync(join(releaseNotesRoot, "unreferenced.html"), ""); + writeFileSync(join(releaseNotesRoot, "unreferenced.png"), "not actually a PNG"); + const expectedErrors = [ + "/release-notes/unreferenced.html must be a PNG file; other public-tree leaves are not allowed.", + "/release-notes/unreferenced.png must be a decodable PNG within the release-note asset tree.", + ]; + expect(verifyBundledReleaseNoteAssetTree(publicRoot)).toEqual(expectedErrors); + expect(verifyReleaseNoteForVersion("1.2.3", [entry()], { publicRoot }).errors).toEqual( + expectedErrors, + ); + }); + + it.runIf(process.platform !== "win32")( + "rejects in-root and escaping release-note symlinks", + () => { + const publicRoot = mkdtempSync(join(tmpdir(), "scient-release-notes-links-")); + temporaryDirectories.push(publicRoot); + const releaseNotesRoot = join(publicRoot, "release-notes"); + mkdirSync(releaseNotesRoot, { recursive: true }); + const validTarget = join(releaseNotesRoot, "target.png"); + const outsideTarget = join(publicRoot, "outside.png"); + const outsideDirectory = join(publicRoot, "outside-directory"); + const png = makeValidPng(); + writeFileSync(validTarget, png); + writeFileSync(outsideTarget, png); + mkdirSync(outsideDirectory); + writeFileSync(join(outsideDirectory, "escaped.html"), "not public release-note content"); + symlinkSync(validTarget, join(releaseNotesRoot, "inside-link.png")); + symlinkSync(outsideTarget, join(releaseNotesRoot, "outside-link.png")); + symlinkSync(outsideDirectory, join(releaseNotesRoot, "escaped-directory"), "dir"); + + expect( + isSafeBundledReleaseNoteRasterAsset("/release-notes/inside-link.png", publicRoot), + ).toBe(false); + expect( + isSafeBundledReleaseNoteRasterAsset("/release-notes/outside-link.png", publicRoot), + ).toBe(false); + expect(verifyBundledReleaseNoteAssetTree(publicRoot)).toEqual([ + "/release-notes/escaped-directory must not be a symlink.", + "/release-notes/inside-link.png must not be a symlink.", + "/release-notes/outside-link.png must not be a symlink.", + ]); + }, + ); + + it.runIf(process.platform !== "win32")("rejects unreferenced non-regular leaves", () => { + const publicRoot = mkdtempSync(join(tmpdir(), "scient-release-notes-special-")); + temporaryDirectories.push(publicRoot); + const releaseNotesRoot = join(publicRoot, "release-notes"); + mkdirSync(releaseNotesRoot, { recursive: true }); + const fifoPath = join(releaseNotesRoot, "unreferenced.png"); + const result = spawnSync("mkfifo", [fifoPath], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + expect(verifyBundledReleaseNoteAssetTree(publicRoot)).toEqual([ + "/release-notes/unreferenced.png must be a regular file or directory.", + ]); + }); + + it("decodes catalog field shapes before semantic validation", () => { + expect(parseReleaseNoteCatalog([entry()])).toEqual([entry()]); + expect(() => parseReleaseNoteCatalog([{ ...entry(), date: undefined }])).toThrow( + "Entry 1 date must be a string.", + ); + expect(() => + parseReleaseNoteCatalog([{ ...entry(), features: [{ ...entry().features[0], title: 4 }] }]), + ).toThrow("Entry 1, highlight 1 title must be a string."); + expect(() => parseReleaseNoteCatalog([null])).toThrow("Entry 1 must be an object."); + }); + + it("does not mutate its input", () => { + const entries = [entry()]; + const before = structuredClone(entries); + verifyReleaseNoteForVersion("1.2.3", entries); + expect(entries).toEqual(before); + }); +}); diff --git a/scripts/verify-release-notes.ts b/scripts/verify-release-notes.ts new file mode 100644 index 000000000..68a7f174a --- /dev/null +++ b/scripts/verify-release-notes.ts @@ -0,0 +1,474 @@ +// FILE: verify-release-notes.ts +// Purpose: Fail a release preflight unless its exact, curated Scient note exists. + +import { fileURLToPath } from "node:url"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import { createRequire } from "node:module"; +import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; + +export interface ReleaseNoteFeature { + readonly id: string; + readonly title: string; + readonly description: string; + readonly image?: string; + readonly imageAlt?: string; + readonly details?: string; +} + +export interface ReleaseNoteEntry { + readonly version: string; + readonly date: string; + readonly headline: string; + readonly kind?: "standard" | "hotfix"; + readonly features: readonly ReleaseNoteFeature[]; + readonly heroImage?: string; +} + +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +export interface ReleaseNoteVerification { + readonly version: string; + readonly errors: readonly string[]; +} + +export interface ReleaseNoteVerificationOptions { + readonly assetExists?: (publicPath: string) => boolean; + readonly publicRoot?: string; +} + +const RELEASE_NOTE_ASSET_PATTERN = + /^\/release-notes\/(?:[A-Za-z0-9][A-Za-z0-9._-]*\/)*[A-Za-z0-9][A-Za-z0-9._-]*\.png$/i; +const WEB_PUBLIC_ROOT = fileURLToPath(new URL("../apps/web/public/", import.meta.url)); +const MAX_RELEASE_NOTE_PNG_BYTES = 10 * 1024 * 1024; +const MAX_RELEASE_NOTE_PNG_CHUNKS = 256; +const MAX_RELEASE_NOTE_PNG_DIMENSION = 8_192; +const MAX_RELEASE_NOTE_PNG_PIXELS = 16_000_000; + +interface DecodedPng { + readonly width: number; + readonly height: number; + readonly data: Buffer; +} + +const { PNG } = createRequire(import.meta.url)("pngjs") as { + readonly PNG: { + readonly sync: { + read(contents: Buffer, options?: { readonly checkCRC?: boolean }): DecodedPng; + }; + }; +}; + +export function normalizeReleaseVersion(rawVersion: string): string { + const version = rawVersion.trim().replace(/^v/, ""); + if (!SEMVER_PATTERN.test(version)) { + throw new Error(`Expected a full semantic version, got ${JSON.stringify(rawVersion)}.`); + } + return version; +} + +export function verifyReleaseNoteForVersion( + rawVersion: string, + entries: readonly ReleaseNoteEntry[], + options: ReleaseNoteVerificationOptions = {}, +): ReleaseNoteVerification { + let version = rawVersion.trim().replace(/^v/, ""); + const errors: string[] = []; + const publicRoot = options.publicRoot ?? WEB_PUBLIC_ROOT; + const assetExists = + options.assetExists ?? + ((publicPath: string) => isSafeBundledReleaseNoteRasterAsset(publicPath, publicRoot)); + errors.push(...verifyBundledReleaseNoteAssetTree(publicRoot)); + try { + version = normalizeReleaseVersion(rawVersion); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + + const releaseVersions = new Set(); + for (const [entryIndex, entry] of entries.entries()) { + const label = `Entry ${entryIndex + 1}`; + if (!SEMVER_PATTERN.test(entry.version) || entry.version.startsWith("v")) { + errors.push(`${label} must use a canonical full semantic version without a leading v.`); + } + if (releaseVersions.has(entry.version)) { + errors.push(`Release ${entry.version} appears more than once.`); + } + releaseVersions.add(entry.version); + + if (entry.date.trim().length === 0) errors.push(`${label} needs a release date.`); + if (entry.headline.trim().length === 0) errors.push(`${label} needs a benefit-led headline.`); + const isHotfix = entry.kind === "hotfix"; + if (entry.kind !== undefined && entry.kind !== "standard" && !isHotfix) { + errors.push(`${label} kind must be standard or hotfix.`); + } + const minimumHighlights = isHotfix ? 1 : 3; + const maximumHighlights = isHotfix ? 2 : 5; + if (entry.features.length < minimumHighlights || entry.features.length > maximumHighlights) { + errors.push( + isHotfix + ? `${label} hotfix must contain between 1 and 2 user-facing highlights.` + : `${label} must contain between 3 and 5 user-facing highlights.`, + ); + } + validateImage(label, "hero", entry.heroImage, errors, assetExists); + + const featureIds = new Set(); + for (const [featureIndex, feature] of entry.features.entries()) { + const featureLabel = `${label}, highlight ${featureIndex + 1}`; + if (feature.id.trim().length === 0) errors.push(`${featureLabel} needs an id.`); + if (featureIds.has(feature.id)) errors.push(`${featureLabel} reuses id ${feature.id}.`); + featureIds.add(feature.id); + if (feature.title.trim().length === 0) errors.push(`${featureLabel} needs a title.`); + if (feature.description.trim().length === 0) { + errors.push(`${featureLabel} needs a description.`); + } + if (feature.details !== undefined && feature.details.trim().length === 0) { + errors.push(`${featureLabel} details cannot be blank.`); + } + validateImagePair( + featureLabel, + "image", + feature.image, + feature.imageAlt, + errors, + assetExists, + ); + } + } + + if (SEMVER_PATTERN.test(version)) { + const matches = entries.filter((entry) => entry.version === version); + if (matches.length !== 1) { + errors.push( + `Release ${version} requires exactly one curated Scient release note; found ${matches.length}.`, + ); + } + } + + return { version, errors }; +} + +function validateImagePair( + label: string, + kind: string, + image: string | undefined, + alt: string | undefined, + errors: string[], + assetExists: ((publicPath: string) => boolean) | undefined, +) { + if ((image === undefined) !== (alt === undefined)) { + errors.push(`${label} ${kind} and accessible alt text must be provided together.`); + } + validateImage(label, kind, image, errors, assetExists); + if (alt !== undefined && alt.trim().length === 0) + errors.push(`${label} ${kind} alt text is blank.`); +} + +function validateImage( + label: string, + kind: string, + image: string | undefined, + errors: string[], + assetExists: ((publicPath: string) => boolean) | undefined, +) { + if (image === undefined) return; + if (image.trim().length === 0) { + errors.push(`${label} ${kind} is blank.`); + return; + } + if (!RELEASE_NOTE_ASSET_PATTERN.test(image)) { + errors.push( + `${label} ${kind} must be a bundled PNG asset under /release-notes/ with no URL, query, hash, or traversal.`, + ); + } else if (!assetExists?.(image)) { + errors.push( + `${label} ${kind} must resolve to a regular, non-symlinked PNG file in apps/web/public${image}.`, + ); + } +} + +export function isSafeBundledReleaseNoteRasterAsset( + publicPath: string, + publicRoot: string, +): boolean { + try { + if (!RELEASE_NOTE_ASSET_PATTERN.test(publicPath)) return false; + const canonicalPublicRoot = realpathSync(publicRoot); + const releaseNotesRoot = resolve(canonicalPublicRoot, "release-notes"); + const releaseNotesRootStat = lstatSync(releaseNotesRoot); + if (!releaseNotesRootStat.isDirectory() || releaseNotesRootStat.isSymbolicLink()) return false; + + const candidatePath = resolve(canonicalPublicRoot, publicPath.slice(1)); + const lexicalRelativePath = relative(releaseNotesRoot, candidatePath); + if (!isContainedRelativePath(lexicalRelativePath)) { + return false; + } + + let inspectedPath = releaseNotesRoot; + for (const segment of lexicalRelativePath.split(/[\\/]/)) { + inspectedPath = resolve(inspectedPath, segment); + if (lstatSync(inspectedPath).isSymbolicLink()) return false; + } + + const candidateStat = lstatSync(candidatePath); + if (!candidateStat.isFile() || candidateStat.size > MAX_RELEASE_NOTE_PNG_BYTES) return false; + const canonicalCandidatePath = realpathSync(candidatePath); + const canonicalRelativePath = relative(realpathSync(releaseNotesRoot), canonicalCandidatePath); + if (!isContainedRelativePath(canonicalRelativePath)) { + return false; + } + + return isDecodableReleaseNotePng(readFileSync(canonicalCandidatePath)); + } catch { + return false; + } +} + +export function verifyBundledReleaseNoteAssetTree(publicRoot: string): readonly string[] { + const releaseNotesRoot = resolve(publicRoot, "release-notes"); + const errors: string[] = []; + + let rootStat; + try { + rootStat = lstatSync(releaseNotesRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return errors; + return [`Unable to inspect the release-note asset tree: ${formatError(error)}`]; + } + + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + return ["apps/web/public/release-notes must be a real directory, not a symlink or file."]; + } + + const inspectDirectory = (directory: string, pathSegments: readonly string[]) => { + let names: string[]; + try { + names = readdirSync(directory).sort((left, right) => left.localeCompare(right)); + } catch (error) { + errors.push( + `Unable to inspect /release-notes/${pathSegments.join("/")}: ${formatError(error)}`, + ); + return; + } + + for (const name of names) { + const candidatePath = resolve(directory, name); + const candidateSegments = [...pathSegments, name]; + const publicPath = `/release-notes/${candidateSegments.join("/")}`; + let candidateStat; + try { + candidateStat = lstatSync(candidatePath); + } catch (error) { + errors.push(`Unable to inspect ${publicPath}: ${formatError(error)}`); + continue; + } + + if (candidateStat.isSymbolicLink()) { + errors.push(`${publicPath} must not be a symlink.`); + } else if (candidateStat.isDirectory()) { + inspectDirectory(candidatePath, candidateSegments); + } else if (!candidateStat.isFile()) { + errors.push(`${publicPath} must be a regular file or directory.`); + } else if (extname(name).toLowerCase() !== ".png") { + errors.push(`${publicPath} must be a PNG file; other public-tree leaves are not allowed.`); + } else if (!isSafeBundledReleaseNoteRasterAsset(publicPath, publicRoot)) { + errors.push(`${publicPath} must be a decodable PNG within the release-note asset tree.`); + } + } + }; + + inspectDirectory(releaseNotesRoot, []); + return errors; +} + +function isContainedRelativePath(pathValue: string): boolean { + return ( + pathValue.length > 0 && + pathValue !== ".." && + !pathValue.startsWith(`..${sep}`) && + !isAbsolute(pathValue) + ); +} + +function isDecodableReleaseNotePng(contents: Buffer): boolean { + if (contents.length < 33 || contents.length > MAX_RELEASE_NOTE_PNG_BYTES) return false; + if ( + !contents + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) || + contents.readUInt32BE(8) !== 13 || + contents.toString("ascii", 12, 16) !== "IHDR" || + contents[28] !== 0 + ) { + return false; + } + + if (!hasBoundedPngChunkTable(contents)) return false; + + const declaredWidth = contents.readUInt32BE(16); + const declaredHeight = contents.readUInt32BE(20); + if ( + declaredWidth === 0 || + declaredHeight === 0 || + declaredWidth > MAX_RELEASE_NOTE_PNG_DIMENSION || + declaredHeight > MAX_RELEASE_NOTE_PNG_DIMENSION || + declaredWidth * declaredHeight > MAX_RELEASE_NOTE_PNG_PIXELS + ) { + return false; + } + + try { + const decoded = PNG.sync.read(contents, { checkCRC: true }); + return ( + decoded.width === declaredWidth && + decoded.height === declaredHeight && + decoded.data.length === declaredWidth * declaredHeight * 4 + ); + } catch { + return false; + } +} + +function hasBoundedPngChunkTable(contents: Buffer): boolean { + let chunkCount = 0; + let offset = 8; + let sawIdat = false; + let idatSequenceClosed = false; + let sawPalette = false; + + while (offset < contents.length) { + if (contents.length - offset < 12) return false; + const chunkLength = contents.readUInt32BE(offset); + const chunkTypeBytes = contents.subarray(offset + 4, offset + 8); + if ( + ![...chunkTypeBytes].every( + (byte) => (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122), + ) || + chunkTypeBytes[2]! < 65 || + chunkTypeBytes[2]! > 90 + ) { + return false; + } + const chunkType = chunkTypeBytes.toString("ascii"); + const chunkEnd = offset + 12 + chunkLength; + if (chunkEnd > contents.length) return false; + const storedCrc = contents.readUInt32BE(offset + 8 + chunkLength); + const computedCrc = crc32(contents.subarray(offset + 4, offset + 8 + chunkLength)); + if (storedCrc !== computedCrc) return false; + + chunkCount += 1; + if (chunkCount > MAX_RELEASE_NOTE_PNG_CHUNKS) return false; + if (chunkCount === 1) { + if (chunkType !== "IHDR" || chunkLength !== 13) return false; + } else if (chunkType === "IHDR") { + return false; + } + + if (chunkType === "PLTE") { + if (sawPalette || sawIdat || chunkLength < 3 || chunkLength > 768 || chunkLength % 3 !== 0) { + return false; + } + sawPalette = true; + } + + if (chunkType === "IDAT") { + if (idatSequenceClosed) return false; + sawIdat = true; + } else if (sawIdat) { + idatSequenceClosed = true; + } + + if (chunkType === "IEND") { + return chunkLength === 0 && sawIdat && chunkEnd === contents.length; + } + offset = chunkEnd; + } + + return false; +} + +function crc32(contents: Buffer): number { + let crc = 0xffffffff; + for (const byte of contents) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function main(args: readonly string[]) { + if (args.length !== 1) { + throw new Error("Usage: node scripts/verify-release-notes.ts "); + } + const entries = readReleaseNoteCatalog(); + const result = verifyReleaseNoteForVersion(args[0] ?? "", entries); + if (result.errors.length > 0) { + throw new Error(`Release-note preflight failed:\n- ${result.errors.join("\n- ")}`); + } + console.log( + `Release-note preflight passed for Scient v${result.version} (${entries.length} catalog entries).`, + ); +} + +function readReleaseNoteCatalog(): readonly ReleaseNoteEntry[] { + const path = fileURLToPath(new URL("../apps/web/src/whatsNew/entries.json", import.meta.url)); + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + return parseReleaseNoteCatalog(parsed); +} + +export function parseReleaseNoteCatalog(parsed: unknown): readonly ReleaseNoteEntry[] { + if (!Array.isArray(parsed)) { + throw new Error("The Scient release-note catalog must be a JSON array."); + } + + for (const [entryIndex, entry] of parsed.entries()) { + const label = `Entry ${entryIndex + 1}`; + assertRecord(entry, `${label} must be an object.`); + assertString(entry.version, `${label} version must be a string.`); + assertString(entry.date, `${label} date must be a string.`); + assertString(entry.headline, `${label} headline must be a string.`); + if (entry.kind !== undefined) assertString(entry.kind, `${label} kind must be a string.`); + if (entry.heroImage !== undefined) { + assertString(entry.heroImage, `${label} hero image must be a string.`); + } + if (!Array.isArray(entry.features)) { + throw new Error(`${label} features must be an array.`); + } + for (const [featureIndex, feature] of entry.features.entries()) { + const featureLabel = `${label}, highlight ${featureIndex + 1}`; + assertRecord(feature, `${featureLabel} must be an object.`); + assertString(feature.id, `${featureLabel} id must be a string.`); + assertString(feature.title, `${featureLabel} title must be a string.`); + assertString(feature.description, `${featureLabel} description must be a string.`); + for (const [field, fieldLabel] of [ + ["image", "image"], + ["imageAlt", "image alt text"], + ["details", "details"], + ] as const) { + if (feature[field] !== undefined) { + assertString(feature[field], `${featureLabel} ${fieldLabel} must be a string.`); + } + } + } + } + return parsed as readonly ReleaseNoteEntry[]; +} + +function assertRecord(value: unknown, message: string): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(message); +} + +function assertString(value: unknown, message: string): asserts value is string { + if (typeof value !== "string") throw new Error(message); +} + +const isMain = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) main(process.argv.slice(2)); diff --git a/turbo.json b/turbo.json index a888bce53..ef3902df4 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,6 @@ "VITE_WS_URL", "VITE_DEV_SERVER_URL", "ELECTRON_RENDERER_PORT", - "SCIENT_EXECUTABLE_HTML_PREVIEW", "SCIENT_HOME", "SYNARA_HOME", "SYNARA_DESKTOP_WS_URL",