From dddc0bdcb2230147e207efb17df2e49dbe1bdd8c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 18:35:27 -0700 Subject: [PATCH 01/36] fix(server): include SQLite conditions in persistence errors Include SQLite conditions and schema issue tags without copying query data. Continue @Sy-D's [#4837](https://github.com/pingdotgg/t3code/pull/4837). Add the missing Bun error codes and test the real SQL client. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: Sy-D <8460326+Sy-D@users.noreply.github.com> Co-authored-by: Claude Opus 5 --- apps/server/src/persistence/Errors.test.ts | 73 +++++++++++++++++++++- apps/server/src/persistence/Errors.ts | 48 +++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts index 680a362e20a3..bd3e5128b1f1 100644 --- a/apps/server/src/persistence/Errors.test.ts +++ b/apps/server/src/persistence/Errors.test.ts @@ -1,8 +1,11 @@ import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; -import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; +import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts"; const decodeRuntimePayload = Schema.decodeUnknownEffect( Schema.Struct({ @@ -25,6 +28,74 @@ it("keeps SQL operation context without a tautological detail", () => { assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); }); +it.effect("names a real SQLite condition without copying query data", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const payload = "sql-private-sentinel"; + yield* sql`CREATE TABLE error_test (private_column TEXT PRIMARY KEY)`; + yield* sql`INSERT INTO error_test VALUES (${payload})`; + const cause = yield* Effect.flip(sql`INSERT INTO error_test VALUES (${payload})`); + const error = toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")( + cause, + ); + + assert.equal(error.detail, "SQLITE(1555) constraint failed"); + assert.equal(error.cause, cause); + assert.notInclude(error.message, payload); + assert.notInclude(error.message, "private_column"); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), +); + +it("reads the condition through a wrapping driver error", () => { + const driver = Object.assign(new Error("locked"), { errcode: 5, errstr: "database is locked" }); + const error = toPersistenceSqlError("AuthSessionRepository.list:query")( + new Error("Failed to prepare statement", { cause: driver }), + ); + + assert.equal(error.detail, "SQLITE(5) database is locked"); +}); + +it.each([{ errno: 1555, code: "SQLITE_CONSTRAINT_PRIMARYKEY" }, { errno: 1 }])( + "names Bun SQLite condition $errno through the SQL error wrapper", + (condition) => { + const driver = Object.assign(new Error("bun-sql-private-sentinel"), { + name: "SQLiteError", + ...condition, + }); + const cause = new SqlError({ reason: classifySqliteError(driver) }); + const error = toPersistenceSqlError("AuthSessionRepository.create:query")(cause); + + assert.equal( + error.message, + `SQL error in AuthSessionRepository.create:query: SQLITE(${condition.errno})`, + ); + assert.equal(error.cause, cause); + }, +); + +it.each([ + new Error("unhelpful"), + Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }), +])("omits a detail for a cause it cannot categorize (%#)", (cause) => { + const error = toPersistenceSqlError("AuthSessionRepository.list:query")(cause); + + assert.equal(error.detail, undefined); + assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); +}); + +it.effect("summarizes a schema cause by issue tag instead of by rejected value", () => + Effect.gen(function* () { + const rejectedPayload = "sql-mapper-secret-sentinel"; + const cause = yield* Effect.flip( + decodeRuntimePayload({ runtimePayload: { attempt: rejectedPayload } }), + ); + const error = toPersistenceSqlError("ProviderSessionRuntimeRepository.list:query")(cause); + + assert.ok(error.detail !== undefined); + assert.ok(!error.message.includes(rejectedPayload)); + }), +); + it.effect("maps schema errors without copying rejected payloads into diagnostics", () => Effect.gen(function* () { const rejectedPayload = "runtime-payload-secret-sentinel"; diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d63..64d47d4ab772 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,3 +1,4 @@ +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; @@ -72,14 +73,55 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass - new PersistenceSqlError({ + return (cause: unknown): PersistenceSqlError => { + const detail = describeSqlCause(cause); + return new PersistenceSqlError({ operation, - detail: `Failed to execute ${operation}`, + ...(detail === undefined ? {} : { detail }), cause, }); + }; } // Kept for orchestration/projection call sites, which are being revamped separately. From 6f405370c8e552da9dcfdd6922e85789fd918340 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 18:45:08 -0700 Subject: [PATCH 02/36] fix(dev): keep shared dev reloads and hot updates working (#9543) --- apps/web/index.html | 26 +++- apps/web/src/bootstrap.test.ts | 92 +++++++++++++ apps/web/src/bootstrap.ts | 5 + apps/web/src/bundledDev.test.ts | 223 ++++++++++++++++++++++++++++++++ apps/web/src/lib/bootError.ts | 27 ++++ apps/web/src/main.tsx | 12 +- apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 4 +- apps/web/vite/tailwind.ts | 15 +++ docs/internals/scripts.md | 5 + docs/user/install.md | 3 + 11 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/bootstrap.test.ts create mode 100644 apps/web/src/bootstrap.ts create mode 100644 apps/web/src/bundledDev.test.ts create mode 100644 apps/web/src/lib/bootError.ts create mode 100644 apps/web/vite/tailwind.ts diff --git a/apps/web/index.html b/apps/web/index.html index 8aef3a4286f2..c2d164742402 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -452,6 +452,30 @@ height: 64px; object-fit: contain; } + + #boot-error { + display: grid; + justify-items: center; + gap: 16px; + max-width: 600px; + padding: 24px; + text-align: center; + } + + #boot-error p { + margin: 0; + overflow-wrap: anywhere; + } + + #boot-error button { + padding: 8px 16px; + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + } T3 Code (Alpha) @@ -463,6 +487,6 @@ - + diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts new file mode 100644 index 000000000000..d682e0150de5 --- /dev/null +++ b/apps/web/src/bootstrap.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { showBootError } from "./lib/bootError"; + +class BootElement extends EventTarget { + children: BootElement[] = []; + textContent = ""; + + constructor(readonly tagName: string) { + super(); + } + + setAttribute() {} + + append(child: BootElement) { + this.children.push(child); + } + + replaceChildren(...children: BootElement[]) { + this.children = children; + } + + get text(): string { + return this.textContent + this.children.map((child) => child.text).join(" "); + } +} + +describe("app startup failures", () => { + let bootShell: BootElement | null; + + beforeEach(() => { + vi.resetModules(); + vi.doMock("./main", () => { + throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); + }); + bootShell = new BootElement("div"); + vi.stubGlobal("document", { + getElementById: () => bootShell, + createElement: (tagName: string) => new BootElement(tagName), + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("shows failures from asynchronous app startup", async () => { + vi.doMock("./main", () => ({ startup: Promise.reject(new Error("Startup chunks failed")) })); + + await import("./bootstrap"); + await vi.dynamicImportSettled(); + + expect(bootShell?.text).toContain("Startup chunks failed"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("replaces the splash when an app import throws before main can run", async () => { + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + + await import("./bootstrap"); + await vi.dynamicImportSettled(); + + expect(bootShell?.text).toContain("T3 Code could not load."); + const reloadButton = bootShell?.children[0]?.children.find( + (element) => element.tagName === "button", + ); + expect(reloadButton?.text).toBe("Reload"); + reloadButton?.dispatchEvent(new Event("click")); + expect(reload).toHaveBeenCalledOnce(); + }); + + it.each([true, false])("shows startup error details only in dev mode, DEV=%s", (dev) => { + vi.stubEnv("DEV", dev); + + showBootError(new Error("internal module path")); + + expect(bootShell?.text).toContain("T3 Code could not load."); + expect(bootShell?.text.includes("internal module path")).toBe(dev); + }); + + it("does not replace the app after React removes the splash", () => { + bootShell = null; + const createElement = vi.spyOn(document, "createElement"); + + showBootError(new Error("late failure")); + + expect(createElement).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts new file mode 100644 index 000000000000..9a3d4a03b3ca --- /dev/null +++ b/apps/web/src/bootstrap.ts @@ -0,0 +1,5 @@ +import { showBootError } from "./lib/bootError"; + +// Bundled dev can move UI code into shared chunks. Load it only after this +// entry runs the React refresh preamble, and catch failures before React mounts. +void import("./main").then(({ startup }) => startup).catch(showBootError); diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts new file mode 100644 index 000000000000..1ca5038da50e --- /dev/null +++ b/apps/web/src/bundledDev.test.ts @@ -0,0 +1,223 @@ +// @effect-diagnostics nodeBuiltinImport:off - builds and executes real dev bundles on disk. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +import react from "@vitejs/plugin-react"; +import { createLogger, createServer } from "vite-plus"; +import { expect, it } from "vite-plus/test"; + +import { tailwindPlugins } from "../vite/tailwind"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +it("initializes React refresh before a shared UI chunk runs in bundled dev", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bootstrap-")); + const output = NodePath.join(root, "output"); + let resolveBundle!: (files: Map) => void; + let rejectBundle!: (error: unknown) => void; + const bundled = new Promise>((resolve, reject) => { + resolveBundle = resolve; + rejectBundle = reject; + }); + let server: Awaited> | undefined; + + try { + await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); + for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { + await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); + } + await NodeFSP.writeFile( + NodePath.join(root, "src/shared.tsx"), + "export function Shared() { return
ready
; }", + ); + await NodeFSP.writeFile( + NodePath.join(root, "src/main.tsx"), + `import { Shared } from "./shared"; +export const startup = Promise.resolve().then(() => globalThis.onStarted(Shared()));`, + ); + + server = await createServer({ + configFile: false, + root, + publicDir: NodeURL.fileURLToPath(new URL("../public", import.meta.url)), + logLevel: "silent", + resolve: { + alias: { react: NodePath.dirname(NodeURL.fileURLToPath(import.meta.resolve("react"))) }, + }, + experimental: { bundledDev: true }, + plugins: [ + react(), + { + name: "capture-bootstrap-bundle", + buildEnd(error) { + if (error) rejectBundle(error); + }, + generateBundle(_options, bundle) { + resolveBundle( + new Map( + Object.values(bundle) + .filter((file) => file.type === "chunk") + .map((file) => [file.fileName, file.code]), + ), + ); + }, + }, + ], + build: { + rolldownOptions: { + experimental: { devMode: { lazy: false } }, + output: { + // Reproduce the shared chunks Vite creates after lazy routes load, + // without needing a browser to trigger the lazy compiler first. + codeSplitting: { + groups: [ + { name: "vendor", test: /node_modules|@react-refresh/, priority: 10 }, + { name: "shared-ui", test: /shared\.tsx$/, includeDependenciesRecursively: false }, + ], + }, + }, + }, + }, + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + for (const [file, code] of await bundled) { + const target = NodePath.join(output, file); + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.writeFile(target, code); + } + + // Run the actual generated ES modules so their import order and refresh + // checks execute. These stubs replace only the browser and HMR transport. + const runner = NodePath.join(output, "check.mjs"); + await NodeFSP.writeFile( + runner, + `import assert from "node:assert/strict"; +const started = Promise.withResolvers(); +globalThis.window = globalThis; +globalThis.document = { + createElement: () => ({ relList: { supports: () => true } }), + getElementById: () => null, +}; +globalThis.__rolldown_runtime__ = { + registerGraph() {}, + registerModule() {}, + createModuleHotContext: () => ({ accept() {} }), +}; +globalThis.onStarted = started.resolve; +console.error = (_message, error) => started.reject(error); +await import("./assets/index.js"); +const element = await started.promise; +assert.equal(element.props.children, "ready"); +assert.equal(typeof window.$RefreshReg$, "function"); +console.log("App started with React refresh ready.");`, + ); + const result = await execFile("node", [runner]); + expect(result.stdout).toContain("App started with React refresh ready."); + } finally { + await server?.close(); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("hot updates Tailwind classes when a source file changes in bundled dev", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tailwind-")); + const events = new NodeEvents.EventEmitter(); + let server: Awaited> | undefined; + let socket: WebSocket | undefined; + let css = ""; + + try { + await NodeFSP.writeFile( + NodePath.join(root, "index.html"), + '', + ); + const source = + 'import "./style.css"; export const margin = "m-[13px]"; import.meta.hot?.accept();'; + await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source); + await NodeFSP.writeFile( + NodePath.join(root, "style.css"), + '@import "tailwindcss" source(none); @source "./main.ts";', + ); + const logger = createLogger("silent"); + logger.error = (message) => events.emit("error", new Error(message)); + const built = NodeEvents.EventEmitter.once(events, "built"); + server = await createServer({ + configFile: false, + root, + customLogger: logger, + resolve: { + alias: { + tailwindcss: NodeURL.fileURLToPath( + new URL("../node_modules/tailwindcss/index.css", import.meta.url), + ), + }, + }, + experimental: { bundledDev: true }, + plugins: [ + ...tailwindPlugins(true), + { + name: "observe-tailwind-output", + enforce: "pre", + transform(code, id) { + if (id.endsWith("/style.css")) css = code; + }, + generateBundle() { + events.emit("built"); + }, + }, + ], + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + await built; + const address = server.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); + + const connected = NodeEvents.EventEmitter.once(events, "connected"); + server.ws.on("vite:client-connected", () => events.emit("connected")); + socket = new WebSocket( + `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, + "vite-hmr", + ); + socket.addEventListener("open", () => { + socket?.send( + JSON.stringify({ + type: "custom", + event: "vite:client-connected", + data: { clientId: "tailwind-test" }, + }), + ); + }); + socket.addEventListener("message", ({ data }) => { + const message: unknown = JSON.parse(String(data)); + if ( + message !== null && + typeof message === "object" && + "type" in message && + message.type === "bundled-dev-update" + ) { + events.emit("updated"); + } + }); + await connected; + const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); + expect(entry.headers.get("content-type")).toContain("javascript"); + await entry.text(); + + const updated = NodeEvents.EventEmitter.once(events, "updated"); + await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px")); + await updated; + expect(css).toContain("margin: 137px"); + } finally { + socket?.close(); + await server?.close(); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts new file mode 100644 index 000000000000..697c8e9d8d72 --- /dev/null +++ b/apps/web/src/lib/bootError.ts @@ -0,0 +1,27 @@ +/** Shows startup failures before React can replace the boot splash. */ +export function showBootError(error: unknown) { + console.error("T3 Code failed to start.", error); + const bootShell = document.getElementById("boot-shell"); + if (!bootShell) return; + + const content = document.createElement("div"); + content.id = "boot-error"; + content.setAttribute("role", "alert"); + + const message = document.createElement("p"); + message.textContent = "T3 Code could not load."; + content.append(message); + + if (import.meta.env.DEV && error instanceof Error) { + const detail = document.createElement("p"); + detail.textContent = error.message; + content.append(detail); + } + + const reload = document.createElement("button"); + reload.type = "button"; + reload.textContent = "Reload"; + reload.addEventListener("click", () => window.location.reload()); + content.append(reload); + bootShell.replaceChildren(content); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 78655785020c..8cafbd5009b4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -56,7 +56,10 @@ const managedAuthShellModule = // managed-auth runtime and the initial route's split chunks, before // rendering, so the splash holds until real UI paints instead of dropping to // a blank window while chunks download. -void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()]) +export const startup = Promise.all([ + managedAuthShellModule?.then((module) => module.default) ?? null, + router.load(), +]) .then(([ManagedAuthShell]) => { // A route chunk failure still resolves router.load(): the error is parked in // the lazy component and surfaces through the route error boundary. Skip the @@ -75,10 +78,7 @@ void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? nu ); }) .catch((error: unknown) => { - // The auth shell chunk failed and the guarded reload is spent. Say so - // instead of leaving the splash up forever. + // Let the bootstrap entry show the error unless a reload is already scheduled. if (reloadScheduled) return; - console.error("T3 Code failed to load its startup chunks.", error); - const bootShell = document.getElementById("boot-shell"); - if (bootShell) bootShell.textContent = "T3 Code could not load. Reload to try again."; + throw error; }); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 186b6ebfe509..65b04800e837 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -27,6 +27,7 @@ }, "include": [ "src", + "vite", "vite.config.ts", "vercel.ts", "test", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6e947effd861..955099fba65e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,5 @@ import * as NodeZlib from "node:zlib"; -import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; @@ -13,6 +12,7 @@ import pkg from "./package.json" with { type: "json" }; import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; +import { tailwindPlugins } from "./vite/tailwind"; const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); @@ -169,7 +169,7 @@ export default defineConfig(() => { parserOpts: { plugins: ["typescript", "jsx"] }, presets: [reactCompilerPreset()], }), - tailwindcss(), + tailwindPlugins(bundledDev), ], optimizeDeps: { include: [ diff --git a/apps/web/vite/tailwind.ts b/apps/web/vite/tailwind.ts new file mode 100644 index 000000000000..5726468a0595 --- /dev/null +++ b/apps/web/vite/tailwind.ts @@ -0,0 +1,15 @@ +import tailwindcss from "@tailwindcss/vite"; + +/** Adapts Tailwind's dev hooks to Vite's experimental bundled mode. */ +export function tailwindPlugins(bundledDev: boolean) { + const plugins = tailwindcss(); + if (bundledDev) { + for (const plugin of plugins) { + // This hook expects Vite ModuleNodes and a server, which Rolldown does + // not supply. Bundled dev tracks Tailwind's addWatchFile dependencies + // and rebuilds CSS when those files change without this hook. + delete plugin.hotUpdate; + } + } + return plugins; +} diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 1cb5d006901b..3f8c58601e7f 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -27,6 +27,11 @@ authenticated. Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a network round trip per import level in unbundled dev, which turns a cold module graph into minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out. + The web entry loads the app with a dynamic import so React refresh starts before shared UI + chunks run. Keep app imports out of that entry. A static import can work on the first load + and then fail on reload after Vite splits code for lazy routes. + Bundled dev uses Tailwind's watched files to rebuild CSS. Its Vite-only hot-update hook is + disabled in this mode because Rolldown does not supply the Vite server or module graph. - `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has no effect; use `--browser`. diff --git a/docs/user/install.md b/docs/user/install.md index bfe292dd891d..7534759fd10c 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -18,6 +18,9 @@ npx t3@latest This starts the T3 Code server on your machine and opens the local web app. Use `npx t3@latest --help` for the full CLI reference. +If the web or desktop app shows "T3 Code could not load", check your connection and select +**Reload** to try again. + ## Open a project in the desktop app When the T3 Code desktop app is running on the same machine, open the current directory with: From c5ba51d629b3813182cf3e161cc3f23b1e541dc3 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 21:48:28 -0400 Subject: [PATCH 03/36] feat(providers): add context compaction command (#9293) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../features/threads/NewTaskDraftScreen.tsx | 1 + .../src/features/threads/ThreadComposer.tsx | 2 + .../features/threads/ThreadDetailScreen.tsx | 14 + .../src/features/threads/ThreadFeed.tsx | 27 ++ .../features/threads/ThreadRouteScreen.tsx | 1 + .../threads/floating-working-control.tsx | 25 +- .../threads/use-composer-command-menu.ts | 6 + apps/mobile/src/lib/threadActivity.test.ts | 27 ++ apps/mobile/src/lib/threadActivity.ts | 28 ++ .../src/state/use-thread-composer-state.ts | 46 ++- .../src/state/use-thread-outbox-drain.ts | 8 +- apps/mobile/src/state/use-thread-outbox.ts | 5 + ...ProviderSessionStartup.integration.test.ts | 1 + .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 63 ++++ .../Layers/ProjectionPipeline.ts | 48 +++ .../Layers/ProviderCommandReactor.test.ts | 280 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 252 +++++++++++++--- .../Layers/ProviderRuntimeIngestion.test.ts | 53 +++- .../Layers/ProviderRuntimeIngestion.ts | 108 ++++++- .../src/provider/Layers/ClaudeAdapter.test.ts | 18 +- .../src/provider/Layers/ClaudeAdapter.ts | 37 ++- .../src/provider/Layers/ClaudeProvider.ts | 9 +- .../src/provider/Layers/CodexAdapter.test.ts | 43 +++ .../src/provider/Layers/CodexAdapter.ts | 23 +- .../src/provider/Layers/CodexProvider.ts | 2 + .../provider/Layers/CodexSessionRuntime.ts | 5 + .../src/provider/Layers/CursorProvider.ts | 2 + .../src/provider/Layers/GrokProvider.ts | 2 + .../provider/Layers/OpenCodeAdapter.test.ts | 39 +++ .../src/provider/Layers/OpenCodeAdapter.ts | 81 +++++ .../src/provider/Layers/OpenCodeProvider.ts | 2 + .../provider/Layers/ProviderRegistry.test.ts | 14 +- .../provider/Layers/ProviderService.test.ts | 251 ++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 267 ++++++++++++++++- .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderAdapter.ts | 5 + .../src/provider/Services/ProviderService.ts | 7 + apps/server/src/provider/providerSnapshot.ts | 5 + .../serverRuntimeStartup.reconcile.test.ts | 1 + .../web/src/components/ChatView.logic.test.ts | 36 +++ apps/web/src/components/ChatView.logic.ts | 27 ++ apps/web/src/components/ChatView.tsx | 70 ++++- apps/web/src/components/chat/ChatComposer.tsx | 32 +- .../chat/ContextWindowMeter.logic.test.ts | 12 +- .../chat/ContextWindowMeter.logic.ts | 26 +- .../chat/MessagesTimeline.logic.test.ts | 32 ++ .../components/chat/MessagesTimeline.logic.ts | 32 +- .../components/chat/MessagesTimeline.test.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 55 +++- docs/user/composer.md | 2 + packages/contracts/src/providerRuntime.ts | 2 + 53 files changed, 1999 insertions(+), 143 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 5519f2817582..26fdfd24a4fb 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -16,6 +16,7 @@ import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle"; import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight"; import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle"; import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize"; +import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; import IconBolt from "@tabler/icons-react-native/IconBolt"; import IconBox from "@tabler/icons-react-native/IconBox"; @@ -99,6 +100,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.up": IconArrowUp, "arrow.up.circle": IconArrowUpCircle, "arrow.up.left.and.arrow.down.right": IconArrowsMaximize, + "arrow.down.right.and.arrow.up.left": IconArrowsMinimize, "arrow.up.right": IconArrowUpRight, "arrow.up.right.circle": IconArrowUpRightCircle, "arrow.uturn.backward": IconArrowBackUp, diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index b02afce3a12f..94cd242d36e1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -319,6 +319,7 @@ export function NewTaskDraftScreen(props: { : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, + hasCompactableConversation: false, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index b4cdd43deca9..af3359ec8c79 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -111,6 +111,7 @@ export interface ThreadComposerProps { readonly connectionError: string | null; readonly environmentLabel: string | null; readonly selectedThread: OrchestrationThreadShell; + readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; @@ -343,6 +344,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer projectCwd: props.projectCwd, selectedProviderStatus, hasThread: true, + hasCompactableConversation: props.hasCompactableConversation, onChangeDraftMessage: props.onChangeDraftMessage, onUpdateInteractionMode: selectedProviderStatus?.showInteractionModeToggle === false diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index bf1a55a8a6ae..d0e553ebdcf9 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -100,6 +100,7 @@ export interface ThreadDetailScreenProps { readonly environmentLabel: string | null; readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; + readonly isCompacting: boolean; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; @@ -328,6 +329,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + if (props.isCompacting && contentPresentationKind === "ready") { + return { kind: "compacting" }; + } if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { return { kind: "working", startedAt: props.activeWorkStartedAt }; } @@ -335,6 +339,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread })(); const showWorkingControl = floatingStatus !== null; const selectedThreadFeed = props.selectedThreadFeed; + const hasCompactableConversation = + selectedThreadFeed.some( + (entry) => + entry.type === "message" && + entry.message.role === "user" && + ((entry.message.attachments?.length ?? 0) > 0 || + entry.message.text.trim().toLowerCase() !== "/compact"), + ) || + (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; // While a user-input request is pending, the questionnaire owns the @@ -820,6 +833,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread connectionError={props.connectionError} environmentLabel={props.environmentLabel} selectedThread={props.selectedThread} + hasCompactableConversation={hasCompactableConversation && !props.isCompacting} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} environmentId={props.environmentId} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4d198ce4ae3a..a25045c60112 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -144,6 +144,7 @@ import { } from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, + isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; @@ -1557,6 +1558,29 @@ function renderFeedEntry( ); } + if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) { + const label = entry.activities[0]!.summary; + return ( + + + + + {label} + + + + ); + } + if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; @@ -2721,6 +2745,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "work-toggle": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": + if (isContextCompactionActivityGroup(entry)) { + return undefined; + } // Expanded rows append a variable detail block — fall back to // measurement for those groups. return entry.activities.some((activity) => expandedWorkRows[activity.id]) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 53eca806cbc7..df9486e8556a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -775,6 +775,7 @@ function ThreadRouteContent( environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} selectedThreadFeed={composer.selectedThreadFeed} activeWorkStartedAt={composer.activeWorkStartedAt} + isCompacting={composer.isCompacting} activePendingApproval={requests.activePendingApproval} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index a62a3c9d17bc..0d7f7d88f014 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -13,6 +13,7 @@ import Animated, { import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; @@ -46,7 +47,8 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOS */ export type FloatingWorkingStatus = | { readonly kind: "working"; readonly startedAt: string } - | { readonly kind: "syncing"; readonly label: string }; + | { readonly kind: "syncing"; readonly label: string } + | { readonly kind: "compacting" }; export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; @@ -161,6 +163,24 @@ export function FloatingWorkingControl(props: { ); } +function CompactingLabel() { + return ( + + + Compacting… + + ); +} + function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) { if (props.status.kind === "syncing") { return ( @@ -174,6 +194,9 @@ function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) ); } + if (props.status.kind === "compacting") { + return ; + } return ; } diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 3cf762b4a150..5b5b444cca74 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -35,6 +35,7 @@ export function buildComposerSlashCommandItems(input: { readonly query: string; readonly atMessageStart: boolean; readonly hasThread: boolean; + readonly hasCompactableConversation?: boolean; readonly allowInteractionMode: boolean; readonly selectedProviderStatus: Pick< ServerProvider, @@ -76,6 +77,7 @@ export function buildComposerSlashCommandItems(input: { if (!input.atMessageStart) return items; for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; + if (command.name === "compact" && !input.hasCompactableConversation) continue; if ( !input.hasThread && input.selectedProviderStatus?.driver === "codex" && @@ -140,6 +142,7 @@ export function useComposerCommandMenu({ projectCwd, selectedProviderStatus, hasThread, + hasCompactableConversation, enabled = true, onChangeDraftMessage, onUpdateInteractionMode, @@ -150,6 +153,7 @@ export function useComposerCommandMenu({ readonly projectCwd: string | null; readonly selectedProviderStatus: ServerProvider | null; readonly hasThread: boolean; + readonly hasCompactableConversation: boolean; readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; @@ -262,6 +266,7 @@ export function useComposerCommandMenu({ query: q, atMessageStart: trigger.rangeStart === 0, hasThread, + hasCompactableConversation, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus, }); @@ -379,6 +384,7 @@ export function useComposerCommandMenu({ return []; }, [ hasThread, + hasCompactableConversation, onUpdateInteractionMode, pathSearch.entries, selectedProviderStatus, diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 0ffd0c03071f..a0e68e3f82b1 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -353,6 +353,33 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps context compaction as a standalone timeline row", () => { + const thread = makeThread({ + id: ThreadId.make("thread-context-compaction"), + projectId: ProjectId.make("project-1"), + title: "Context compaction", + activities: [ + makeActivity({ + id: EventId.make("context-compaction"), + kind: "context-compaction", + tone: "info", + summary: "Compacted context 899K → 19K tokens", + createdAt: "2026-09-01T00:00:00.000Z", + turnId: TurnId.make("turn-context-compaction"), + }), + ], + }); + + const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set()); + expect(presented).toMatchObject([ + { + type: "activity-group", + id: "context-compaction", + activities: [{ summary: "Compacted context 899K → 19K tokens" }], + }, + ]); + }); + it("keeps long Claude commands expandable without repeating them in full detail", () => { const command = `printf 'first line\nsecond line'\n&& printf done`; const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 796416d80200..5eb3c0cbf7b1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -176,6 +176,15 @@ export type ThreadFeedLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; +export function isContextCompactionActivityGroup( + entry: Extract, +): boolean { + return ( + entry.activities.length === 1 && + entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction" + ); +} + function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { switch (requestType) { case "command_execution_approval": @@ -1266,6 +1275,18 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th continue; } + if (entry.activity.workEntry.sourceActivityKind === "context-compaction") { + grouped.push({ + type: "activity-group", + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + activities: [entry.activity], + }); + openGroupActivities = null; + continue; + } + if (openGroupActivities !== null && openGroupTurnId === entry.turnId) { openGroupActivities.push(entry.activity); continue; @@ -1345,6 +1366,9 @@ function deriveThreadFeedTurnFolds( pendingUserBoundary = entry.message.createdAt; continue; } + if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) { + continue; + } const turnId = entry.type === "message" && entry.message.role === "assistant" ? entry.message.turnId @@ -1504,6 +1528,10 @@ function appendPresentedFeedEntry( result.push(entry); return; } + if (isContextCompactionActivityGroup(entry)) { + result.push(entry); + return; + } const activities = omitSupersededLifecycleMarkers( entry.activities.filter( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 294647daa9cf..1d760df72cba 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -56,7 +56,7 @@ import { setPendingConnectionError } from "../state/use-remote-environment-regis import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; -import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; import { @@ -107,6 +107,7 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> >({}); @@ -170,6 +171,48 @@ export function useThreadComposerState() { }; }, [selectedThreadDetail, selectedThreadShell]); + const isCompacting = useMemo(() => { + const queuedMessage = selectedThreadQueuedMessages.findLast( + (message) => + message.messageId === dispatchingQueuedMessageId && + message.text.trim().toLowerCase() === "/compact" && + message.attachments.length === 0, + ); + const latestCompactMessage = selectedThreadDetail?.messages.findLast( + (message) => + message.role === "user" && + message.text.trim().toLowerCase() === "/compact" && + !message.attachments?.length, + ); + const compactRequestIsActive = + latestCompactMessage !== undefined && + (latestCompactMessage.createdAt > + (selectedThread?.latestTurn?.requestedAt ?? latestCompactMessage.createdAt) || + (selectedThread?.latestTurn?.state === "running" && + latestCompactMessage.createdAt === selectedThread.latestTurn.requestedAt)); + const compactionSettled = selectedThreadDetail?.activities.some((activity) => { + if (!["context-compaction", "provider.turn.start.failed"].includes(activity.kind)) + return false; + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as { readonly requestId?: unknown }) + : null; + return payload?.requestId === latestCompactMessage?.id; + }); + return ( + queuedMessage !== undefined || + ((selectedThread?.session?.status === "starting" || + selectedThread?.session?.status === "running") && + compactRequestIsActive && + !compactionSettled) + ); + }, [ + dispatchingQueuedMessageId, + selectedThread, + selectedThreadDetail, + selectedThreadQueuedMessages, + ]); + const activeWorkStartedAt = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { @@ -526,6 +569,7 @@ export function useThreadComposerState() { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, + isCompacting, draftMessage, draftAttachments, modelSelection, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index bd3d730e0265..07e0e7d3100a 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -13,7 +13,7 @@ import { } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; @@ -61,6 +61,7 @@ import { } from "./use-composer-drafts"; import { useAtomCommand } from "./use-atom-command"; import { + dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom, useThreadOutboxMessages, useThreadOutboxShellStatuses, @@ -70,11 +71,6 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; -export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), -); - function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts index 542c2d401c38..6ed00e2a0b7d 100644 --- a/apps/mobile/src/state/use-thread-outbox.ts +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -31,6 +31,11 @@ export const editingQueuedMessageIdsAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), +); + export function holdEditingQueuedMessage(messageId: MessageId): void { const current = appAtomRegistry.get(editingQueuedMessageIdsAtom); if (current[messageId]) { diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index d3e55c4b9ceb..86a00323c049 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll( Layer.succeed(ProviderService.ProviderService, { startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), + compactThread: () => Effect.die("unused"), interruptTurn: () => Effect.die("unused"), respondToRequest: () => Effect.die("unused"), respondToUserInput: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 3e1953bbeac7..65ed329dfe71 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -113,6 +113,7 @@ function createProviderServiceHarness( const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), + compactThread: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 260e3567c242..1b8a451175f3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -3196,6 +3196,69 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test- assert.deepEqual(pendingRows, []); }), ); + + it.effect("only clears the compact request that produced the compaction activity", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-compaction-correlation"); + + for (const [index, messageId] of ["compact-request", "new-message"].entries()) { + const createdAt = `2026-02-26T15:00:0${index}.000Z`; + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-compaction-pending-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make(`cmd-compaction-pending-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-compaction-pending-${index}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(messageId), + runtimeMode: "full-access", + createdAt, + }, + }); + } + yield* eventStore.append({ + type: "thread.activity-appended", + eventId: EventId.make("evt-compaction-stale"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-02-26T15:00:02.000Z", + commandId: CommandId.make("cmd-compaction-stale"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-compaction-stale"), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make("activity-compaction-stale"), + tone: "info", + kind: "context-compaction", + summary: "Context compacted", + payload: { requestId: "compact-request" }, + turnId: null, + createdAt: "2026-02-26T15:00:02.000Z", + }, + }, + }); + yield* projectionPipeline.bootstrap; + + const pendingRows = yield* sql<{ readonly messageId: string }>` + SELECT pending_message_id AS "messageId" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + `; + assert.deepEqual(pendingRows, [{ messageId: "new-message" }]); + }), + ); }, ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 48b7169d1028..1ba1b6afa4f3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1269,6 +1269,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; case "thread.turn-start-requested": { + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: event.payload.threadId, + }); + if (Option.isSome(pendingTurnStart)) { + const pendingMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: pendingTurnStart.value.messageId, + }); + if ( + Option.isSome(pendingMessage) && + pendingMessage.value.role === "user" && + (pendingMessage.value.attachments?.length ?? 0) === 0 && + pendingMessage.value.text.trim().toLowerCase() === "/compact" + ) { + return; + } + } yield* projectionTurnRepository.replacePendingTurnStart({ threadId: event.payload.threadId, messageId: event.payload.messageId, @@ -1279,10 +1295,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.activity-appended": { + if (event.payload.activity.kind === "context-compaction") { + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId( + event.payload, + ); + if ( + Option.isNone(pendingTurnStart) || + String(pendingTurnStart.value.messageId) !== + extractActivityRequestId(event.payload.activity.payload) + ) { + return; + } + yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload); + return; + } + if (event.payload.activity.kind !== "provider.turn.start.failed") return; + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId( + event.payload, + ); + if ( + Option.isNone(pendingTurnStart) || + String(pendingTurnStart.value.messageId) !== + extractActivityRequestId(event.payload.activity.payload) + ) { + return; + } + yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload); + return; + } + case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { if ( + (event.payload.session.status === "ready" && + event.commandId?.startsWith("server:provider-session-set:") === true) || event.payload.session.status === "error" || event.payload.session.status === "stopped" || event.payload.session.status === "interrupted" diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d892e92cfff3..e8f35672da76 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -173,6 +173,8 @@ describe("ProviderCommandReactor", () => { readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; readonly serverActivation?: Effect.Effect; + readonly beforeReadySessionDispatch?: () => Effect.Effect; + readonly compactThreadEffect?: () => Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( @@ -264,6 +266,7 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); + const compactThread = vi.fn((_: ThreadId) => input?.compactThreadEffect?.() ?? Effect.void); const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); @@ -349,6 +352,7 @@ describe("ProviderCommandReactor", () => { const service: ProviderServiceShape = { startSession: startSession as ProviderServiceShape["startSession"], sendTurn: sendTurn as ProviderServiceShape["sendTurn"], + compactThread, interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], @@ -424,7 +428,11 @@ describe("ProviderCommandReactor", () => { return Effect.die(new Error("Injected title regeneration completion failure")); } } - return engine.dispatch(command); + return ( + command.type === "thread.session.set" && command.session.status === "ready" + ? (input?.beforeReadySessionDispatch?.() ?? Effect.void) + : Effect.void + ).pipe(Effect.andThen(engine.dispatch(command))); }, get streamDomainEvents() { return engine.streamDomainEvents; @@ -564,6 +572,7 @@ describe("ProviderCommandReactor", () => { tryHandlePromptCommand, startSession, sendTurn, + compactThread, interruptTurn, respondToRequest, respondToUserInput, @@ -877,6 +886,275 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("rejects /compact without conversation context", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-empty-compact"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-empty-compact"), + role: "user", + text: "/compact", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(() => harness.drain()); + expect(harness.compactThread).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("keeps turns blocked until compaction restores the session", () => + Effect.gen(function* () { + const readyDispatchStarted = yield* Deferred.make(); + const releaseReadyDispatch = yield* Deferred.make(); + let blockReadyDispatch = false; + const harness = yield* Effect.promise(() => + createHarness({ + beforeReadySessionDispatch: () => + blockReadyDispatch + ? Deferred.succeed(readyDispatchStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseReadyDispatch)), + ) + : Effect.void, + }), + ); + const threadId = ThreadId.make("thread-1"); + const now = "2026-01-01T00:00:00.000Z"; + const dispatchTurn = (id: string, text: string, createdAt: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-${id}`), + threadId, + message: { + messageId: asMessageId(`user-message-${id}`), + role: "user", + text, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + + yield* dispatchTurn("before-blocked-compact", "hello", now); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + blockReadyDispatch = true; + yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); + yield* Deferred.await(readyDispatchStarted); + + yield* dispatchTurn("during-compact-recovery", "too soon", "2026-01-01T00:00:02.000Z"); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.turn.start.failed", + ) === true + ); + }), + ); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ + { threadId: "thread-1" }, + ]); + + yield* Deferred.succeed(releaseReadyDispatch, undefined); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return thread?.session?.status === "ready"; + }), + ); + }), + ); + + effectIt.effect("does not overwrite concurrent session state after compaction failure", () => + Effect.gen(function* () { + const releaseCompaction = yield* Deferred.make(); + const releaseRunningCompaction = yield* Deferred.make(); + const releaseFailedStop = yield* Deferred.make(); + let compactionCount = 0; + const harness = yield* Effect.promise(() => + createHarness({ + compactThreadEffect: () => + Deferred.await( + compactionCount++ === 0 ? releaseCompaction : releaseRunningCompaction, + ).pipe(Effect.andThen(Effect.die("Compaction stopped"))), + stopSessionEffect: () => + Deferred.await(releaseFailedStop).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "session.stop", + detail: "provider stop failed", + }), + ), + ), + ), + }), + ); + const threadId = ThreadId.make("thread-1"); + const now = "2026-01-01T00:00:00.000Z"; + const dispatchCompact = (suffix: string, createdAt: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-compact-${suffix}`), + threadId, + message: { + messageId: asMessageId(`user-message-compact-${suffix}`), + role: "user", + text: "/compact", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-message-before-compact"), + threadId, + message: { + messageId: asMessageId("user-message-before-compact"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-ready-before-compact"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + yield* dispatchCompact("before-stop", now); + yield* Effect.promise(() => waitFor(() => harness.compactThread.mock.calls.length === 1)); + const compactingThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(compactingThread?.session?.status).toBe("starting"); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-during-compact"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Effect.promise(() => waitFor(() => harness.stopSession.mock.calls.length === 1)); + yield* Deferred.succeed(releaseCompaction, undefined); + yield* Effect.promise(() => + waitFor(async () => { + const compactingThread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + return ( + compactingThread?.activities.some( + (activity) => activity.kind === "provider.turn.start.failed", + ) === true + ); + }), + ); + const stoppingThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(stoppingThread?.session?.status).toBe("starting"); + yield* Deferred.succeed(releaseFailedStop, undefined); + yield* Effect.promise(() => harness.drain()); + + const recoveredThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(recoveredThread?.session?.status).toBe("ready"); + expect( + recoveredThread?.activities.find( + (activity) => activity.kind === "provider.session.stop.failed", + ), + ).toMatchObject({ + summary: "Provider session stop failed", + payload: { detail: "provider stop failed" }, + }); + + yield* dispatchCompact("before-running", "2026-01-01T00:00:02.000Z"); + yield* Effect.promise(() => waitFor(() => harness.compactThread.mock.calls.length === 2)); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-failed-stop-before-compaction-settles"), + threadId, + createdAt: "2026-01-01T00:00:02.500Z", + }); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return ( + thread?.activities.filter( + (activity) => activity.kind === "provider.session.stop.failed", + ).length === 2 + ); + }), + ); + const restartedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(restartedThread?.session?.status).toBe("starting"); + const restartedSession = restartedThread?.session; + if (!restartedSession) return yield* Effect.die("Compaction session missing"); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-running-during-compact"), + threadId, + session: { + ...restartedSession, + status: "running", + activeTurnId: asTurnId("compaction-turn"), + updatedAt: "2026-01-01T00:00:03.000Z", + }, + createdAt: "2026-01-01T00:00:03.000Z", + }); + yield* Deferred.succeed(releaseRunningCompaction, undefined); + yield* Effect.promise(() => harness.drain()); + const runningThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(runningThread?.session?.status).toBe("running"); + }), + ); effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e01cd938b4ef..1beab8ed22bb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -17,6 +17,7 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shar import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -30,7 +31,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterValidationError, +} from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; @@ -51,6 +55,7 @@ import { import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderDriverKind = Schema.is(ProviderDriverKind); type ProviderIntentEvent = Extract< @@ -73,6 +78,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined return normalized && normalized.length > 0 ? normalized : undefined; } +const isCompactCommandMessage = (message: ThreadTitleMessage): boolean => + message.role === "user" && + (message.attachments?.length ?? 0) === 0 && + message.text.trim().toLowerCase() === "/compact"; function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -334,6 +343,8 @@ const make = Effect.gen(function* () { ); const threadModelSelections = new Map(); + const compactingThreadIds = new Set(); + const stoppingThreadIds = new Set(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -377,11 +388,11 @@ const make = Effect.gen(function* () { const formatFailureDetail = (cause: Cause.Cause): string => { const failReason = cause.reasons.find(Cause.isFailReason); - const providerError = isProviderAdapterRequestError(failReason?.error) - ? failReason.error - : undefined; - if (providerError) { - return providerError.detail; + if (isProviderAdapterRequestError(failReason?.error)) { + return failReason.error.detail; + } + if (isProviderAdapterValidationError(failReason?.error)) { + return failReason.error.issue; } return Cause.pretty(cause); }; @@ -431,6 +442,37 @@ const make = Effect.gen(function* () { }); }); + const restoreCompaction = Effect.fnUntraced(function* (threadId: ThreadId, fromRunning = false) { + if (stoppingThreadIds.has(threadId)) { + compactingThreadIds.delete(threadId); + return; + } + const thread = yield* resolveThread(threadId); + if (!thread?.session) return; + if ( + thread.session.status !== "starting" && + thread.session.status !== "ready" && + (!fromRunning || thread.session.status !== "running") + ) + return; + const completedAt = DateTime.formatIso(yield* DateTime.now); + if (stoppingThreadIds.has(threadId)) { + compactingThreadIds.delete(threadId); + return; + } + yield* setThreadSession({ + threadId, + session: { + ...thread.session, + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }, + createdAt: completedAt, + }); + }); + const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) { return yield* projectionSnapshotQuery .getProjectShellById(projectId) @@ -1139,7 +1181,6 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const message = thread.messages.find((entry) => entry.id === event.payload.messageId); if (!message || message.role !== "user") { yield* appendProviderFailureActivity({ @@ -1149,9 +1190,20 @@ const make = Effect.gen(function* () { detail: `User message '${event.payload.messageId}' was not found for turn start request.`, turnId: null, createdAt: event.payload.createdAt, + requestId: event.payload.messageId, }); return; } + const appendTurnStartFailure = (summary: string, detail: string) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary, + detail, + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.messageId, + }); const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1163,16 +1215,7 @@ const make = Effect.gen(function* () { detail, createdAt: event.payload.createdAt, }).pipe( - Effect.flatMap(() => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail, - turnId: null, - createdAt: event.payload.createdAt, - }), - ), + Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)), Effect.asVoid, ); }; @@ -1242,9 +1285,11 @@ const make = Effect.gen(function* () { yield* ensureThreadWorktree(thread); - const isFirstUserMessageTurn = - thread.messages.filter((entry) => entry.role === "user").length === 1; - if (isFirstUserMessageTurn) { + const isCompactCommand = isCompactCommandMessage(message); + const nonCompactUserMessageCount = thread.messages.filter( + (entry) => entry.role === "user" && !isCompactCommandMessage(entry), + ).length; + if (nonCompactUserMessageCount === 1 && !isCompactCommand) { const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ @@ -1273,6 +1318,98 @@ const make = Effect.gen(function* () { } } + let compactionSessionEnsured = false; + const handleCompactionFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const detail = formatFailureDetail(cause); + if (!compactionSessionEnsured) { + return setThreadSessionErrorOnTurnStartFailure({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }).pipe( + Effect.flatMap(() => appendTurnStartFailure("Context compaction failed", detail)), + Effect.asVoid, + ); + } + return appendTurnStartFailure("Context compaction failed", detail).pipe( + Effect.ensuring( + restoreCompaction(event.payload.threadId).pipe( + Effect.catchCause((restoreCause) => + Effect.logWarning("failed to restore provider session after compaction failure", { + threadId: event.payload.threadId, + cause: Cause.pretty(restoreCause), + }), + ), + ), + ), + Effect.asVoid, + ); + }; + const recoverCompactionFailure = (cause: Cause.Cause) => + handleCompactionFailure(cause).pipe( + Effect.catchCause((recoveryCause) => + Effect.logWarning("provider command reactor failed to recover compaction failure", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(recoveryCause), + originalCause: Cause.pretty(cause), + }), + ), + ); + if (isCompactCommand) { + if (nonCompactUserMessageCount === 0) { + return yield* appendTurnStartFailure( + "Context compaction failed", + "Context compaction requires an existing conversation.", + ); + } + const latestThread = yield* resolveThread(event.payload.threadId); + if ( + compactingThreadIds.has(event.payload.threadId) || + latestThread?.session?.status === "starting" || + latestThread?.session?.status === "running" + ) { + yield* appendTurnStartFailure( + "Context compaction failed", + "Context compaction is unavailable while a provider turn is running.", + ); + return; + } + compactingThreadIds.add(event.payload.threadId); + yield* Effect.gen(function* () { + yield* ensureSessionForThread( + event.payload.threadId, + event.payload.createdAt, + event.payload.modelSelection !== undefined + ? { modelSelection: event.payload.modelSelection, pendingTurnStart: true } + : { pendingTurnStart: true }, + ); + compactionSessionEnsured = true; + if (event.payload.modelSelection !== undefined) { + threadModelSelections.set(event.payload.threadId, event.payload.modelSelection); + } + yield* providerService.compactThread( + event.payload.threadId, + event.payload.modelSelection, + event.payload.messageId, + ); + }).pipe( + Effect.andThen(restoreCompaction(event.payload.threadId, true)), + Effect.catchCause(recoverCompactionFailure), + Effect.ensuring(Effect.sync(() => void compactingThreadIds.delete(event.payload.threadId))), + Effect.forkScoped, + ); + return; + } + if (compactingThreadIds.has(event.payload.threadId)) { + return yield* appendTurnStartFailure( + "Provider turn start failed", + "Wait for context compaction to finish before sending another message.", + ); + } const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, @@ -1293,7 +1430,7 @@ const make = Effect.gen(function* () { yield* providerService .sendTurn(sendTurnRequest.value) - .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( @@ -1488,26 +1625,59 @@ const make = Effect.gen(function* () { } const now = event.payload.createdAt; - if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); - } - - yield* setThreadSession({ - threadId: thread.id, - session: { - threadId: thread.id, - status: "stopped", - providerName: thread.session?.providerName ?? null, - ...(thread.session?.providerInstanceId !== undefined - ? { providerInstanceId: thread.session.providerInstanceId } - : {}), - runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, - activeTurnId: null, - lastError: thread.session?.lastError ?? null, - updatedAt: now, - }, - createdAt: now, - }); + const wasCompacting = compactingThreadIds.has(thread.id); + stoppingThreadIds.add(thread.id); + const clearStopping = Effect.sync(() => void stoppingThreadIds.delete(thread.id)); + yield* ( + thread.session && thread.session.status !== "stopped" + ? providerService.stopSession({ threadId: thread.id }) + : Effect.void + ).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + const detail = formatFailureDetail(cause); + return Effect.sync(() => { + stoppingThreadIds.delete(thread.id); + return wasCompacting && !compactingThreadIds.has(thread.id); + }).pipe( + Effect.flatMap((compactionSettled) => + compactionSettled ? restoreCompaction(thread.id) : Effect.void, + ), + Effect.andThen( + appendProviderFailureActivity({ + threadId: thread.id, + kind: "provider.session.stop.failed", + summary: "Provider session stop failed", + detail, + turnId: null, + createdAt: now, + }), + ), + ); + }, + onSuccess: () => + setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "stopped", + providerName: thread.session?.providerName ?? null, + ...(thread.session?.providerInstanceId !== undefined + ? { providerInstanceId: thread.session.providerInstanceId } + : {}), + runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + activeTurnId: null, + lastError: thread.session?.lastError ?? null, + updatedAt: now, + }, + createdAt: now, + }), + }), + Effect.ensuring(clearStopping), + ); }); const processDomainEvent = Effect.fn("processDomainEvent")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b490d44726c0..ef9300a196ad 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -105,6 +105,7 @@ function createProviderServiceHarness() { const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), + compactThread: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), @@ -3284,10 +3285,55 @@ describe("ProviderRuntimeIngestion", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; + const compactCommand = { + type: "thread.turn.start", + commandId: CommandId.make("cmd-thread-compact"), + threadId: asThreadId("thread-1"), + message: { + messageId: asMessageId("message-compact"), + role: "user", + text: "/compact", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + } satisfies OrchestrationCommand; + await harness.dispatch(compactCommand); + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-starting-compact"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { state: "starting" }, + }); + await waitForThread(harness.readModel, (entry) => entry.session?.status === "starting"); + + for (const [index, usedTokens] of [899_000, 0].entries()) { + harness.emit({ + type: "thread.token-usage.updated", + eventId: asEventId(`evt-thread-token-usage-${index}`), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { usage: { usedTokens } }, + }); + } + await waitForThread( + harness.readModel, + (entry) => + entry.activities.filter( + (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated", + ).length === 2, + ); + harness.emit({ type: "thread.state.changed", eventId: asEventId("evt-thread-compacted"), provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), @@ -3299,15 +3345,16 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction", + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-thread-compacted", ), ); const activity = thread.activities.find( - (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction", + (candidate: ProviderRuntimeTestActivity) => candidate.id === "evt-thread-compacted", ); - expect(activity?.summary).toBe("Context compacted"); + expect(activity?.summary).toBe("Compacted context 899K → 0 tokens"); expect(activity?.tone).toBe("info"); + expect(activity?.payload).toMatchObject({ requestId: "message-compact" }); }); it("projects Codex task lifecycle chunks into thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a503a5eebbcf..f78088675045 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -18,16 +18,20 @@ import { type OrchestrationThread, type OrchestrationThreadActivity, type ProviderRuntimeEvent, + RuntimeRequestId, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; @@ -251,12 +255,45 @@ function assistantSegmentMessageId(baseKey: string, segmentIndex: number): Messa function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { - if (event.type !== "thread.token-usage.updated" || event.payload.usage.usedTokens <= 0) { + if (event.type !== "thread.token-usage.updated" || event.payload.usage.usedTokens < 0) { return undefined; } return event.payload.usage; } +function compactedTokenCountsFromActivities( + activities: ReadonlyArray | undefined, +): { readonly beforeTokens: number; readonly afterTokens: number } | undefined { + const lastCompactionIndex = activities?.findLastIndex( + (activity) => activity.kind === "context-compaction", + ); + const lastCompaction = + lastCompactionIndex !== undefined && lastCompactionIndex >= 0 + ? activities?.[lastCompactionIndex] + : undefined; + const activitiesSinceLastCompaction = activities?.slice((lastCompactionIndex ?? -1) + 1) ?? []; + const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => { + if (activity.kind !== "context-window.updated") return []; + if (lastCompaction !== undefined) { + const isAfterLastCompaction = + activity.sequence !== undefined && lastCompaction.sequence !== undefined + ? activity.sequence > lastCompaction.sequence + : activity.createdAt > lastCompaction.createdAt; + if (!isAfterLastCompaction) return []; + } + const payload = Predicate.isObject(activity.payload) ? activity.payload : undefined; + return Predicate.isNumber(payload?.usedTokens) && payload.usedTokens >= 0 + ? [payload.usedTokens] + : []; + }); + const beforeTokens = usedTokens.at(-2); + const afterTokens = usedTokens.at(-1); + if (beforeTokens === undefined || afterTokens === undefined || afterTokens >= beforeTokens) { + return undefined; + } + return { beforeTokens, afterTokens }; +} + function normalizeRuntimeTurnState( value: string | undefined, ): "completed" | "failed" | "interrupted" | "cancelled" { @@ -754,15 +791,24 @@ export function runtimeEventToActivities( return []; } + const beforeTokens = event.payload.beforeTokens; + const afterTokens = event.payload.afterTokens; + const summary = + beforeTokens !== undefined && afterTokens !== undefined + ? `Compacted context ${formatTokens(beforeTokens)} → ${formatTokens(afterTokens)} tokens` + : "Context compacted"; return [ { id: event.eventId, createdAt: event.createdAt, tone: "info", kind: "context-compaction", - summary: "Context compacted", + summary, payload: { state: event.payload.state, + ...(beforeTokens !== undefined ? { beforeTokens } : {}), + ...(afterTokens !== undefined ? { afterTokens } : {}), + ...(event.requestId !== undefined ? { requestId: event.requestId } : {}), ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -1532,13 +1578,16 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const isCompactedThreadState = + event.type === "thread.state.changed" && event.payload.state === "compacted"; const pendingTurnStart = event.type === "session.started" || event.type === "session.state.changed" || event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + isCompactedThreadState ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ threadId: thread.id, }) @@ -2057,7 +2106,58 @@ const make = Effect.gen(function* () { } } - const activities = runtimeEventToActivities(event, taskTitle); + let activityEvent = event; + if ( + isCompactedThreadState && + event.requestId === undefined && + Option.isSome(pendingTurnStart) && + thread.session?.status === "starting" && + activeTurnId === null && + sameId(thread.session.providerName, event.provider) && + sameId(thread.session.providerInstanceId, event.providerInstanceId) && + DateTime.isGreaterThanOrEqualTo( + DateTime.makeUnsafe(event.createdAt), + DateTime.makeUnsafe(pendingTurnStart.value.requestedAt), + ) + ) { + const pendingMessage = (yield* getLoadedThreadDetail())?.messages.find( + (message) => message.id === pendingTurnStart.value.messageId, + ); + if ( + pendingMessage?.role === "user" && + (pendingMessage.attachments?.length ?? 0) === 0 && + pendingMessage.text.trim().toLowerCase() === "/compact" + ) { + activityEvent = { + ...event, + requestId: RuntimeRequestId.make(String(pendingTurnStart.value.messageId)), + }; + } + } + if ( + activityEvent.type === "thread.state.changed" && + activityEvent.payload.state === "compacted" && + (activityEvent.payload.beforeTokens === undefined || + activityEvent.payload.afterTokens === undefined) + ) { + const threadDetail = yield* resolveThreadDetail(thread.id, [ + "context-window.updated", + "context-compaction", + ]); + const tokenCounts = compactedTokenCountsFromActivities(threadDetail?.activities); + if (tokenCounts) { + activityEvent = { + ...activityEvent, + payload: { + ...activityEvent.payload, + beforeTokens: activityEvent.payload.beforeTokens ?? tokenCounts.beforeTokens, + afterTokens: activityEvent.payload.afterTokens ?? tokenCounts.afterTokens, + }, + }; + } + } + + const activities = runtimeEventToActivities(activityEvent, taskTitle); yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index eb8f131009b4..06226d446db8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2370,7 +2370,7 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( Stream.runCollect, Effect.forkChild, ); @@ -2410,6 +2410,13 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-compacted-usage", uuid: "compact-boundary-usage", } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { post_tokens: 40 }, + session_id: "sdk-session-compacted-usage", + uuid: "compact-boundary-post-usage", + } as unknown as SDKMessage); harness.query.emit({ type: "result", subtype: "success", @@ -2433,6 +2440,14 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const compactionEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "thread.state.changed" && event.payload.state === "compacted", + ); + assert.equal(compactionEvents[0]?.payload.beforeTokens, 200); + assert.equal(compactionEvents[0]?.payload.afterTokens, 40); + assert.equal(compactionEvents[1]?.payload.beforeTokens, undefined); + assert.equal(compactionEvents[1]?.payload.afterTokens, 40); const finalUsageEvent = runtimeEvents.findLast( (event) => event.type === "thread.token-usage.updated", ); @@ -2440,7 +2455,6 @@ describe("ClaudeAdapterLive", () => { if (finalUsageEvent?.type === "thread.token-usage.updated") { assert.deepEqual(finalUsageEvent.payload.usage, { usedTokens: 40, - lastUsedTokens: 200, totalProcessedTokens: 450, maxTokens: 200000, }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 24a7fb28fd6a..d0569ec94f9a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -624,12 +624,17 @@ function compactBoundaryTokenUsageSnapshot( } const preTokens = finiteNonNegativeInteger(compactMetadata.pre_tokens); - return makeClaudeTokenUsageSnapshot({ + const snapshot = makeClaudeTokenUsageSnapshot({ activeTokens: postTokens, ...(preTokens !== undefined ? { lastUsedTokens: preTokens } : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), }); + if (snapshot === undefined || preTokens !== undefined) { + return snapshot; + } + const { lastUsedTokens: _lastUsedTokens, ...snapshotWithoutBeforeTokens } = snapshot; + return snapshotWithoutBeforeTokens; } function normalizeClaudeTaskProgressTokenUsage( @@ -3203,32 +3208,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "compact_boundary": + case "compact_boundary": { if (context.turnState) { context.turnState.latestAssistantUsage = undefined; context.turnState.compactedSinceLatestAssistantUsage = true; } - yield* emitThreadTokenUsage( - context, - compactBoundaryTokenUsageSnapshot( - message as unknown as Record, - context.lastKnownContextWindow, - context.lastKnownTotalProcessedTokens, - ), - { - rawMethod: "claude/system/compact_boundary", - rawPayload: message, - }, + const compactedUsage = compactBoundaryTokenUsageSnapshot( + message as unknown as Record, + context.lastKnownContextWindow, + context.lastKnownTotalProcessedTokens, ); + yield* emitThreadTokenUsage(context, compactedUsage, { + rawMethod: "claude/system/compact_boundary", + rawPayload: message, + }); yield* offerRuntimeEvent({ ...base, type: "thread.state.changed", payload: { state: "compacted", + ...(compactedUsage?.lastUsedTokens !== undefined + ? { beforeTokens: compactedUsage.lastUsedTokens } + : {}), + ...(compactedUsage?.usedTokens !== undefined + ? { afterTokens: compactedUsage.usedTokens } + : {}), detail: message, }, }); return; + } case "hook_started": yield* offerRuntimeEvent({ ...base, @@ -3790,7 +3799,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Same reason as the approvals above: a request nobody can answer any more // must not stay open, or the thread can never be settled. - for (const pending of [...context.pendingUserInputs.values()]) { + for (const pending of context.pendingUserInputs.values()) { yield* pending.cancel; } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index bb60327ada0e..e4ec8c522da7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -24,6 +24,7 @@ import { import { buildServerProvider, + COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, isCommandMissingCause, parseGenericCliVersion, @@ -529,13 +530,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = [ - { - name: "compact", - description: "Summarize the conversation and reduce context usage", - }, - ...(capabilities?.slashCommands ?? []), - ]; + const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index fbfc48c53827..eefd7f05afa6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); + public readonly compactThread = Effect.void; + public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise => Promise.resolve(undefined), ); @@ -334,6 +336,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("compacts the active Codex thread and emits compacted state", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-compact"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + const compactedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "thread.state.changed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.compactThread!(threadId); + yield* runtime.emit({ + id: asEventId("evt-compaction-item-completed"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId, + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "provider-thread-1", + turnId: "provider-compact-turn", + item: { + id: "provider-compact-item", + type: "contextCompaction", + }, + }, + }); + const event = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); + NodeAssert.ok(event.type === "thread.state.changed"); + NodeAssert.equal(event.payload.state, "compacted"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("uploads feedback for the active Codex thread", () => 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 b17200d36bea..0dd0d6fc271f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1485,7 +1485,18 @@ function mapToRuntimeEvents( ]; } const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); - return completed ? [completed] : []; + if (!completed || itemType !== "context_compaction") { + return completed ? [completed] : []; + } + return [ + completed, + { + ...runtimeEventBase(event, canonicalThreadId), + eventId: EventId.make(`${event.id}:thread-compacted`), + type: "thread.state.changed", + payload: { state: "compacted" }, + }, + ]; } if ( @@ -2216,6 +2227,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); + const compactThread: NonNullable = Effect.fn("compactThread")( + function* (threadId) { + const session = yield* requireSession(threadId); + yield* session.runtime.compactThread.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), + ); + }, + ); + const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.readThread), @@ -2351,6 +2371,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }, startSession, sendTurn, + compactThread, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 9ffd6ccff9ec..d180e10dbcfd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, + COMPACT_SLASH_COMMAND, type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; @@ -690,6 +691,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu models: snapshot.models, skills: snapshot.skills, slashCommands: [ + COMPACT_SLASH_COMMAND, { name: "feedback", description: "Send this thread and Codex logs to OpenAI", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index d83489763f5c..4b88b7ce01c0 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape { readonly sendTurn: ( input: CodexSessionRuntimeSendTurnInput, ) => Effect.Effect; + readonly compactThread: Effect.Effect; readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; readonly readThread: Effect.Effect; readonly rollbackThread: ( @@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = ( return { start, getSession: Ref.get(sessionRef), + compactThread: Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + yield* client.request("thread/compact/start", { threadId: providerThreadId }); + }), sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5c..e6c7853844e0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -34,6 +34,7 @@ import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, + COMPACT_SLASH_COMMAND, collectStreamAsString, isCommandMissingCause, providerModelsFromSettings, @@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: { input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), + slashCommands: [COMPACT_SLASH_COMMAND], probe: { installed: true, version: input.parsed.version, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 0bf02ab5eec3..50a881f38897 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, + COMPACT_SLASH_COMMAND, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -499,6 +500,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models, skills, + slashCommands: [COMPACT_SLASH_COMMAND], probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 01baf92db73e..7210aae75d69 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -79,6 +79,7 @@ const runtimeMock = { messageCalls: [] as Array<{ sessionID: string; messageID: string }>, messageFailures: 0, promptCalls: [] as Array, + summarizeCalls: [] as Array, promptAsyncError: null as Error | null, promptAsyncImplementation: null as (() => Promise) | null, autoPromptEcho: true, @@ -130,6 +131,7 @@ const runtimeMock = { this.state.messageCalls.length = 0; this.state.messageFailures = 0; this.state.promptCalls.length = 0; + this.state.summarizeCalls.length = 0; this.state.promptAsyncError = null; this.state.promptAsyncImplementation = null; this.state.autoPromptEcho = true; @@ -315,6 +317,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }); } }, + summarize: async (input: unknown) => { + runtimeMock.state.summarizeCalls.push(input); + return { data: true }; + }, messages: async () => ({ data: runtimeMock.state.messages }), message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { runtimeMock.state.messageCalls.push({ sessionID, messageID }); @@ -954,6 +960,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("compacts through the native OpenCode session API", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-compact"); + runtimeMock.state.subscribedEvents.push({ + type: "session.compacted", + properties: { sessionID: "http://127.0.0.1:9999/session" }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.compactThread!( + threadId, + createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), + ); + const summarizeCall = runtimeMock.state.summarizeCalls[0] as Record; + NodeAssert.equal(summarizeCall.modelID, "gpt-5"); + const events = Array.from(yield* Fiber.join(eventsFiber)); + yield* adapter.stopSession(threadId); + const compacted = events.some( + (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", + ); + NodeAssert.equal(compacted, true); + }), + ); it.effect("falls back to a fresh session when the persisted session is gone", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 6d94e0c09a04..b6cfa4e366ca 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2070,6 +2070,21 @@ export function makeOpenCodeAdapter( } break; } + case "session.compacted": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "thread.state.changed", + payload: { + state: "compacted", + detail: event, + }, + }); + break; + } case "message.updated": { const promptAdmission = context.promptAdmission; @@ -3041,6 +3056,71 @@ export function makeOpenCodeAdapter( ); }); + const compactThread: NonNullable = Effect.fn( + "compactThread", + )(function* (threadId, requestedModelSelection) { + const context = yield* ensureSessionContext(sessions, threadId); + yield* awaitOpenCodeContextReady(context); + const modelSelection = + requestedModelSelection ?? + (context.session.model + ? { instanceId: boundInstanceId, model: context.session.model } + : undefined); + if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "compactThread", + issue: `OpenCode model selection is bound to instance '${modelSelection.instanceId}', expected '${boundInstanceId}'.`, + }); + } + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "compactThread", + issue: "OpenCode compaction requires an active 'provider/model' selection.", + }); + } + yield* context.promptSemaphore.withPermit( + Effect.gen(function* () { + if (sessions.get(threadId) !== context || (yield* Ref.get(context.stopped))) { + return yield* Effect.interrupt; + } + if (context.activeTurnId !== undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "compactThread", + issue: "OpenCode cannot compact while a turn is running.", + }); + } + yield* runOpenCodeSdk("session.summarize", (signal) => + context.client.session.summarize( + { + sessionID: context.openCodeSessionId, + ...parsedModel, + auto: false, + }, + { signal }, + ), + ).pipe( + Effect.timeout("10 minutes"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.summarize", + detail: "OpenCode session compaction did not complete within 10 minutes.", + cause, + }), + ), + }), + Effect.asVoid, + ); + }), + ); + }); const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); @@ -3325,6 +3405,7 @@ export function makeOpenCodeAdapter( }, startSession, sendTurn, + compactThread, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 7f131a54b330..7fc33d2bb9f2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, + COMPACT_SLASH_COMMAND, nonEmptyTrimmed, parseGenericCliVersion, providerModelsFromSettings, @@ -525,6 +526,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu checkedAt, models, skills, + slashCommands: [COMPACT_SLASH_COMMAND], probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6816df464fb8..a3bcb029698b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -49,6 +49,7 @@ import { import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; +import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -385,7 +386,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te shortDescription: "Debug failing GitHub Actions checks", }, ]); - assert.deepStrictEqual(status.slashCommands, [ + assert.deepStrictEqual(status.slashCommands.slice(1), [ { name: "feedback", description: "Send this thread and Codex logs to OpenAI", @@ -2550,11 +2551,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - assert.deepStrictEqual(status.slashCommands, [ - { - name: "compact", - description: "Summarize the conversation and reduce context usage", - }, + assert.deepStrictEqual(status.slashCommands.slice(1), [ { name: "review", description: "Review a pull request", @@ -2598,10 +2595,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ - { - name: "compact", - description: "Summarize the conversation and reduce context usage", - }, + COMPACT_SLASH_COMMAND, { name: "ui", description: "Explore and refine UI", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f61e9054578b..fb997b1ab82a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -175,6 +175,18 @@ function makeFakeCodexAdapter( Effect.void, ); + const compactThread = vi.fn((threadId: ThreadId) => + Effect.sync(() => + emit({ + type: "thread.state.changed", + eventId: asEventId("evt-native-compact"), + provider, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + payload: { state: "compacted" }, + }), + ), + ); const respondToRequest = vi.fn( ( _threadId: ThreadId, @@ -251,6 +263,7 @@ function makeFakeCodexAdapter( }, startSession, sendTurn, + ...(provider === CODEX_DRIVER ? { compactThread } : {}), interruptTurn, respondToRequest, respondToUserInput, @@ -287,6 +300,7 @@ function makeFakeCodexAdapter( updateSession, startSession, sendTurn, + compactThread, interruptTurn, respondToRequest, respondToUserInput, @@ -1215,6 +1229,243 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("marks a successful fallback compaction as compacted", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-compact-cursor"); + yield* provider.startSession(threadId, { + provider: CURSOR_DRIVER, + providerInstanceId: ProviderInstanceId.make("cursor"), + threadId, + runtimeMode: "full-access", + }); + const compactedEventFiber = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.type === "thread.state.changed"), + Stream.runHead, + Effect.forkChild, + ); + const requestId = MessageId.make("message-compact-cursor"); + const compactFiber = yield* provider + .compactThread(threadId, undefined, requestId) + .pipe(Effect.forkChild); + yield* advanceTestClock(50); + routing.cursor.emit({ + type: "turn.completed", + eventId: asEventId("evt-cursor-stale-turn-completed"), + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:00:00.500Z", + threadId, + turnId: asTurnId("turn-before-compaction"), + payload: { state: "completed" }, + }); + yield* Effect.yieldNow; + assert.equal(compactFiber.pollUnsafe(), undefined); + routing.cursor.emit({ + type: "turn.completed", + eventId: asEventId("evt-cursor-compact-completed"), + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:00:01.000Z", + threadId, + turnId: asTurnId(`turn-${threadId}`), + payload: { state: "completed" }, + }); + yield* Fiber.join(compactFiber); + + const compacted = yield* Fiber.join(compactedEventFiber); + assert.equal(compacted._tag, "Some"); + if (Option.isSome(compacted)) { + assert.equal(compacted.value.requestId, String(requestId)); + } + + const observedEvents = yield* Ref.make>([]); + const observedEventsFiber = yield* provider.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + event.type === "thread.state.changed" && + event.payload.state === "compacted", + ), + Stream.runForEach((event) => Ref.update(observedEvents, (events) => [...events, event])), + Effect.forkChild, + ); + const observedRequestId = MessageId.make("message-observed-compact-cursor"); + const observedCompactFiber = yield* provider + .compactThread(threadId, undefined, observedRequestId) + .pipe(Effect.forkChild); + yield* advanceTestClock(50); + routing.cursor.emit({ + type: "thread.state.changed", + eventId: asEventId("evt-cursor-provider-compacted"), + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:00:02.000Z", + threadId, + turnId: asTurnId(`turn-${threadId}`), + payload: { state: "compacted" }, + }); + routing.cursor.emit({ + type: "turn.completed", + eventId: asEventId("evt-cursor-observed-compact-completed"), + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:00:03.000Z", + threadId, + turnId: asTurnId(`turn-${threadId}`), + payload: { state: "completed" }, + }); + yield* Fiber.join(observedCompactFiber); + yield* Effect.yieldNow; + const observed = yield* Ref.get(observedEvents); + assert.equal(observed.length, 1); + assert.equal(observed[0]?.requestId, String(observedRequestId)); + yield* Fiber.interrupt(observedEventsFiber); + + const failedStartEventId = asEventId("evt-cursor-failed-compact-start"); + const failedStartEventFiber = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.eventId === failedStartEventId), + Stream.runHead, + Effect.forkChild, + ); + routing.cursor.sendTurn.mockImplementationOnce((input) => + Effect.gen(function* () { + routing.cursor.emit({ + type: "turn.completed", + eventId: failedStartEventId, + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:00:04.000Z", + threadId: input.threadId, + turnId: asTurnId("turn-cursor-failed-compact-start"), + payload: { state: "failed" }, + }); + yield* Effect.yieldNow; + return yield* new ProviderAdapterRequestError({ + provider: String(CURSOR_DRIVER), + method: "turn/start", + detail: "Failed after emitting a terminal event.", + }); + }), + ); + const failedStart = yield* provider.compactThread(threadId).pipe(Effect.result); + assert.equal(failedStart._tag, "Failure"); + assert.equal(Option.isSome(yield* Fiber.join(failedStartEventFiber)), true); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("serializes native compaction and quarantines timed-out completions", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-compact-timeout"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.codex.compactThread.mockClear(); + routing.codex.compactThread.mockImplementationOnce(() => Effect.never); + + const resultFiber = yield* provider + .compactThread(threadId) + .pipe(Effect.result, Effect.forkChild); + yield* advanceTestClock(50); + const concurrent = yield* provider.compactThread(threadId).pipe(Effect.result); + assert.equal(concurrent._tag, "Failure"); + assert.equal(routing.codex.compactThread.mock.calls.length, 1); + + routing.cursor.emit({ + type: "thread.state.changed", + eventId: asEventId("evt-stale-provider-compact"), + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:00:00.100Z", + threadId, + payload: { state: "compacted" }, + }); + yield* Effect.yieldNow; + assert.equal(resultFiber.pollUnsafe(), undefined); + + yield* advanceTestClock(600_001); + const result = yield* Fiber.join(resultFiber); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterRequestError"); + } + + const blockedRetry = yield* provider.compactThread(threadId).pipe(Effect.result); + assert.equal(blockedRetry._tag, "Failure"); + assert.equal(routing.codex.compactThread.mock.calls.length, 1); + + routing.codex.emit({ + type: "thread.state.changed", + eventId: asEventId("evt-native-compact-late"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:10:01.000Z", + threadId, + payload: { state: "compacted" }, + }); + yield* Effect.yieldNow; + yield* provider.compactThread(threadId); + assert.equal(routing.codex.compactThread.mock.calls.length, 2); + + routing.codex.compactThread.mockImplementationOnce(() => Effect.void); + const stoppedResultFiber = yield* provider + .compactThread(threadId) + .pipe(Effect.result, Effect.forkChild); + yield* advanceTestClock(50); + yield* provider.stopSession({ threadId }); + const stoppedResult = yield* Fiber.join(stoppedResultFiber); + assert.equal(stoppedResult._tag, "Failure"); + + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* provider.compactThread(threadId); + assert.equal(routing.codex.compactThread.mock.calls.length, 4); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("times out fallback compaction when its turn never settles", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-compact-fallback-timeout"); + yield* provider.startSession(threadId, { + provider: CURSOR_DRIVER, + providerInstanceId: ProviderInstanceId.make("cursor"), + threadId, + runtimeMode: "full-access", + }); + + const resultFiber = yield* provider + .compactThread(threadId) + .pipe(Effect.result, Effect.forkChild); + yield* advanceTestClock(600_001); + const result = yield* Fiber.join(resultFiber); + assert.equal(result._tag, "Failure"); + + routing.cursor.sendTurn.mockImplementationOnce((input) => + Effect.succeed({ + threadId: input.threadId, + turnId: asTurnId("turn-compact-fallback-retry"), + }), + ); + const retryFiber = yield* provider.compactThread(threadId).pipe(Effect.forkChild); + yield* advanceTestClock(50); + routing.cursor.emit({ + type: "turn.completed", + eventId: asEventId("evt-compact-fallback-retry-completed"), + provider: CURSOR_DRIVER, + createdAt: "2026-01-01T00:10:02.000Z", + threadId, + turnId: asTurnId("turn-compact-fallback-retry"), + payload: { state: "completed" }, + }); + yield* Fiber.join(retryFiber); + yield* provider.stopSession({ threadId }); + }), + ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8ddba586f191..7171430d636b 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -10,16 +10,20 @@ * @module ProviderServiceLive */ import { + EventId, + MessageId, ModelSelection, NonNegativeInt, - ThreadId, ProviderInterruptTurnInput, ProviderRespondToRequestInput, ProviderRespondToUserInputInput, + RuntimeRequestId, ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, ProviderUploadFeedbackInput, + ThreadId, + TurnId, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -28,6 +32,7 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -49,6 +54,7 @@ import { providerTurnMetricAttributes, withMetrics, } from "../../observability/Metrics.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; @@ -62,6 +68,16 @@ import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; const isModelSelection = Schema.is(ModelSelection); +interface PendingCompaction { + readonly completion: Deferred.Deferred; + readonly native: boolean; + readonly providerInstanceId: ProviderInstanceId; + readonly requestId: MessageId | undefined; + readonly earlyEvents: ProviderRuntimeEvent[]; + compactedEventObserved: boolean; + expectedTurnId: TurnId | undefined; +} + /** * Hook for tests that want to override the canonical event logger pulled * from `ProviderEventLoggers`. Production wiring leaves this undefined and @@ -233,6 +249,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const revokeMcpCredential = options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const runtimeEventPubSub = yield* PubSub.unbounded(); + const pendingCompactions = new Map(); + const timedOutNativeCompactions = new Set(); + const settleCompaction = (threadId: ThreadId, pending: PendingCompaction, terminal: string) => + Effect.gen(function* () { + if (pendingCompactions.get(threadId) !== pending) return false; + pendingCompactions.delete(threadId); + yield* Deferred.succeed(pending.completion, terminal); + return true; + }); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** * Attach the `t3-code` MCP server to the session that is about to start. @@ -295,6 +320,65 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.asVoid, ); + const isCompactedEvent = ( + event: ProviderRuntimeEvent, + ): event is Extract => + event.type === "thread.state.changed" && event.payload.state === "compacted"; + const withCompactionRequestId = ( + event: ProviderRuntimeEvent, + pending: PendingCompaction, + ): ProviderRuntimeEvent => + pending.requestId === undefined + ? event + : { + ...event, + requestId: RuntimeRequestId.make(String(pending.requestId)), + }; + const compactionTerminal = (event: ProviderRuntimeEvent): string | null => + event.type === "turn.completed" + ? event.payload.state + : event.type === "runtime.error" || event.type === "turn.aborted" + ? event.type + : null; + const processFallbackCompactionEvent = ( + pending: PendingCompaction, + event: ProviderRuntimeEvent, + ): Effect.Effect => + Effect.gen(function* () { + if (pendingCompactions.get(event.threadId) !== pending) { + yield* publishRuntimeEvent(event); + return; + } + const matchesTurn = event.turnId !== undefined && event.turnId === pending.expectedTurnId; + if (matchesTurn && isCompactedEvent(event)) { + pending.compactedEventObserved = true; + yield* publishRuntimeEvent(withCompactionRequestId(event, pending)); + return; + } + yield* publishRuntimeEvent(event); + const terminal = compactionTerminal(event); + if (!matchesTurn || terminal === null) return; + const settled = yield* settleCompaction(event.threadId, pending, terminal); + if (!settled || terminal !== "completed" || pending.compactedEventObserved) return; + const compactedEvent = { + ...event, + eventId: EventId.make(`${event.eventId}:context-compaction`), + type: "thread.state.changed", + payload: { + state: "compacted", + detail: { source: "provider-native-command" }, + }, + ...(pending.requestId !== undefined + ? { requestId: RuntimeRequestId.make(String(pending.requestId)) } + : {}), + } satisfies ProviderRuntimeEvent; + yield* increment(providerRuntimeEventsTotal, { + provider: compactedEvent.provider, + eventType: compactedEvent.type, + }); + yield* publishRuntimeEvent(compactedEvent); + }); + const requireBindingInstanceId = ( operation: string, payload: { @@ -345,14 +429,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, event: ProviderRuntimeEvent, ): Effect.Effect => - Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( - Effect.flatMap((canonicalEvent) => - increment(providerRuntimeEventsTotal, { - provider: canonicalEvent.provider, - eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), - ), - ); + Effect.gen(function* () { + const canonicalEvent = yield* Effect.sync(() => + correlateRuntimeEventWithInstance(source, event), + ); + yield* increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }); + if ( + isCompactedEvent(canonicalEvent) && + timedOutNativeCompactions.delete(canonicalEvent.threadId) + ) { + yield* publishRuntimeEvent(canonicalEvent); + return; + } + const pendingCompaction = pendingCompactions.get(canonicalEvent.threadId); + if (!pendingCompaction) { + yield* publishRuntimeEvent(canonicalEvent); + return; + } + if (pendingCompaction.providerInstanceId !== source.instanceId) { + yield* publishRuntimeEvent(canonicalEvent); + return; + } + if (pendingCompaction.native) { + const compacted = isCompactedEvent(canonicalEvent); + const terminal = compacted ? "completed" : compactionTerminal(canonicalEvent); + yield* publishRuntimeEvent( + compacted ? withCompactionRequestId(canonicalEvent, pendingCompaction) : canonicalEvent, + ); + if (terminal !== null) + yield* settleCompaction(canonicalEvent.threadId, pendingCompaction, terminal); + return; + } + if ( + pendingCompaction.expectedTurnId === undefined && + canonicalEvent.turnId !== undefined && + (isCompactedEvent(canonicalEvent) || compactionTerminal(canonicalEvent) !== null) + ) { + pendingCompaction.earlyEvents.push(canonicalEvent); + return; + } + yield* processFallbackCompactionEvent(pendingCompaction, canonicalEvent); + }); // `subscribedAdapters` is our source-of-truth for "which instance adapters // are currently wired into the runtime event bus". It both tracks the set @@ -708,6 +828,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( typeof input.modelSelection?.model === "string" && input.modelSelection.model.trim().length > 0, }); + timedOutNativeCompactions.delete(threadId); // Changing runtime mode restarts the session, so the transition is only // observable here, by diffing against the mode the previous session for @@ -874,6 +995,128 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const compactThread: ProviderServiceMethod<"compactThread"> = Effect.fn("compactThread")( + function* (threadId, modelSelection, requestId) { + const routed = yield* resolveRoutableSession({ + threadId, + operation: "ProviderService.compactThread", + allowRecovery: true, + }); + yield* Effect.annotateCurrentSpan({ + "provider.operation": "compact-thread", + "provider.kind": routed.adapter.provider, + "provider.thread_id": threadId, + }); + yield* McpSessionRegistry.touchActiveMcpThread(threadId); + const nativeCompaction = routed.adapter.compactThread; + const completion = yield* Deferred.make(); + const pending: PendingCompaction = { + completion, + native: nativeCompaction !== undefined, + providerInstanceId: routed.instanceId, + requestId, + earlyEvents: [], + compactedEventObserved: false, + expectedTurnId: undefined, + }; + if (nativeCompaction !== undefined && timedOutNativeCompactions.has(threadId)) { + return yield* new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: "thread/compact", + detail: + "The previous context compaction may still be running. Restart the provider session before retrying.", + }); + } + const claimed = yield* Effect.sync(() => { + if (pendingCompactions.has(threadId)) return false; + pendingCompactions.set(threadId, pending); + return true; + }); + if (!claimed) { + return yield* new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: "thread/compact", + detail: "Context compaction is already in progress.", + }); + } + const clearPending = Effect.sync(() => { + if (pendingCompactions.get(threadId) === pending) { + pendingCompactions.delete(threadId); + } + }); + const nativeCompletionTimeout = + routed.adapter.provider === "codex" || routed.adapter.provider === "opencode" + ? "10 minutes" + : "30 seconds"; + const awaitNativeCompaction = (start: Effect.Effect) => + start.pipe( + Effect.andThen(Deferred.await(completion)), + Effect.timeout(nativeCompletionTimeout), + Effect.catchTag("TimeoutError", (cause) => + Effect.sync(() => { + timedOutNativeCompactions.add(threadId); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: "thread/compact", + detail: `Provider did not report completed context compaction within ${nativeCompletionTimeout}.`, + cause, + }), + ), + ), + ), + ), + ); + const awaitFallbackCompaction = Deferred.await(completion).pipe( + Effect.timeout("10 minutes"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: "turn/start", + detail: "Provider did not finish context compaction within 10 minutes.", + cause, + }), + ), + ); + const terminal = yield* ( + nativeCompaction + ? awaitNativeCompaction(nativeCompaction(routed.threadId, modelSelection)) + : Effect.gen(function* () { + const turn = yield* sendTurn({ + threadId, + input: routed.adapter.provider === "cursor" ? "/compress" : "/compact", + ...(modelSelection !== undefined ? { modelSelection } : {}), + }).pipe( + Effect.onError(() => + Effect.forEach(pending.earlyEvents.splice(0), publishRuntimeEvent, { + discard: true, + }), + ), + ); + pending.expectedTurnId = turn.turnId; + const earlyEvents = pending.earlyEvents.splice(0); + for (const earlyEvent of earlyEvents) { + yield* processFallbackCompactionEvent(pending, earlyEvent); + } + return yield* awaitFallbackCompaction; + }) + ).pipe(Effect.ensuring(clearPending)); + if (terminal !== "completed") { + return yield* new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: nativeCompaction ? "thread/compact" : "turn/start", + detail: `Context compaction ended with ${terminal}.`, + }); + } + yield* analytics.record("provider.thread.compacted", { + provider: routed.adapter.provider, + }); + }, + ); + const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -1006,6 +1249,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } + const pendingCompaction = pendingCompactions.get(input.threadId); + if (pendingCompaction !== undefined) { + yield* settleCompaction(input.threadId, pendingCompaction, "turn.aborted"); + } + timedOutNativeCompactions.delete(input.threadId); yield* clearMcpSession(input.threadId); yield* directory.upsert({ threadId: input.threadId, @@ -1285,6 +1533,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { startSession, sendTurn, + compactThread, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index a796d1c4038a..e5d6beb25d54 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => { const providerService: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), + compactThread: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index db9b9d6a17fb..0e4d696335b0 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -70,6 +70,11 @@ export interface ProviderAdapterShape { input: ProviderSendTurnInput, ) => Effect.Effect; + readonly compactThread?: ( + threadId: ThreadId, + modelSelection?: ProviderSendTurnInput["modelSelection"], + ) => Effect.Effect; + /** * Interrupt an active turn. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 2f88d2a0271f..c189e2916ff1 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -23,6 +23,7 @@ import type { ProviderStopSessionInput, ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, + MessageId, ThreadId, ProviderTurnStartResult, } from "@t3tools/contracts"; @@ -53,6 +54,12 @@ export interface ProviderServiceShape { input: ProviderSendTurnInput, ) => Effect.Effect; + readonly compactThread: ( + threadId: ThreadId, + modelSelection?: ProviderSendTurnInput["modelSelection"], + requestId?: MessageId, + ) => Effect.Effect; + /** * Interrupt a running provider turn. */ diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index ff98ba8a00d5..55534629d3eb 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -23,6 +23,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) export const AUTH_PROBE_TIMEOUT_MS = 10_000; +export const COMPACT_SLASH_COMMAND = { + name: "compact", + description: "Summarize the conversation and reduce context usage", +} satisfies ServerProviderSlashCommand; + export interface CommandResult { readonly stdout: string; readonly stderr: string; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 1de6a4247811..9374560c4e9b 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => ({ startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), + compactThread: () => Effect.die("unused"), interruptTurn: () => Effect.die("unused"), respondToRequest: () => Effect.die("unused"), respondToUserInput: () => Effect.die("unused"), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 4cc750d45236..8e9c80641f2b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1566,6 +1566,42 @@ describe("hasServerAcknowledgedLocalDispatch", () => { expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-1", + }), + ).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); + + it("acknowledges only a new turn-start failure", () => { + const localDispatch = { + ...createLocalDispatchSnapshot(makeThread()), + latestTurnStartFailureId: "turn-start-failure-old", + }; + const common = { + localDispatch, + phase: "ready" as const, + latestTurn: null, + latestUserMessageId: localDispatch.latestUserMessageId, + session: null, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-old", + }), + ).toBe(false); + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-new", + }), + ).toBe(true); + }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 46ff8c473c6d..1a6b1b775f41 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -895,6 +895,24 @@ export interface LocalDispatchSnapshot { latestTurnCompletedAt: string | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; + latestTurnStartFailureId: string | null; +} + +export function latestTurnStartFailureId( + activeThread: Thread | undefined, + latestUserMessageId: ChatMessage["id"] | null, +): string | null { + if (latestUserMessageId === null) return null; + return ( + activeThread?.activities.findLast((activity) => { + if (activity.kind !== "provider.turn.start.failed") return false; + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as { readonly requestId?: unknown }) + : null; + return payload?.requestId === latestUserMessageId; + })?.id ?? null + ); } export function createLocalDispatchSnapshot( @@ -918,6 +936,7 @@ export function createLocalDispatchSnapshot( latestTurnCompletedAt: latestTurn?.completedAt ?? null, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, + latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null), }; } @@ -929,6 +948,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { session: Thread["session"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; + latestTurnStartFailureId?: string | null; threadError: string | null | undefined; }): boolean { if (!input.localDispatch) { @@ -937,6 +957,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if ( + input.latestTurnStartFailureId !== undefined && + input.latestTurnStartFailureId !== null && + input.latestTurnStartFailureId !== input.localDispatch.latestTurnStartFailureId + ) { + return true; + } if (input.phase === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ba53f47b6a4..22e1ccd58e5f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -209,8 +209,8 @@ import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - sortProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, @@ -330,7 +330,7 @@ import { import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { - hasAvailableClaudeCompactionProvider, + hasAvailableCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -357,6 +357,7 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, + latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -626,6 +627,11 @@ function formatOutgoingPrompt(params: { const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + type ChatViewProps = | { environmentId: EnvironmentId; @@ -669,6 +675,10 @@ function useLocalDispatchState(input: { (message) => message.role === "user", ); const latestUserMessageId = latestUserMessage?.id ?? null; + const currentTurnStartFailureId = + localDispatch === null + ? null + : latestTurnStartFailureId(input.activeThread, latestUserMessageId); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -684,6 +694,7 @@ function useLocalDispatchState(input: { session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, + latestTurnStartFailureId: currentTurnStartFailureId, threadError: input.threadError, }), [ @@ -694,6 +705,7 @@ function useLocalDispatchState(input: { input.phase, input.threadError, latestUserMessageId, + currentTurnStartFailureId, localDispatch, ], ); @@ -2587,7 +2599,33 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const optimisticCompactionMessage = optimisticUserMessages.at(-1); + const pendingCompactionMessage = + isSendBusy && + optimisticCompactionMessage !== undefined && + isCompactCommandMessage(optimisticCompactionMessage) + ? optimisticCompactionMessage + : activeThread?.messages.findLast(isCompactCommandMessage); + const compactRequestIsActive = + pendingCompactionMessage !== undefined && + (pendingCompactionMessage.createdAt > + (activeLatestTurn?.requestedAt ?? pendingCompactionMessage.createdAt) || + (activeLatestTurn?.state === "running" && + pendingCompactionMessage.createdAt === activeLatestTurn.requestedAt)); + const compactionSettled = + pendingCompactionMessage !== undefined && + (latestTurnStartFailureId(activeThread, pendingCompactionMessage.id) !== null || + activeThread?.activities.some((activity) => { + if (activity.kind !== "context-compaction") return false; + const payload = activity.payload as { readonly requestId?: unknown } | null | undefined; + return payload?.requestId === pendingCompactionMessage.id; + })); + const isCompacting = + (isSendBusy || phase === "connecting" || phase === "running") && + compactRequestIsActive && + !compactionSettled; + const isWorking = + phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2957,10 +2995,11 @@ function ChatViewContent(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const compactionProviderAvailable = useMemo( + const manualCompactionProviderAvailable = useMemo( () => - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers: providerInstanceEntries, + driverKind: selectedProvider, instanceId: activeProviderInstanceId, lockedInstanceId: lockedProvider ? (activeThread?.session?.providerInstanceId ?? @@ -2974,6 +3013,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session?.providerInstanceId, lockedProvider, providerInstanceEntries, + selectedProvider, ], ); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = @@ -5393,12 +5433,16 @@ function ChatViewContent(props: ChatViewProps) { activeThread && activeContextWindow ? `${activeThread.id}:${activeContextWindow.updatedAt}` : null; - const compactDisabled = + const activeThreadHasCompactableConversation = + activeThread?.messages.some( + (message) => message.role === "user" && !isCompactCommandMessage(message), + ) ?? false; + const compactThreadUnavailable = !activeThread || + !activeThreadHasCompactableConversation || !activeProject || !isServerThread || - selectedProvider !== "claudeAgent" || - !compactionProviderAvailable || + !manualCompactionProviderAvailable || isWorking || threadDetailLoading || isPreparingWorktree || @@ -5406,15 +5450,15 @@ function ChatViewContent(props: ChatViewProps) { feedbackUploading || pendingApprovals.length > 0 || pendingUserInputs.length > 0 || - showPlanFollowUpPrompt || - composerHasUnsentContent; + showPlanFollowUpPrompt; + const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; const compactDisabledReason = compactDisabled ? composerHasUnsentContent ? "Send or clear your draft before compacting" : !activeProject ? "Choose a project before compacting" - : !compactionProviderAvailable - ? "Enable a Claude provider before compacting" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { @@ -7703,6 +7747,7 @@ function ChatViewContent(props: ChatViewProps) { key={activeThread.id} isWorking={isWorking} isPreparingWorktree={isPreparingWorktree} + isCompacting={isCompacting} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} @@ -7862,6 +7907,7 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} + compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d5bf8524da3b..a8d43e7b0a1f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -186,7 +186,10 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; +import { + providerSupportsManualCompaction, + resolveContextWindowModelDisplayName, +} from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, buildExpandedImagePreview, @@ -1208,6 +1211,7 @@ export interface ChatComposerProps { // Context window activeContextWindow: ContextWindowSnapshot | null; + compactThreadUnavailable: boolean; compactDisabled: boolean; compactDisabledReason: string | null; @@ -1313,6 +1317,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProjectDefaultModelSelection, activeThreadModelSelection, activeContextWindow, + compactThreadUnavailable, compactDisabled, compactDisabledReason, resolvedTheme, @@ -1603,6 +1608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry); const selectedProviderSkills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) : []; @@ -1840,7 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, ], ); - // ------------------------------------------------------------------ // Derived: composer trigger / menu // ------------------------------------------------------------------ @@ -1852,6 +1857,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const compactSlashCommandAvailable = + composerTrigger?.kind === "slash-command" && + prompt.slice(0, composerTrigger.rangeStart).trim() === "" && + !compactThreadUnavailable && + prompt.slice(composerTrigger.rangeEnd).trim() === "" && + composerImages.length + composerFiles.length === 0 && + composerDraft.persistedAttachments.length === 0 && + composerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -1920,8 +1936,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); + const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( + (item) => item.command.name !== "compact" || compactSlashCommandAvailable, + ); const slashCommandItems = slashCommandItemsForPromptPosition( - [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems], + [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], composerTrigger.rangeStart === 0, ); return searchSlashCommandItems(slashCommandItems, query); @@ -1941,6 +1960,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return []; }, [ + compactSlashCommandAvailable, composerTrigger, planModeUiEnabled, selectedProvider, @@ -2843,7 +2863,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if ( compactDisabled || noProviderAvailable || - composerSendState.hasSendableContent || activePendingApproval !== null || pendingUserInputs.length > 0 || phase === "running" || @@ -2881,7 +2900,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, compactDisabled, composerDraftTarget, - composerSendState.hasSendableContent, isConnecting, isSendBusy, noProviderAvailable, @@ -5455,9 +5473,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactDisabled || noProviderAvailable || isSendBusy || isConnecting } compactDisabledReason={resolvedCompactDisabledReason} - {...(selectedProvider === "claudeAgent" - ? { onCompactContext: compactThreadContext } - : {})} + {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} /> diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index 032076c0b74b..b4c4d2217565 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { formatContextWindowCompactionMessage, - hasAvailableClaudeCompactionProvider, + hasAvailableCompactionProvider, hasDismissedResumeCompaction, resolveContextWindowModelDisplayName, shouldOfferResumeCompaction, @@ -25,12 +25,12 @@ function claudeProvider(input: { auth: { status: "authenticated" }, checkedAt: "2026-08-24T12:00:00.000Z", models: [], - slashCommands: [], + slashCommands: [{ name: "compact", description: "" }], skills: [], }; } -describe("hasAvailableClaudeCompactionProvider", () => { +describe("hasAvailableCompactionProvider", () => { const originalInstanceId = ProviderInstanceId.make("claude_original"); it("rejects a fallback in a different locked continuation group", () => { @@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => { ]); expect( - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers, + driverKind: ProviderDriverKind.make("claudeAgent"), instanceId: originalInstanceId, lockedInstanceId: originalInstanceId, }), @@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => { ]); expect( - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers, + driverKind: ProviderDriverKind.make("claudeAgent"), instanceId: originalInstanceId, lockedInstanceId: originalInstanceId, }), diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 8e46c16e9a09..be3dacb05e92 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,4 +1,4 @@ -import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, isClaudeResumeCompactionQuestion, @@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; -export function hasAvailableClaudeCompactionProvider(input: { +export function providerSupportsManualCompaction( + provider: ProviderInstanceEntry | null | undefined, +): boolean { + return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false; +} + +export function hasAvailableCompactionProvider(input: { readonly providers: ReadonlyArray; + readonly driverKind: ProviderDriverKind; readonly instanceId: ProviderInstanceId | null; readonly lockedInstanceId: ProviderInstanceId | null; }): boolean { - const claudeProviders = input.providers.filter( - (provider) => provider.driverKind === "claudeAgent", + const driverProviders = input.providers.filter( + (provider) => provider.driverKind === input.driverKind, ); const lockedContinuationGroupKey = input.lockedInstanceId - ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) + ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId) ?.continuationGroupKey : undefined; const compatibleProviders = lockedContinuationGroupKey - ? claudeProviders.filter( + ? driverProviders.filter( (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, ) - : claudeProviders; + : driverProviders; - return ( - resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== - undefined + return providerSupportsManualCompaction( + resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined), ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e19c2fdb1923..c8c045017a53 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -489,6 +489,38 @@ describe("workEntryIsVisibleInGroup", () => { }); describe("deriveMessagesTimelineRows", () => { + it("keeps context compaction visible outside folded work", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "compaction-entry", + kind: "work", + createdAt: "2026-01-01T00:00:00Z", + entry: { + id: "compaction", + createdAt: "2026-01-01T00:00:00Z", + label: "Compacted context 899K → 19K tokens", + tone: "info", + sourceActivityKind: "context-compaction", + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows).toEqual([ + { + kind: "context-compaction", + id: "compaction-entry", + createdAt: "2026-01-01T00:00:00Z", + label: "Compacted context 899K → 19K tokens", + }, + ]); + }); + it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c661636821aa..a1d78fce76f0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -332,6 +332,12 @@ export type MessagesTimelineRow = label: string; expanded: boolean; } + | { + kind: "context-compaction"; + id: string; + createdAt: string; + label: string; + } | { kind: "message"; id: string; @@ -601,7 +607,11 @@ function deriveTurnFolds(input: { // Agent-spawn CTA rows never fold: workflows outlive their launching // turn (dynamic spawns, background execution), and folding the CTA // when the turn settles makes a still-running fleet invisible. - if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { + if ( + entry.kind === "work" && + (entry.entry.agentSpawn !== undefined || + entry.entry.sourceActivityKind === "context-compaction") + ) { continue; } if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) { @@ -803,6 +813,7 @@ export function deriveMessagesTimelineRows(input: { !entryBelongsToActiveTurn(entry, index) || entry.kind !== "work" || entry.entry.agentSpawn !== undefined || + entry.entry.sourceActivityKind === "context-compaction" || entry.entry.tone === "error" || workEntryRendersImagePreview(entry.entry) ) { @@ -906,6 +917,19 @@ export function deriveMessagesTimelineRows(input: { continue; } + if ( + timelineEntry.kind === "work" && + timelineEntry.entry.sourceActivityKind === "context-compaction" + ) { + nextRows.push({ + kind: "context-compaction", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + label: timelineEntry.entry.label, + }); + continue; + } + if (timelineEntry.kind === "work") { if ( timelineEntry.entry.agentSpawn !== undefined || @@ -929,6 +953,7 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.agentSpawn !== undefined || + nextEntry.entry.sourceActivityKind === "context-compaction" || nextEntry.entry.tone === "error" || workEntryRendersImagePreview(nextEntry.entry) || activeWorkEntryIds.has(nextEntry.id) || @@ -1150,6 +1175,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; } + case "context-compaction": { + const bc = b as typeof a; + return a.createdAt === bc.createdAt && a.label === bc.label; + } + case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4bb9b13b4296..3a1337e2e651 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -974,7 +974,7 @@ describe("MessagesTimeline", () => { entry: { id: "work-1", createdAt: "2026-03-17T19:12:28.000Z", - label: "Context compacted", + label: "Compacted context 899K → 19K tokens", tone: "info", }, }, @@ -982,7 +982,7 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain("Context compacted"); + expect(markup).toContain("Compacted context 899K → 19K tokens"); }); it("summarizes changed files in one line", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2b0219012971..04e33914b623 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -86,6 +86,7 @@ import { GlobeIcon, HammerIcon, MessageCircleIcon, + Minimize2Icon, MousePointerClickIcon, PaintbrushIcon, SearchIcon, @@ -210,6 +211,7 @@ interface TimelineRowSharedState { interface TimelineRowActivityState { isWorking: boolean; isPreparingWorktree: boolean; + isCompacting: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; } @@ -294,6 +296,7 @@ interface MessagesTimelineProps { onOpenAgents?: () => void; isWorking: boolean; isPreparingWorktree?: boolean; + isCompacting?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; @@ -343,6 +346,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCiteAssistantText, isWorking, isPreparingWorktree = false, + isCompacting = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, @@ -686,10 +690,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ isWorking, isPreparingWorktree, + isCompacting, isRevertingCheckpoint, latestTurnId: latestTurn?.turnId ?? null, }), - [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], + [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], ); // Stable renderItem — no closure deps. Row components read shared state @@ -1133,6 +1138,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} + {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( @@ -1145,6 +1151,27 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +function ContextCompactionTimelineRow({ + row, +}: { + row: Extract; +}) { + return ( +
+ + + + +
+ ); +} + function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { const ctx = use(TimelineRowCtx); const asset = useMemo( @@ -1579,12 +1606,12 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { - const { isPreparingWorktree } = use(TimelineRowActivityCtx); + const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); return (
{isPreparingWorktree ? ( @@ -1592,6 +1619,13 @@ function WorkingTimelineRow({ row }: { row: ExtractSetting up worktree… + ) : isCompacting ? ( + <> + + + + + ) : row.createdAt ? ( <> Working for @@ -1606,15 +1640,26 @@ function WorkingTimelineRow({ row }: { row: Extract - {isPreparingWorktree ? null : } + {isPreparingWorktree || isCompacting ? null : ( + + )}
); } +function CompactingLabel() { + return ( + + + ); +} + // --------------------------------------------------------------------------- // Self-ticking labels — update their own text nodes so elapsed-time display // does not create a React commit every second while a response is streaming. diff --git a/docs/user/composer.md b/docs/user/composer.md index 1affd1631ca2..b844a34d1379 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -212,6 +212,8 @@ such as System, Personal, Project, or App. On mobile, these menus are available on the **New task** screen before you start a thread. They use the skills and commands from the selected environment and provider. +In a thread with prior conversation context, send `/compact` to reduce context usage. Web and desktop also offer this action from the context meter, and the work log records token counts when the provider reports them. + By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the same `$name` skill token to your message. The original skill name remains searchable. If the provider diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index fb762b9c172e..2f6307d6a57c 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -299,6 +299,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; const ThreadStateChangedPayload = Schema.Struct({ state: RuntimeThreadState, + beforeTokens: Schema.optional(NonNegativeInt), + afterTokens: Schema.optional(NonNegativeInt), detail: Schema.optional(Schema.Unknown), }); export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; From 5989de44a24888dab02854477ea1d0f50ac3a4a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 18:53:21 -0700 Subject: [PATCH 04/36] fix(mobile): keep store screenshots free of system banners and show dictation (#9548) Co-authored-by: Claude Fable 5 --- .../voice-input/useVoiceInputController.ts | 5 ++- scripts/mobile-showcase.ts | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index 2170ff255f8c..8d1c9a9780a6 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -14,6 +14,7 @@ import { useSharedValue } from "react-native-reanimated"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; import { VoiceInputController, VOICE_RECORDING_LIMIT_SECONDS, @@ -203,7 +204,9 @@ export function useVoiceInputController(input: { const cancel = useCallback(() => controller.cancel(), [controller]); return { - isAvailable: getLocalVoiceTranscriber() !== null, + // Store screenshots show the dictation button even on simulators, whose + // on-device transcription is unavailable. + isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null, state, audioLevels, elapsedSeconds, diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 8f987b584d9d..d6ced75d5db2 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -806,6 +806,47 @@ async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{ }; } +async function iosSimulatorDataPath(udid: string): Promise { + const parsed = JSON.parse(await commandOutput("xcrun", ["simctl", "list", "devices", "-j"])) as { + readonly devices: Readonly< + Record> + >; + }; + const dataPath = Object.values(parsed.devices) + .flat() + .find((device) => device.udid === udid)?.dataPath; + if (!dataPath) throw new Error(`Could not resolve the data path of iOS simulator ${udid}.`); + return dataPath; +} + +// generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up +// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it +// on every boot until the user dismisses it. Stamping the readiness marker +// before boot makes the daemon skip the post, and dropping the CoreFollowUp +// store clears a banner that a previous boot already queued. Runs while the +// device is shut down so the files are read fresh on the next boot. +async function suppressIosSystemFollowUps(udid: string): Promise { + const dataPath = await iosSimulatorDataPath(udid); + const preferences = NodePath.join(dataPath, "Library/Preferences"); + await NodeFSP.mkdir(preferences, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(preferences, "com.apple.generativeexperiences.corefollowup.plist"), + ` + + + +\tDateOfLastAppleIntelligenceReadinessCFU +\t2020-01-01T00:00:00Z + + +`, + ); + await NodeFSP.rm(NodePath.join(dataPath, "Library/CoreFollowUp"), { + recursive: true, + force: true, + }); +} + async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise { await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]); await runCommand("xcrun", [ @@ -922,6 +963,7 @@ async function captureIos( // confirmations, keyboards) without erasing the developer's simulator. await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]); } + await suppressIosSystemFollowUps(simulator.udid); await runCommand("xcrun", ["simctl", "boot", simulator.udid]); await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); if (capture.device.orientation === "landscape") { From 54aef6fbe16f637092505b30bd25230c4b0744d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 18:55:43 -0700 Subject: [PATCH 05/36] fix(web): restore composer controls as space becomes available (#9539) --- .../components/composerFooterLayout.test.ts | 27 +++++++++++++++++++ .../src/components/composerFooterLayout.ts | 10 ++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 89de512e2eaf..e260de3eb372 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -333,6 +333,33 @@ describe("resolveRestingComposerControlsLayout hysteresis", () => { ).toEqual({ hiddenCount: 0, visible: true }); }); + it("restores the blocks that fit when the full cluster has no slack", () => { + const partlyRestored = resolveRestingComposerControlsLayout({ + ...base, + hostWidth: 357, + previous: { hiddenCount: 2, visible: true }, + }); + expect(partlyRestored).toEqual({ hiddenCount: 1, visible: true }); + expect( + resolveRestingComposerControlsLayout({ ...base, hostWidth: 357, previous: partlyRestored }), + ).toEqual(partlyRestored); + expect( + resolveRestingComposerControlsLayout({ ...base, hostWidth: 358, previous: partlyRestored }), + ).toEqual({ hiddenCount: 0, visible: true }); + }); + + it("requires slack before partially restoring a cluster", () => { + // One inline block, the picker, and overflow need 149 + 60 + 24 + 8 = 241px. + const previous = { hiddenCount: 2, visible: true }; + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241, previous })).toEqual( + previous, + ); + expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 242, previous })).toEqual({ + hiddenCount: 1, + visible: true, + }); + }); + it("still resolves from scratch when there is no previous layout", () => { expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({ hiddenCount: 0, diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index e4435c37c0be..53fef1181d1b 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -134,9 +134,13 @@ export function resolveRestingComposerControlsLayout( // composer re-measures on every render, so without that margin a host // sitting exactly on a threshold flips a block in and out until React // gives up with "Maximum update depth exceeded". - if (previous && hiddenCount < previous.hiddenCount) { - if (restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX) { - hiddenCount = Math.min(previous.hiddenCount, blockWidths.length); + if (previous) { + const previousHiddenCount = Math.min(previous.hiddenCount, blockWidths.length); + while ( + hiddenCount < previousHiddenCount && + restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX + ) { + hiddenCount += 1; } } const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth); From 3c3e05ccfe34ab7af273f4a4faa72909cafd7a21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 18:55:43 -0700 Subject: [PATCH 06/36] fix(web): measure collapsed model labels at their visible width (#9540) --- ...restingComposerControlsMeasurement.test.ts | 72 +++++++++++++++++++ .../restingComposerControlsMeasurement.ts | 6 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts new file mode 100644 index 000000000000..052c9b1d9f4d --- /dev/null +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + resolveRestingComposerControlsLayout, + resolveRestingComposerControlsNaturalWidth, +} from "../composerFooterLayout"; +import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; + +function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?: string }) { + const label = { clientWidth: input.clientWidth, scrollWidth: 160 }; + const picker = { + getBoundingClientRect: () => ({ width: 52 }), + querySelector: () => label, + }; + const controls = { + querySelector: (selector: string) => { + if (selector === "[data-chat-provider-model-picker]") return picker; + if (selector === "[data-resting-controls-overflow]") { + return { getBoundingClientRect: () => ({ width: 24 }) }; + } + return null; + }, + querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }], + }; + vi.stubGlobal("getComputedStyle", (element: unknown) => { + if (element === label) return { flexGrow: input.flexGrow }; + if (element === picker) return { minWidth: "52px", maxWidth: input.maxWidth ?? "none" }; + return { columnGap: "4px" }; + }); + return measureRestingComposerControls(controls as unknown as HTMLElement)!; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("measureRestingComposerControls", () => { + it("keeps controls inline when the model label is deliberately collapsed", () => { + const measurement = measurePicker({ clientWidth: 0, flexGrow: "0" }); + + expect(measurement.naturalFixedWidth).toBe(52); + expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196); + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ + hiddenCount: 0, + visible: true, + }); + }); + + it("recovers truncated text while the model label is flexible", () => { + const measurement = measurePicker({ clientWidth: 20, flexGrow: "1" }); + + expect(measurement.naturalFixedWidth).toBe(192); + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ + hiddenCount: 1, + visible: true, + }); + }); + + it("recovers flexible text squeezed to zero instead of mistaking it for collapsed text", () => { + const measurement = measurePicker({ clientWidth: 0, flexGrow: "1" }); + + expect(measurement.naturalFixedWidth).toBe(212); + expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({ + hiddenCount: 1, + visible: true, + }); + }); + + it("still caps recovered text at the model picker's maximum width", () => { + expect( + measurePicker({ clientWidth: 0, flexGrow: "1", maxWidth: "180px" }).naturalFixedWidth, + ).toBe(180); + }); +}); diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts index 3bd4242d457c..fc39951723fa 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -24,7 +24,11 @@ function providerModelPickerNaturalWidth(picker: HTMLElement): number { if (renderedWidth === 0) return 0; const style = getComputedStyle(picker); const label = picker.querySelector('[data-chat-provider-model-picker-label="true"]'); - const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; + // Narrow composers deliberately collapse the label with w-0 and flex-none. + // A flexible label squeezed to zero still needs its natural width recovered. + const labelIsCollapsed = label?.clientWidth === 0 && getComputedStyle(label).flexGrow === "0"; + const hiddenLabelWidth = + label && !labelIsCollapsed ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; const maxWidth = Number.parseFloat(style.maxWidth); const naturalWidth = Math.min( renderedWidth + hiddenLabelWidth, From f239b77df93077e40c27cc5c5909e94266571859 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 18:55:44 -0700 Subject: [PATCH 07/36] fix(web): close composer menus when their controls hide (#9541) --- apps/web/src/components/chat/ChatComposer.tsx | 44 +++++++-- .../chat/CompactComposerControlsMenu.tsx | 16 +--- apps/web/src/components/chat/TraitsPicker.tsx | 7 +- .../components/chat/composerProviderState.tsx | 3 + .../chat/useComposerMenuState.test.tsx | 90 +++++++++++++++++++ .../components/chat/useComposerMenuState.ts | 12 +++ 6 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerMenuState.test.tsx create mode 100644 apps/web/src/components/chat/useComposerMenuState.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a8d43e7b0a1f..589ef7350ec1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -87,6 +87,7 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { useComposerMenuState } from "./useComposerMenuState"; import { ComposerTasksBadge, ComposerTasksContent, @@ -911,10 +912,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; size?: "sm" | "xs"; + hidden?: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const size = props.size ?? "sm"; + const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -972,6 +975,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop { + if (value !== null) setPickedEnvironmentId(EnvironmentId.make(value)); + }} + > + + {targetEnvironment.label} + + + {connected.map((environment) => ( + + {environment.label} + + ))} + + + ) : null} + {canOperateTarget ? ( + + ) : ( + + + } + > + + + + + + Your session cannot change settings on {targetEnvironment.label}. + + + )} +
+ ) : null} + {groups.length === 0 && sources.length === 0 ? (

No provider on a connected environment reports subscription limits.

) : null} {sources.map((source) => ( - removeSource(source.id) - : null - } - /> + ))} {groups.map((group) => (
@@ -447,12 +665,27 @@ export function UsageLimitsSection() { ) : null} {group.providers.map((provider) => ( - + ))}
))} - {addHubButton ?
{addHubButton}
: null} - + {targetEnvironment && canOperateTarget ? ( + // Keyed on the target: if it disconnects or the primary changes while + // the dialog is open, a fresh dialog mounts empty rather than carrying + // a typed key over to a different environment. + + ) : null} ); } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 851e67dfa82b..4a2843abb806 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -902,6 +902,15 @@ export function createServerEnvironmentAtoms( Stream.mapAccum(Option.none, projectServerWelcome), ), }), + consumeResetCredit: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:consume-reset-credit", + tag: WS_METHODS.providerConsumeResetCredit, + concurrency: { + mode: "singleFlight", + // Both ids are free-form strings; a delimiter could collide. + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]), + }, + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 05a54ea94574..0478b113d61d 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -6,7 +6,7 @@ import { NonNegativeInt, TrimmedNonEmptyString, } from "./baseSchemas.ts"; -import { ProviderDriverKind } from "./providerInstance.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; /** @@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({ }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; +/** + * Reset credits a provider banks on the account. Codex grants these when it + * has rate-limited the user unfairly; redeeming one clears the current + * windows. Only present when the provider reports them at all. + */ +export const ServerProviderResetCredits = Schema.Struct({ + availableCount: NonNegativeInt, + nextExpiresAt: Schema.optional(IsoDateTime), +}); +export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; + /** * Subscription usage the provider knows about the signed-in account. * @@ -37,6 +48,7 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), + resetCredits: Schema.optional(ServerProviderResetCredits), unavailable: Schema.optional( Schema.Struct({ reason: Schema.Literals(["unsupported", "probeFailed"]), @@ -90,3 +102,22 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type; export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; + +export const ProviderConsumeResetCreditInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; + +/** Mirrors Codex's own outcome set; other providers map onto it. */ +export const ProviderConsumeResetCreditOutcome = Schema.Literals([ + "reset", + "nothingToReset", + "noCredit", + "alreadyRedeemed", +]); +export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCreditOutcome.Type; + +export const ProviderConsumeResetCreditResult = Schema.Struct({ + outcome: ProviderConsumeResetCreditOutcome, +}); +export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 84634664fa37..c0ef8cd56d6d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -206,6 +206,10 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; +import { + ProviderConsumeResetCreditInput, + ProviderConsumeResetCreditResult, +} from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { @@ -243,6 +247,7 @@ export const WS_METHODS = { // Provider methods providerUploadFeedback: "provider.uploadFeedback", providerAuthStart: "provider.auth.start", + providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", providerAuthCancel: "provider.auth.cancel", providerAuthLogout: "provider.auth.logout", @@ -410,6 +415,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); +export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { + payload: ProviderConsumeResetCreditInput, + success: ProviderConsumeResetCreditResult, + error: ProviderSetupRpcError, +}); + export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { payload: ProviderSetupInput, success: ProviderAuthState, @@ -1157,6 +1168,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, WsProviderAuthCompleteRpc, WsProviderAuthCancelRpc, From 4e547318b60031eb546d8cf2b84ad9fa0785a87a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 19:19:26 -0700 Subject: [PATCH 12/36] fix(server): find newly opened pull requests after agent turns (#9125) Refresh missing PR associations after agent turns on the thread's current branch. Preserve background policy, known PR caches, and failed-lookup backoff. Serialize status loads and refreshes to prevent stale responses from hiding a PR. Find branches pushed under their own name while still tracking the default branch. Original work by Theo Browne with Claude Fable 5.1 in Claude Code. Takeover fixes created with GPT-6 Astra (preview) in Codex. Co-authored-by: Theo Browne Co-authored-by: Claude Fable 5.1 --- .../OrchestrationEngineHarness.integration.ts | 2 + apps/server/src/git/GitManager.test.ts | 279 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 158 ++++++++-- apps/server/src/git/GitWorkflowService.ts | 2 +- .../Layers/CheckpointReactor.test.ts | 94 +++++- .../orchestration/Layers/CheckpointReactor.ts | 38 ++- .../Layers/ProviderCommandReactor.test.ts | 2 + .../src/vcs/VcsStatusBroadcaster.test.ts | 147 ++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 108 +++++-- docs/internals/overview.md | 15 +- docs/user/source-control.md | 3 + 11 files changed, 788 insertions(+), 60 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 79150f0f2531..b4e04fd44f60 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -363,6 +363,8 @@ export const makeOrchestrationIntegrationHarness = ( workingTree: { files: [], insertions: 0, deletions: 0 }, }), refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), + refreshPullRequestStatus: () => + Effect.die("refreshPullRequestStatus should not be called in this test"), streamStatus: () => Stream.empty, }), ), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index fc2a2c81279d..3e4be02a3c14 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -999,6 +999,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/turn-refresh", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/turn-refresh"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 114, + title: "Opened during the turn", + url: "https://github.com/pingdotgg/codething-mvp/pull/114", + baseRefName: "main", + headRefName: "feature/turn-refresh", + }, + ]), + ], + }, + }); + expect((yield* manager.remoteStatus({ cwd: repoDir }))?.pr).toBeNull(); + expect( + (yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr, + ).toBeNull(); + + const refreshed = yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(refreshed?.pr?.number).toBe(114); + yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("turn-end refresh preserves failed PR lookup backoff", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/rate-limited"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + }, + }); + yield* manager.remoteStatus({ cwd: repoDir }); + const callsAfterFailure = ghCalls.length; + yield* manager.remoteStatus( + { cwd: repoDir }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + expect(callsAfterFailure).toBeGreaterThan(0); + expect(ghCalls).toHaveLength(callsAfterFailure); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1909,6 +1983,211 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status finds a PR pushed under the branch's own name despite a default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-plain", "origin/main"]); + // A plain push (no -u) leaves the upstream on origin/main. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-plain"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/pushed-plain": JSON.stringify([ + { + number: 88, + title: "Pushed without -u", + url: "https://github.com/pingdotgg/codething-mvp/pull/88", + baseRefName: "main", + headRefName: "feature/pushed-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/pushed-plain"); + expect(status.pr?.number).toBe(88); + expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); + }), + ); + + it.effect( + "status finds a fork PR pushed under the branch's own name despite a default upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-plain", "origin/main"]); + // Pushed to the fork without -u: upstream stays origin/main. + yield* runGit(repoDir, ["push", "team/fork", "feature/fork-plain"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/fork-plain": JSON.stringify([ + { + number: 89, + title: "Fork PR pushed without -u", + url: "https://github.com/pingdotgg/codething-mvp/pull/89", + baseRefName: "main", + headRefName: "feature/fork-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.pr?.number).toBe(89); + expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( + true, + ); + expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); + }), + ); + + it.effect("branch PR lookup verifies identity on the fork that holds the own-name ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/fork-settle", "origin/main"]); + yield* runGit(repoDir, ["push", "team/fork", "feature/fork-settle"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/fork-settle": JSON.stringify([ + { + number: 91, + title: "Fork PR to settle", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "feature/fork-settle", + state: "MERGED", + updatedAt: "2026-05-02T10:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/fork-settle", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-05-02T10:00:00.000Z", + }); + }), + ); + + it.effect("status keeps an own-name PR when a later lookup fails on a default upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/sticky-plain", "origin/main"]); + yield* runGit(repoDir, ["push", "origin", "feature/sticky-plain"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/sticky-plain": JSON.stringify([ + { + number: 90, + title: "Sticky own-name PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/90", + baseRefName: "main", + headRefName: "feature/sticky-plain", + state: "OPEN", + updatedAt: "2026-05-01T10:00:00Z", + }, + ]), + }, + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(90); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(90); + }), + ); + it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d65417e99e13..cad328d16b08 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -72,6 +72,11 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } +export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { + /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ + readonly refreshMissingPullRequest?: boolean; +} + interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; @@ -88,7 +93,7 @@ export class GitManager extends Context.Service< ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitRemoteStatusOptions, ) => Effect.Effect; /** Resolve the PR for a saved branch without changing the current checkout. */ readonly branchPullRequest: (input: { @@ -1009,20 +1014,8 @@ export const make = Effect.gen(function* () { ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { - const headContext = yield* resolveBranchHeadContext(cwd, details); - const upstreamHeadIsDefault = - headContext.headBranch === details.defaultBranch || - (details.defaultBranch === null && - (headContext.headBranch === "main" || headContext.headBranch === "master")); - // `git worktree add -b feature origin/main` makes the new local branch - // track origin/main. That upstream is the branch's base, not its - // published PR head. Looking up PRs for it can attach an old reverse - // merge from main and auto-settle an unrelated feature thread. - if ( - headContext.headBranch !== details.branch && - upstreamHeadIsDefault && - !headContext.isCrossRepository - ) { + const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); + if (!lookup) { return { latest: null, headContext }; } // Only skip when the branch is untracked as well: anything carrying an @@ -1115,11 +1108,21 @@ export const make = Effect.gen(function* () { defaultBranch: string | null; isDefaultBranch: boolean; }, + refreshMissingPullRequest = false, ) { // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first // `push -u`) must not orphan the fallback value for the same branch. const branchKey = `${cwd}\u0000${details.branch}`; - return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + const cacheKey = prLookupCacheKey(cwd, details); + if (refreshMissingPullRequest) { + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached) && cached.value.latest === null) { + yield* Cache.invalidate(prLookupCache, cacheKey); + } + } + return yield* Cache.get(prLookupCache, cacheKey).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; // On the default branch, only surface open PRs. @@ -1159,8 +1162,8 @@ export const make = Effect.gen(function* () { } : {}), }), - Effect.andThen(resolveBranchHeadContext(cwd, details)), - Effect.map((headContext) => + Effect.andThen(resolveLookupHeadContext(cwd, details)), + Effect.map(({ headContext }) => resolveLastKnownPr(branchKey, { upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, @@ -1174,7 +1177,7 @@ export const make = Effect.gen(function* () { }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitRemoteStatusOptions, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) @@ -1185,12 +1188,16 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* lookupStatusPr(cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - defaultBranch: details.defaultBranch, - isDefaultBranch: details.isDefaultBranch, - }) + ? yield* lookupStatusPr( + cwd, + { + branch: details.branch, + upstreamRef: details.upstreamRef, + defaultBranch: details.defaultBranch, + isDefaultBranch: details.isDefaultBranch, + }, + options?.refreshMissingPullRequest, + ) : null; return { @@ -1346,6 +1353,96 @@ export const make = Effect.gen(function* () { } satisfies BranchHeadContext; }); + // The remote that holds a ref named after the local branch, or null when + // none does. Remote names may contain slashes, so refs are matched literally + // per remote instead of with a glob. When several remotes hold the name, the + // preferred remote wins, then origin, then the first configured remote. + const findRemoteTrackingRemote = Effect.fn("findRemoteTrackingRemote")(function* ( + cwd: string, + branch: string, + preferredRemoteName: string | null, + ) { + if (branch.length === 0) return null; + return yield* Effect.gen(function* () { + const remoteNames = (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.remotes", + cwd, + args: ["remote"], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + if (remoteNames.length === 0) return null; + const refs = new Set( + (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.refs", + cwd, + args: [ + "for-each-ref", + "--format=%(refname)", + ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), + ], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((ref) => ref.trim()) + .filter((ref) => ref.length > 0), + ); + const matching = remoteNames.filter((name) => refs.has(`refs/remotes/${name}/${branch}`)); + if (preferredRemoteName !== null && matching.includes(preferredRemoteName)) { + return preferredRemoteName; + } + if (matching.includes("origin")) return "origin"; + return matching[0] ?? null; + }).pipe(Effect.orElseSucceed(() => null)); + }); + + // `git worktree add -b feature origin/main` makes the new local branch track + // origin/main. That upstream is the branch's base, not its published PR + // head. Looking up PRs for it can attach an old reverse merge from main and + // auto-settle an unrelated feature thread. + // + // The branch may still have been pushed under its own name by a plain + // `git push feature` that never moved the upstream. When a remote + // holds a ref for the local name, look the PR up by that name on that + // remote. Without such a ref there is nothing to ask the host about, so + // `lookup` is false and no API call is spent. Both the cached lookup and the + // failure fallback resolve through here so the last-known PR compares + // against the same head branch. + const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* ( + cwd: string, + details: { + branch: string; + upstreamRef: string | null; + defaultBranch: string | null; + remoteName?: string; + }, + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const upstreamHeadIsDefault = + headContext.headBranch === details.defaultBranch || + (details.defaultBranch === null && + (headContext.headBranch === "main" || headContext.headBranch === "master")); + if ( + headContext.headBranch === details.branch || + !upstreamHeadIsDefault || + headContext.isCrossRepository + ) { + return { headContext, lookup: true }; + } + const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName); + if (remoteName === null) { + return { headContext, lookup: false }; + } + const ownNameContext = yield* resolveBranchHeadContext(cwd, { + branch: details.branch, + upstreamRef: null, + remoteName, + }); + return { headContext: ownNameContext, lookup: true }; + }); + /** * Whether git has no record of this branch on any remote, so a change request * cannot exist for it and asking the provider is a guaranteed-empty API call. @@ -1911,7 +2008,7 @@ export const make = Effect.gen(function* () { const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - if (options?.refreshUpstream === false) { + if (options?.refreshUpstream === false || options?.refreshMissingPullRequest) { return yield* readRemoteStatus(cacheKey, options); } return yield* Cache.get(remoteStatusResultCache, cacheKey); @@ -2006,10 +2103,15 @@ export const make = Effect.gen(function* () { ...(localBranchExists ? {} : { remoteName }), }); let cached = yield* Cache.get(prLookupCache, cacheKey); + // The cached head context may have resolved on a different remote than + // the saved upstream: a branch tracking origin/main but pushed to a fork + // is looked up on the fork. Verify against the remote the lookup used. + const identityRemoteName = (headContext: BranchHeadContext) => + headContext.remoteName ?? remoteName ?? undefined; const currentIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, - remoteName ?? undefined, + identityRemoteName(cached.headContext), ); const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => !( @@ -2032,7 +2134,7 @@ export const make = Effect.gen(function* () { const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, - remoteName ?? undefined, + identityRemoteName(cached.headContext), ); if ( !canVerifyIdentity(cached.headContext, refreshedIdentity) || diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c539dc0b890a..c9b4a4cca365 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -43,7 +43,7 @@ export class GitWorkflowService extends Context.Service< ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitManager.GitRemoteStatusOptions, ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 65ed329dfe71..87a239b13d92 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -295,6 +295,7 @@ describe("CheckpointReactor", () => { readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; + readonly pullRequestRefreshCalls?: Array; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -333,7 +334,8 @@ describe("CheckpointReactor", () => { Effect.as({ isRepo: true, hasPrimaryRemote: false, - isDefaultRef: true, + isDefaultRef: + options?.localStatusRefName === undefined || options.localStatusRefName === "main", refName: options?.localStatusRefName !== undefined ? options.localStatusRefName : "main", hasWorkingTreeChanges: false, @@ -341,6 +343,10 @@ describe("CheckpointReactor", () => { }), ), refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), + refreshPullRequestStatus: (cwd: string) => + Effect.sync(() => { + options?.pullRequestRefreshCalls?.push(cwd); + }).pipe(Effect.as(null)), streamStatus: () => Stream.empty, }); @@ -561,6 +567,78 @@ describe("CheckpointReactor", () => { expect(gitStatusRefreshCalls).toEqual([harness.cwd]); }); + it("re-asks for the pull request at turn end when the thread branch is checked out", async () => { + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/feature", + localStatusRefName: "t3code/feature", + pullRequestRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-refresh-pr"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-refresh-pr"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(pullRequestRefreshCalls).toEqual([harness.cwd]); + }); + + it("re-asks for the pull request after adopting a drifted checkout", async () => { + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/original-branch", + localStatusRefName: "t3code/renamed-by-agent", + pullRequestRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-drift-pr"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-drift-pr"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(pullRequestRefreshCalls).toEqual([harness.cwd]); + }); + + it("does not re-ask for the pull request at turn end on the default branch", async () => { + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "main", + localStatusRefName: "main", + pullRequestRefreshCalls, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-no-pr-refresh"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-no-pr-refresh"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + expect(pullRequestRefreshCalls).toEqual([]); + }); + it("adopts a drifted checkout as the thread branch on a dedicated worktree", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, @@ -593,11 +671,13 @@ describe("CheckpointReactor", () => { }); it("does not adopt a drifted checkout when the worktree is shared by another thread", async () => { + const pullRequestRefreshCalls: string[] = []; const harness = await createHarness({ seedFilesystemCheckpoints: false, threadBranch: "t3code/original-branch", localStatusRefName: "t3code/renamed-by-agent", secondThreadSharingWorktree: true, + pullRequestRefreshCalls, }); harness.provider.emit({ @@ -615,6 +695,7 @@ describe("CheckpointReactor", () => { const snapshot = await harness.readModel(); const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.branch).toBe("t3code/original-branch"); + expect(pullRequestRefreshCalls).toEqual([]); }); it("does not adopt a temporary placeholder checkout as the thread branch", async () => { @@ -642,7 +723,13 @@ describe("CheckpointReactor", () => { }); it("ignores auxiliary thread turn completion while primary turn is active", async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const pullRequestRefreshCalls: string[] = []; + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/feature", + localStatusRefName: "t3code/feature", + pullRequestRefreshCalls, + }); const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( @@ -694,6 +781,7 @@ describe("CheckpointReactor", () => { const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.checkpoints).toHaveLength(0); + expect(pullRequestRefreshCalls).toEqual([]); harness.provider.emit({ type: "turn.completed", @@ -711,6 +799,8 @@ describe("CheckpointReactor", () => { (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + await harness.drain(); + expect(pullRequestRefreshCalls).toEqual([harness.cwd]); }); it("captures pre-turn and completion checkpoints for claude runtime events", async () => { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 5c5465cdb7e5..0fc6295d4495 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -553,9 +553,42 @@ const make = Effect.gen(function* () { cwd: sessionRuntime.value.cwd, local, }); + yield* refreshPullRequestAfterTurn({ + threadId: event.threadId, + turnId: toTurnId(event.turnId), + cwd: sessionRuntime.value.cwd, + local, + }); } }); + // Retry a missing PR after the agent finishes its push and PR creation. + // Re-read the projected branch after drift adoption. A rejected metadata + // update must not let this thread refresh another thread's checkout. + const refreshPullRequestAfterTurn = Effect.fn("refreshPullRequestAfterTurn")(function* (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | null; + readonly cwd: string; + readonly local: VcsStatusLocalResult; + }) { + const checkedOutBranch = input.local.refName; + if (checkedOutBranch === null || input.local.isDefaultRef) return; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if (!thread || thread.branch !== checkedOutBranch) return; + if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, input.turnId)) return; + yield* vcsStatusBroadcaster.refreshPullRequestStatus(input.cwd).pipe( + Effect.catch((error) => + Effect.logWarning("failed to refresh pull request status after turn completion", { + threadId: input.threadId, + cwd: input.cwd, + detail: error.message, + }), + ), + ); + }); + // A `git checkout` run inside a thread's dedicated worktree (by an agent or // the user) bypasses T3's commands, so the thread's recorded branch goes // stale. Since #4460 the client only attributes PR state to a thread when @@ -843,9 +876,8 @@ const make = Effect.gen(function* () { // When ProviderRuntimeIngestion creates a placeholder checkpoint (status "missing") // from a turn.diff.updated runtime event, capture the real git checkpoint to - // replace it. The providerService.streamEvents PubSub does not reliably deliver - // turn.completed runtime events to this reactor (shared subscription), so - // reacting to the domain event is the reliable path. + // replace it. ProviderService broadcasts runtime events to each subscriber. + // This domain-event path also captures checkpoints from turn diff updates. if (event.type === "thread.turn-diff-completed") { yield* captureCheckpointFromPlaceholder(event).pipe( Effect.catch((error) => diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index e8f35672da76..1c5e834ec2d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -461,6 +461,8 @@ describe("ProviderCommandReactor", () => { refreshLocalStatus: () => Effect.die("refreshLocalStatus should not be called in this test"), refreshStatus, + refreshPullRequestStatus: () => + Effect.die("refreshPullRequestStatus should not be called in this test"), streamStatus: () => Stream.die("streamStatus should not be called in this test"), }), ), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 8b1a5f5bc809..c7d7b79a12e0 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -5,6 +5,7 @@ import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -75,10 +76,11 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + backgroundWorkEnabled?: boolean; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), - Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(makeBackgroundPolicyLayer(() => state.backgroundWorkEnabled !== false)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -228,6 +230,149 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + // Nobody loaded this cwd yet: no host request is spent. + assert.isNull(yield* broadcaster.refreshPullRequestStatus("/repo")); + assert.equal(state.remoteStatusCalls, 0); + + yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(state.remoteStatusCalls, 1); + + // Loaded and no PR known: ask GitManager to retry the missing PR. + state.currentRemoteStatus = remoteStatusWithPr; + const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(refreshed, remoteStatusWithPr); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteInvalidationCalls, 0); + + // The agent switches branches. The previous branch's PR must not block a read. + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; + state.currentRemoteStatus = baseRemoteStatus; + yield* broadcaster.refreshLocalStatus("/repo"); + const refreshedBranch = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(refreshedBranch, baseRemoteStatus); + assert.equal(state.remoteStatusCalls, 3); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("a poll that started before the turn-end refresh cannot overwrite its PR", () => { + const releaseFirstPoll = Deferred.makeUnsafe(); + const firstPollStarted = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 2) { + // Hold an older empty response while the turn-end refresh queues. + yield* Deferred.succeed(firstPollStarted, undefined); + yield* Deferred.await(releaseFirstPoll); + return baseRemoteStatus; + } + return remoteReads === 1 ? baseRemoteStatus : remoteStatusWithPr; + }), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + const poll = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(firstPollStarted); + const refresh = yield* broadcaster.refreshPullRequestStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseFirstPoll, undefined); + yield* Fiber.join(poll); + const refreshed = yield* Fiber.join(refresh); + + assert.deepStrictEqual(refreshed, remoteStatusWithPr); + const final = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(final.pr, remoteStatusWithPr.pr); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("an initial status read cannot overwrite an explicit refresh", () => { + const firstReadStarted = Deferred.makeUnsafe(); + const releaseFirstRead = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 1) { + yield* Deferred.succeed(firstReadStarted, undefined); + yield* Deferred.await(releaseFirstRead); + return baseRemoteStatus; + } + return remoteStatusWithPr; + }), + invalidateStatus: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(firstReadStarted); + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + // Run ready fibers before releasing the delayed first read. + yield* TestClock.adjust(Duration.zero); + yield* Deferred.succeed(releaseFirstRead, undefined); + yield* Fiber.join(initial); + yield* Fiber.join(refresh); + assert.deepStrictEqual( + (yield* broadcaster.getStatus({ cwd: "/repo" })).pr, + remoteStatusWithPr.pr, + ); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("turn-end refresh skips a loaded cwd when background policy pauses it", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + backgroundWorkEnabled: false, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it.effect("refreshes the cached snapshot after explicit invalidation", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 3ae47c5c03e5..04d320c03bf9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import type { @@ -184,6 +185,14 @@ export class VcsStatusBroadcaster extends Context.Service< cwd: string, ) => Effect.Effect; readonly refreshStatus: (cwd: string) => Effect.Effect; + /** + * Refresh a loaded cwd after a turn if background policy allows it. + * GitManager retries missing PRs for the current branch and keeps known + * PRs and failed lookup backoff cached. This does not fetch Git remotes. + */ + readonly refreshPullRequestStatus: ( + cwd: string, + ) => Effect.Effect; readonly streamStatus: ( input: VcsStatusInput, options?: StreamStatusOptions, @@ -214,6 +223,18 @@ export const make = Effect.gen(function* () { Scope.close(scope, Exit.void), ); const cacheRef = yield* Ref.make(new Map()); + // One permit per cwd for remote reads that write the cache. Without it a + // periodic poll that started before `gh pr create` can finish after the + // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. + const remoteWriteLocks = new Map(); + const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => { + let lock = remoteWriteLocks.get(cwd); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + remoteWriteLocks.set(cwd, lock); + } + return lock.withPermits(1)(effect); + }; const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( @@ -351,14 +372,20 @@ export const make = Effect.gen(function* () { if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const latest = yield* getCachedStatus(cwd); + const [local, remote] = yield* Effect.all( + [ + latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), + latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote); + }), ); - return yield* updateCachedStatus(cwd, local, remote); }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( @@ -421,13 +448,18 @@ export const make = Effect.gen(function* () { readonly policyCwds?: ReadonlyArray; }, ) { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } - const remote = yield* workflow.remoteStatus({ cwd }, options); - const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); - if (pulled !== null) return pulled.remote; - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + if (pulled !== null) return pulled.remote; + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( @@ -436,16 +468,49 @@ export const make = Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + yield* workflow.invalidateStatus(cwd); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }), ); - const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); - if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }); + const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = + Effect.fn("VcsStatusBroadcaster.refreshPullRequestStatus")(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote?.value == null) return null; + const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); + const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; + const shouldRefresh = (yield* Effect.forEach( + demandCwds, + (demandCwd) => + backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), + { concurrency: "unbounded" }, + )).some(Boolean); + if (!shouldRefresh) return null; + // Resolve the checked-out branch again. A cached PR can belong to + // the previous branch after an agent checks out another branch. + const remote = yield* workflow.remoteStatus( + { cwd }, + { refreshUpstream: false, refreshMissingPullRequest: true }, + ); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); + }); + const makeRemoteRefreshLoop = ( cwd: string, demandCwdsRef: Ref.Ref>, @@ -657,6 +722,7 @@ export const make = Effect.gen(function* () { getStatus, refreshLocalStatus, refreshStatus, + refreshPullRequestStatus, streamStatus, }); }); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 7ad971ae745d..3affe10c48f7 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -99,10 +99,17 @@ once per minute, including when no client is connected. It dispatches the guarde `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic settlement excludes live background work and requires a comparable PR timestamp for immediate PR settlement. The command carries the latest activity timestamp and rejects any later event for its -thread after the reactor's snapshot. -Clients render the persisted settlement state and do not derive settlement from PR or inactivity -state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider -session. +thread after the reactor's snapshot. The sweep looks a branch up from the thread's worktree when it +still exists, so it shares the per-cwd PR cache the sidebar polls instead of spending a second host +request. Clients render the persisted settlement state and do not derive settlement from PR or +inactivity state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle +provider session. + +At turn completion, `CheckpointReactor` refreshes PR discovery when the checkout matches the +thread's non-default branch. `VcsStatusBroadcaster` requires loaded remote status and permission +from background policy. `GitManager` retries only a successful "no PR" cache entry for the current +branch, preserving known PRs and failure backoff without fetching remotes. Remote status reads +that write the broadcaster cache share a lock per cwd, including the initial status load. ## Drainable workers diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5727be12ae86..482b3ccd1f12 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -40,6 +40,9 @@ T3 Code works with the platforms your team already uses: **Stay on top of open reviews** - See if your current branch already has an open PR/MR +- When an agent finishes a turn on your thread's branch, T3 Code checks for a newly opened + PR/MR if background activity is enabled for that repository. Known reviews keep their normal + refresh schedule. - Open several reviews from the **Pull requests** page as tabs in the right panel - Your authored reviews stay at the top and use the selected sort within their group. By default, see passing and approved reviews first, passing reviews awaiting approval next, and conflicting From f96a220b5b154ea44c94bf43929c6362cd511699 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 19:30:37 -0700 Subject: [PATCH 13/36] ci: add on-demand Windows test workflow (#9538) Co-authored-by: Claude Code --- .github/workflows/windows-tests.yml | 81 +++++++++++++++++++ .../scripts/ensure-electron-runtime.mjs | 4 +- docs/internals/ci.md | 8 ++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/windows-tests.yml diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml new file mode 100644 index 000000000000..3a70ad5a26a0 --- /dev/null +++ b/.github/workflows/windows-tests.yml @@ -0,0 +1,81 @@ +# On-demand Windows test lane. Manual only: nothing in the suite passes on +# Windows yet, so this exists to give contributors (and agents) a cloud Windows +# box to iterate against. Once the suite is green here, fold it into ci.yml. +# +# gh workflow run windows-tests.yml --ref -f package=packages/shared +# gh workflow run windows-tests.yml --ref -f package=apps/server \ +# -f files="src/process/externalLauncher.test.ts src/cli/theme.test.ts" +# gh run watch && gh run view --log-failed +name: Windows Tests + +on: + workflow_dispatch: + inputs: + package: + description: "Workspace directory to test, e.g. apps/server or packages/shared. Empty runs every package except apps/server." + type: string + default: "" + files: + description: "Space-separated test files relative to the package directory. Empty runs the package's whole suite. Requires package." + type: string + default: "" + +permissions: + contents: read + +jobs: + test: + name: Test (${{ inputs.package || 'all non-server' }}) + runs-on: blacksmith-8vcpu-windows-2025 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + # setup-vp's own cache restores a Linux-shaped store on Windows, which is + # slower than no cache (see #7975). Cache pnpm's Windows store directly. + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Resolve package cache path + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache packages + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install + run: vp install + + - name: Ensure Electron runtime is installed + if: inputs.package == '' || inputs.package == 'apps/desktop' + run: vp run --filter "@t3tools/desktop" ensure:electron + + # `vp run ... test -- ` does not forward positional args to vitest, + # so file-scoped runs call `vp test run` inside the package instead. + - name: Test + shell: pwsh + run: | + $package = '${{ inputs.package }}' + $files = '${{ inputs.files }}' + if ($package -eq '') { + vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + } elseif ($files -eq '') { + vp run --filter "./$package" test + } else { + Set-Location $package + vp test run $files.Split(' ') + } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index c37838ab1836..b8b8254c9b3c 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as NodeChildProcess from "node:child_process"; const require = NodeModule.createRequire(import.meta.url); @@ -176,7 +177,8 @@ export function ensureElectronRuntime() { return electronPath; } -if (import.meta.url === `file://${process.argv[1]}`) { +// `file://${argv[1]}` never matches on Windows (drive letters need `file:///C:/`). +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { const electronPath = ensureElectronRuntime(); process.stdout.write(`${electronPath}\n`); } diff --git a/docs/internals/ci.md b/docs/internals/ci.md index a454e62c5c59..67851009f5c2 100644 --- a/docs/internals/ci.md +++ b/docs/internals/ci.md @@ -22,6 +22,14 @@ and pushes to `main`: - **Release Smoke**: exercises release-only workflow steps through `scripts/release-smoke.ts`, so release breakage surfaces on PRs rather than at tag time. +[`.github/workflows/windows-tests.yml`](../../.github/workflows/windows-tests.yml) is a manual +Windows lane (`workflow_dispatch` only) on a Blacksmith Windows 2025 runner. The suite does not +pass on Windows yet, so it is not a required check; it exists so the work to get there can be +iterated against a real Windows box without one on hand. Dispatch it with `gh workflow run +windows-tests.yml --ref `, optionally with `-f package=` to run one workspace package +and `-f files=""` to run specific test files inside it. Once it is green, fold it into +`ci.yml`. + `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. It auto-enables signing only when platform credentials are present. macOS passkey builds additionally require From 710f6dc417ebf303eede3df3605a6938482d83ab Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 22:35:24 -0400 Subject: [PATCH 14/36] fix(web): simplify expanded tool details (#9549) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/components/chat/MessagesTimeline.tsx | 92 +++++++++++++------ 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 04e33914b623..cdca3328eb69 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2886,27 +2886,47 @@ function workEntryRawCommand( function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, + visibleLabel: string, + viewedImagePath: string | null, ): string | null { const blocks: string[] = []; + const seen = new Set(); + const addBlock = (value: string | null | undefined) => { + const text = value?.trim(); + if (!text || seen.has(text)) return; + seen.add(text); + blocks.push(text); + }; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { - blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); + addBlock(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } + const command = workEntry.command?.trim(); const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - blocks.push(raw.trim()); - } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); + if (command === visibleLabel.trim()) { + seen.add(command); + } else { + addBlock(raw ?? command); } - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + const detail = workEntry.detail?.trim(); + if (detail !== viewedImagePath?.trim()) { + addBlock(detail); } - const changedFiles = workEntry.changedFiles ?? []; + const viewedImagePaths = new Set( + viewedImagePath + ? [viewedImagePath.trim(), formatWorkspaceRelativePath(viewedImagePath, workspaceRoot)] + : [], + ); + const changedFiles = (workEntry.changedFiles ?? []).flatMap((filePath) => { + const formattedPath = formatWorkspaceRelativePath(filePath, workspaceRoot); + return viewedImagePaths.has(filePath) || + viewedImagePaths.has(formattedPath) || + filePath.trim() === detail || + formattedPath === detail + ? [] + : [formattedPath]; + }); if (changedFiles.length > 0) { - blocks.push( - changedFiles - .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) - .join("\n"), - ); + addBlock([...new Set(changedFiles)].join("\n")); } return blocks.length > 0 ? blocks.join("\n\n") : null; } @@ -3092,19 +3112,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "circle-alert" : workEntryIconName(workEntry); const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); - const displayText = - !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; const viewedImagePath = workEntryViewedImagePath(workEntry); - const canExpand = - (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || - Boolean( - workEntryRawCommand(workEntry) || - workEntry.command?.trim() || - workEntry.detail?.trim() || - workEntry.changedFiles?.length || - viewedImagePath, - ); - const expandedBody = expanded ? buildToolCallExpandedBody(workEntry, workspaceRoot) : null; const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -3112,6 +3120,24 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot, }) : null; + const commandMatchesVisibleLabel = workEntry.command?.trim() === previewText.trim(); + const canExpand = + (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || + Boolean( + (!commandMatchesVisibleLabel && + (workEntryRawCommand(workEntry) || workEntry.command?.trim())) || + workEntry.detail?.trim() || + workEntry.changedFiles?.length || + viewedImage, + ); + const expandedBody = expanded + ? buildToolCallExpandedBody( + workEntry, + workspaceRoot, + previewText, + viewedImage ? viewedImagePath : null, + ) + : null; const showDestructiveRowStyle = showFailedIndicator && (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); @@ -3183,7 +3209,19 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

- {displayText} + + {previewText} +

{showFailedIndicator && hasSpecialToolIcon ? ( @@ -3224,7 +3262,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ) : null} {expanded && canExpand && expandedBody ? (
From 9e1bc36a0843699db54ee28abbdac70584ae8f33 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 19:36:10 -0700 Subject: [PATCH 15/36] fix(web): keep the last message visible when the resting composer expands (#9553) Scrolling a long thread to the end with the composer at rest landed flush against the short composer. The expansion that followed then covered the last rows, because the timeline reserves only the live overlay height and does not move for footer growth. The timeline now keeps the expanded composer's height clear while the composer rests, so expanding it again changes nothing above the composer. The composer reports its resting flag from a layout effect and publishes a fresh overlay height whenever that flag changes, so the reservation is always computed from a height that belongs to the same layout. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ChatView.tsx | 43 ++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 57 +++++++++++++------ .../components/composerFooterLayout.test.ts | 22 +++++++ .../src/components/composerFooterLayout.ts | 30 ++++++++++ docs/user/composer.md | 5 +- 5 files changed, 133 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0141dd4903fc..c4ed99c0e238 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -298,6 +298,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; +import { resolveComposerTimelineInset } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -1628,6 +1629,12 @@ function ChatViewContent(props: ChatViewProps) { const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerOverlayHeightRef = useRef(0); + // Space the timeline keeps clear above its end. Tracks the overlay while the + // composer is expanded and holds that height while it rests, so the resting + // composer never exposes rows that its expansion will cover. + const [composerTimelineInset, setComposerTimelineInset] = useState(0); + const composerTimelineInsetRef = useRef(0); + const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); @@ -4459,11 +4466,11 @@ function ChatViewContent(props: ChatViewProps) { return getAnchoredTurnMetrics({ state, anchorIndex, - composerOverlayHeight, + composerOverlayHeight: composerTimelineInset, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, - [composerOverlayHeight], + [composerTimelineInset], ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { @@ -4488,11 +4495,11 @@ function ChatViewContent(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, - [composerOverlayHeight], + [composerTimelineInset], ); const pageScrollControllerRef = useRef | null>( null, @@ -4964,10 +4971,35 @@ function ChatViewContent(props: ChatViewProps) { composerOverlayHeightRef.current = nextHeight; setComposerOverlayHeight(nextHeight); } + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting: composerRestingRef.current, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } setScrollToEndClearance((currentClearance) => currentClearance === nextHeight ? currentClearance : nextHeight, ); }, []); + // The composer reports its resting flag from a layout effect, which runs + // before this component's own layout effects and before any resize + // observation, so every measurement below sees the flag for its layout. + // Only the flag is stored here: the stored height still belongs to the + // previous layout, and the composer publishes the new layout's height + // itself once it has measured it. + const onComposerRestingChange = useCallback((resting: boolean) => { + composerRestingRef.current = resting; + }, []); + // A held reservation belongs to the previous thread's draft. Rebuild it from + // this thread's overlay so a tall draft elsewhere does not pad this one. + useLayoutEffect(() => { + if (!composerOverlayElement) return; + composerTimelineInsetRef.current = 0; + publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); + }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -7775,7 +7807,7 @@ function ChatViewContent(props: ChatViewProps) { } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - contentInsetEndAdjustment={composerOverlayHeight} + contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -7923,6 +7955,7 @@ function ChatViewContent(props: ChatViewProps) { getTimelineScrollableNode={getTimelineScrollableNode} isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} onComposerOverlayHeightChange={publishComposerOverlayHeight} + onRestingChange={onComposerRestingChange} promptRef={promptRef} composerImagesRef={composerImagesRef} composerFilesRef={composerFilesRef} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 589ef7350ec1..e2d4bda1b1d6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -251,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; function useComposerRestingTransition( isCollapsed: boolean, + isResting: boolean, restingControlsRef: React.RefObject, onOverlayHeightChange: (height: number) => void, ) { const elementRef = useRef(null); const isCollapsedRef = useRef(isCollapsed); const previousCollapsedRef = useRef(isCollapsed); + const previousRestingRef = useRef(isResting); const previousHeightRef = useRef(null); const previousContentOffsetsRef = useRef<{ promptFromTop: number | null; @@ -355,6 +357,15 @@ function useComposerRestingTransition( const nextRect = element.getBoundingClientRect(); const nextHeight = nextRect.height; + // The chat view resize-observes the overlay to place the timeline + // inset, the scroll-to-end pill, and the mini player. Publishing the + // destination height here turns that feedback into one update instead + // of a ChatView re-render on every animation frame. + const overlay = element.closest('[data-chat-composer-overlay="true"]'); + const overlayHeight = overlay?.getBoundingClientRect().height ?? null; + if (overlayHeight !== null) { + onOverlayHeightChange(overlayHeight); + } const nextPromptRect = prompt?.getBoundingClientRect() ?? null; const nextPromptTop = nextPromptRect?.top ?? null; const nextActionTop = action?.getBoundingClientRect().top ?? null; @@ -385,18 +396,13 @@ function useComposerRestingTransition( element.style.overflow = "clip"; surface.style.height = "100%"; - // The chat view resize-observes the overlay to place the timeline - // inset, the scroll-to-end pill, and the mini player. Pinning the - // overlay at the destination height turns that feedback into one - // update instead of a ChatView re-render on every animation frame; - // bottom alignment keeps the animating surface glued to the overlay's - // stable bottom edge. The pin lasts only for the tween so later - // attachment, thread, font, and viewport changes remain natural. - const overlay = element.closest('[data-chat-composer-overlay="true"]'); - let pinnedOverlayHeight: number | null = null; - if (overlay) { - pinnedOverlayHeight = overlay.getBoundingClientRect().height; - overlay.style.height = `${String(pinnedOverlayHeight)}px`; + // Pinning the overlay at the destination height keeps the resize + // observer quiet for the tween; bottom alignment keeps the animating + // surface glued to the overlay's stable bottom edge. The pin lasts + // only for the tween so later attachment, thread, font, and viewport + // changes remain natural. + if (overlay && overlayHeight !== null) { + overlay.style.height = `${String(overlayHeight)}px`; overlay.style.display = "flex"; overlay.style.flexDirection = "column"; overlay.style.justifyContent = "flex-end"; @@ -430,11 +436,6 @@ function useComposerRestingTransition( ); animationRef.current = animation; animationTargetHeightRef.current = nextHeight; - // Publish the destination overlay geometry in the same layout pass; - // ResizeObserver remains the fallback for non-transition changes. - if (pinnedOverlayHeight !== null) { - onOverlayHeightChange(pinnedOverlayHeight); - } const animatedRect = element.getBoundingClientRect(); const previousPromptTop = @@ -612,6 +613,18 @@ function useComposerRestingTransition( }; }, [isCollapsed, transitionToCurrentGeometry]); + // The resting flag can change while the collapsed layout stays the same, + // for example when an unfocused thread crosses the phone breakpoint. The + // chat view pairs overlay heights with that flag, so republish the natural + // height for the new flag. A transition in flight publishes its own. + useLayoutEffect(() => { + if (previousRestingRef.current === isResting) return; + previousRestingRef.current = isResting; + if (animationRef.current) return; + const overlay = elementRef.current?.closest('[data-chat-composer-overlay="true"]'); + if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); + }, [isResting, onOverlayHeightChange]); + useLayoutEffect(() => { const element = elementRef.current; if (!element || typeof ResizeObserver === "undefined") return; @@ -1232,6 +1245,11 @@ export interface ChatComposerProps { getTimelineScrollableNode: () => HTMLElement | null; isTimelineAtLogicalEnd: () => boolean; onComposerOverlayHeightChange: (height: number) => void; + /** + * Whether the desktop resting layout is active. Reported from a layout + * effect, so it is current before the chat view measures the overlay. + */ + onRestingChange: (resting: boolean) => void; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -1336,6 +1354,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getTimelineScrollableNode, isTimelineAtLogicalEnd, onComposerOverlayHeightChange, + onRestingChange, promptRef, composerRef, composerImagesRef, @@ -3584,6 +3603,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useLayoutEffect(() => { onRestingControlsVisibilityChange(composerControlsVisibleInStrip); }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); + useLayoutEffect(() => { + onRestingChange(isComposerResting); + }, [isComposerResting, onRestingChange]); const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( standaloneComposerImages.length, ); @@ -3639,6 +3661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null; const composerMainSurfaceRef = useComposerRestingTransition( composerControlsInStrip, + isComposerResting, restingComposerControlsRef, onComposerOverlayHeightChange, ); diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index e260de3eb372..82ef5850cc1f 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -4,7 +4,9 @@ import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, + COMPOSER_RESTING_EXPANSION_MIN_PX, getRestingComposerImagePreviewCounts, + resolveComposerTimelineInset, resolveRestingComposerControlsLayout, resolveRestingComposerControlsNaturalWidth, shouldAnimateComposerRestingTransition, @@ -74,6 +76,26 @@ describe("shouldUseCompactComposerPrimaryActions", () => { }); }); +describe("resolveComposerTimelineInset", () => { + it("follows the expanded overlay height", () => { + expect( + resolveComposerTimelineInset({ currentInset: 160, overlayHeight: 140, isResting: false }), + ).toBe(140); + }); + + it("keeps a larger expanded reservation while resting", () => { + expect( + resolveComposerTimelineInset({ currentInset: 200, overlayHeight: 60, isResting: true }), + ).toBe(200); + }); + + it("reserves the empty expansion when no larger height is known", () => { + expect( + resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }), + ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); + }); +}); + describe("shouldUseRestingComposerLayout", () => { const resting = { isExistingThread: true, diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 53fef1181d1b..b7b7d91a033c 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -48,6 +48,36 @@ export function shouldUseRestingComposerLayout(input: { return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; } +/** + * How much taller the empty expanded composer is than its resting row on + * desktop widths, from the layout classes in ChatComposer: the body loses + * 8px of top padding, the prompt clamps from min-h-17.5 (70px) to 32px, and + * the 48px footer leaves flow. + */ +export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; + +/** + * The space the timeline reserves at its end for the composer overlay. + * + * The overlay is measured live, but a resting composer is much shorter than + * an expanded one. Reserving only the resting height lets a scroll to the end + * land flush against the short composer, and the expansion that follows then + * covers the last rows because the timeline never moves for footer growth. + * While resting, the reservation keeps the last expanded height, or at least + * the resting height plus the empty expansion, so expanding again changes + * nothing above the composer. An expanded measurement is authoritative and + * may shrink it. + */ +export function resolveComposerTimelineInset(input: { + currentInset: number; + overlayHeight: number; + isResting: boolean; +}): number { + return input.isResting + ? Math.max(input.currentInset, input.overlayHeight + COMPOSER_RESTING_EXPANSION_MIN_PX) + : input.overlayHeight; +} + export function shouldAnimateComposerRestingTransition(input: { hasCompletedInitialLayout: boolean; stateChanged: boolean; diff --git a/docs/user/composer.md b/docs/user/composer.md index b844a34d1379..fce133cd27d8 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -55,8 +55,9 @@ On web and desktop, an existing thread settles its composer into a single-line r the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer, except when scrolling toward the end while already there. When the thread-context strip has room, the model and mode controls stay available beside the thread context; otherwise they return when the -composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep -the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: +composer is focused. Focus the composer or start typing to expand it again. The conversation keeps +the expanded composer's space clear above its last message while the composer rests, so expanding it +again never covers what you scrolled to. New-thread layouts keep the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: **On unfocus**, **On scroll**, both, or neither. With neither selected the composer stays expanded. At phone-sized web or desktop window widths, existing threads animate between their compact and From fee2e0ff8168b82ab19f496fcc874c1f3871f522 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 19:50:20 -0700 Subject: [PATCH 16/36] test(web): fix flaky startup and Tailwind tests (#9558) --- apps/web/src/bootstrap.test.ts | 7 ++++--- apps/web/src/bundledDev.test.ts | 29 +++++++++++++++++------------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts index d682e0150de5..c5c0d89597aa 100644 --- a/apps/web/src/bootstrap.test.ts +++ b/apps/web/src/bootstrap.test.ts @@ -30,9 +30,6 @@ describe("app startup failures", () => { beforeEach(() => { vi.resetModules(); - vi.doMock("./main", () => { - throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); - }); bootShell = new BootElement("div"); vi.stubGlobal("document", { getElementById: () => bootShell, @@ -51,12 +48,16 @@ describe("app startup failures", () => { }); afterEach(() => { + vi.doUnmock("./main"); vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it("replaces the splash when an app import throws before main can run", async () => { + vi.doMock("./main", () => { + throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); + }); const reload = vi.fn(); vi.stubGlobal("window", { location: { reload } }); diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index 1ca5038da50e..599b45d22524 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -147,7 +147,8 @@ it("hot updates Tailwind classes when a source file changes in bundled dev", asy ); const logger = createLogger("silent"); logger.error = (message) => events.emit("error", new Error(message)); - const built = NodeEvents.EventEmitter.once(events, "built"); + const connected = NodeEvents.EventEmitter.once(events, "connected"); + const ready = NodeEvents.EventEmitter.once(events, "ready"); server = await createServer({ configFile: false, root, @@ -168,19 +169,18 @@ it("hot updates Tailwind classes when a source file changes in bundled dev", asy transform(code, id) { if (id.endsWith("/style.css")) css = code; }, - generateBundle() { - events.emit("built"); + async generateBundle() { + // Keep the build pending until the socket can receive Vite's ready message. + await connected; }, }, ], server: { host: "127.0.0.1", port: 0 }, }); await server.listen(); - await built; const address = server.httpServer?.address(); if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); - const connected = NodeEvents.EventEmitter.once(events, "connected"); server.ws.on("vite:client-connected", () => events.emit("connected")); socket = new WebSocket( `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, @@ -197,16 +197,21 @@ it("hot updates Tailwind classes when a source file changes in bundled dev", asy }); socket.addEventListener("message", ({ data }) => { const message: unknown = JSON.parse(String(data)); - if ( - message !== null && - typeof message === "object" && - "type" in message && - message.type === "bundled-dev-update" - ) { - events.emit("updated"); + if (message !== null && typeof message === "object" && "type" in message) { + // generateBundle runs before Vite stores the files for HTTP requests. + if ( + message.type === "full-reload" && + "ifFallback" in message && + message.ifFallback === true + ) { + events.emit("ready"); + } else if (message.type === "bundled-dev-update") { + events.emit("updated"); + } } }); await connected; + await ready; const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); expect(entry.headers.get("content-type")).toContain("javascript"); await entry.text(); From 65f1839ae82af4e67f389f23e4ccc50f51a4a83f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 23:00:04 -0400 Subject: [PATCH 17/36] fix(web): keep codex restart responses continuous (#9560) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 105 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 72 +++++++++--- 2 files changed, 159 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c8c045017a53..67dea03e44ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1326,6 +1326,111 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("keeps a promptless restart in one active visual response", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "keep going", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "old-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "old-work", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-before-restart" as never, + label: "Searched files", + command: "rg restart", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + { + id: "old-stale-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:06Z", + entry: { + id: "old-stale-work", + createdAt: "2026-01-01T00:00:06Z", + turnId: "turn-before-restart" as never, + label: "Running stale command", + command: "rg stale", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }, + }, + { + id: "old-commentary-entry", + kind: "message", + createdAt: "2026-01-01T00:00:08Z", + message: { + id: "old-commentary" as never, + role: "assistant", + text: "the server restarted, continuing here.", + turnId: "turn-before-restart" as never, + createdAt: "2026-01-01T00:00:08Z", + updatedAt: "2026-01-01T00:00:08Z", + streaming: false, + }, + }, + { + id: "new-work-entry", + kind: "work", + createdAt: "2026-01-01T00:01:05Z", + entry: { + id: "new-work", + createdAt: "2026-01-01T00:01:05Z", + turnId: "turn-after-restart" as never, + label: "Running tests", + command: "vp test run", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-after-restart" as never, + state: "running", + startedAt: "2026-01-01T00:01:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:01:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); + expect(rows.filter((row) => row.id === "working-indicator-row")).toHaveLength(1); + expect(rows.findIndex((row) => row.id === "working-indicator-row")).toBeLessThan( + rows.findIndex((row) => row.id === "old-work-entry"), + ); + expect(rows.find((row) => row.id === "working-indicator-row")).toMatchObject({ + createdAt: "2026-01-01T00:00:00Z", + }); + expect(rows.find((row) => row.id === "old-commentary-entry")).toMatchObject({ + showAssistantMeta: false, + showAssistantCopyButton: false, + assistantCopyStreaming: true, + }); + expect(rows.filter((row) => row.kind === "work-live" && row.active)).toEqual([ + expect.objectContaining({ entry: expect.objectContaining({ id: "new-work" }) }), + ]); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); + }); + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a1d78fce76f0..f33e847adc5d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -512,6 +512,37 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; } +/** + * A promptless provider restart replaces the native turn without adding a + * user message. Keep every provider turn since the latest user message in one + * visual response until the replacement turn settles. A steer has its own + * user message, so it naturally starts a new visual response. + */ +function deriveActiveVisualResponseTurnIds(input: { + timelineEntries: ReadonlyArray; + unsettledTurnId: TurnId | null; + isWorking: boolean; +}): ReadonlySet { + const turnIds = new Set(); + if (input.unsettledTurnId === null) { + return turnIds; + } + + turnIds.add(input.unsettledTurnId); + if (!input.isWorking) { + return turnIds; + } + + const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); + for (let index = latestUserMessageIndex + 1; index < input.timelineEntries.length; index += 1) { + const turnId = timelineEntryTurnId(input.timelineEntries[index]!); + if (turnId !== null) { + turnIds.add(turnId); + } + } + return turnIds; +} + function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { return ( entry.toolLifecycleStatus === "inProgress" || @@ -529,7 +560,7 @@ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; - unsettledTurnId: TurnId | null; + unfoldedTurnIds: ReadonlySet; }): ReadonlyMap { interface TurnGroup { entries: Array; @@ -587,7 +618,7 @@ function deriveTurnFolds(input: { const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { - if (turnId === input.unsettledTurnId) { + if (input.unfoldedTurnIds.has(turnId)) { continue; } if (group.hasStreamingMessage) { @@ -769,11 +800,16 @@ export function deriveMessagesTimelineRows(input: { input.latestTurn ?? null, input.runningTurnId ?? null, ); + const activeVisualResponseTurnIds = deriveActiveVisualResponseTurnIds({ + timelineEntries: input.timelineEntries, + unsettledTurnId, + isWorking: input.isWorking, + }); const foldsByAnchorEntryId = deriveTurnFolds({ timelineEntries: input.timelineEntries, terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, - unsettledTurnId, + unfoldedTurnIds: activeVisualResponseTurnIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -787,15 +823,7 @@ export function deriveMessagesTimelineRows(input: { let activeTurnHeaderIndex = input.timelineEntries.length; if (input.isWorking) { const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); - const firstOwnedAfterUser = - unsettledTurnId === null - ? -1 - : input.timelineEntries.findIndex( - (entry, index) => - index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, - ); - activeTurnHeaderIndex = - firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; + activeTurnHeaderIndex = latestUserMessageIndex + 1; } const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => input.isWorking && @@ -862,10 +890,17 @@ export function deriveMessagesTimelineRows(input: { activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [], ); const appendWorkingRow = () => { + const latestUserMessage = input.timelineEntries[lastUserMessageIndex(input.timelineEntries)]; + const visualResponseStartedAt = + activeVisualResponseTurnIds.size > 1 && + latestUserMessage?.kind === "message" && + latestUserMessage.message.role === "user" + ? latestUserMessage.message.createdAt + : input.activeTurnStartedAt; nextRows.push({ kind: "working", id: "working-indicator-row", - createdAt: input.activeTurnStartedAt, + createdAt: visualResponseStartedAt, }); }; let hasActivityRow = false; @@ -1081,10 +1116,11 @@ export function deriveMessagesTimelineRows(input: { continue; } - const assistantTurnStillInProgress = + const assistantResponseStillInProgress = timelineEntry.message.role === "assistant" && - unsettledTurnId !== null && - timelineEntry.message.turnId === unsettledTurnId; + timelineEntry.message.turnId !== null && + timelineEntry.message.turnId !== undefined && + activeVisualResponseTurnIds.has(timelineEntry.message.turnId); const durationStart = durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt; @@ -1095,7 +1131,7 @@ export function deriveMessagesTimelineRows(input: { const showAssistantMeta = timelineEntry.message.role === "assistant" && terminalAssistantMessageIds.has(timelineEntry.message.id) && - !assistantTurnStillInProgress; + !assistantResponseStillInProgress; nextRows.push({ kind: "message", @@ -1105,7 +1141,7 @@ export function deriveMessagesTimelineRows(input: { durationStart, showAssistantMeta, showAssistantCopyButton: showAssistantMeta, - assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress, + assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) From d7884ce90b9845e6e8aa737dfe02062b91b11c91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 20:05:01 -0700 Subject: [PATCH 18/36] fix(web): make settings sidebar sub-section buttons full width (#9562) Co-authored-by: Claude Fable 5 --- apps/web/src/components/settings/SettingsSidebarNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 0a55a9fe0483..f703ba09f8c2 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -353,7 +353,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { } size="sm" - className="text-sidebar-muted-foreground/65" + className="w-full text-sidebar-muted-foreground/65" onClick={() => handlePageSectionClick(item.to, section.targetId)} > {section.label} From 2b10398cca3fa74a7c2187c8d9c23ba789333f5b Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 23:08:26 -0400 Subject: [PATCH 19/36] fix(web): render settings sidebar immediately (#9563) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/AppSidebarLayout.tsx | 15 ++----------- .../settings/SettingsSidebarNav.tsx | 22 ++++++++++++++++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 1780c8b9acb8..3bef170bcf51 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,8 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { - lazy, - Suspense, useEffect, useState, useSyncExternalStore, @@ -20,6 +18,7 @@ import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../ho import { usePanelAnimationSettings } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { resolveSidebarStageFocusRingOffsetClass, @@ -45,14 +44,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; -// The settings nav (and the Clerk profile surfaces behind it) only renders on -// settings routes; lazy-loading it keeps that subtree out of the startup chunk. -const SettingsSidebarNav = lazy(() => - import("./settings/SettingsSidebarNav").then((module) => ({ - default: module.SettingsSidebarNav, - })), -); - function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -247,9 +238,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - - - + ) : legacySidebarEnabled ? ( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f703ba09f8c2..2716569c571c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -36,7 +38,6 @@ import { SidebarMenuSubItem, useSidebar, } from "../ui/sidebar"; -import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { @@ -47,6 +48,17 @@ import { } from "./settingsSearch"; import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; +const T3ConnectSidebarSignIn = lazy(() => + import("../clerk/T3ConnectSidebarSignIn").then((module) => ({ + default: module.T3ConnectSidebarSignIn, + })), +); +const T3ConnectSidebarAvatar = lazy(() => + import("../clerk/T3ConnectSidebarSignIn").then((module) => ({ + default: module.T3ConnectSidebarAvatar, + })), +); + const SETTINGS_SECTION_ICONS: Readonly< Record> > = { @@ -370,12 +382,16 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - + + +
- + + +
From 95390ed78458f139cb795bab4baa53ec39222ff7 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 23:10:50 -0400 Subject: [PATCH 20/36] chore: vouch august contributors (#9557) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .github/VOUCHED.td | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 98cbc681b75d..988e223d219f 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -12,22 +12,28 @@ github:0x4bs3nt github:Adamulek123 github:adityavardhansharma +github:aoright github:arhxam github:bil0000 github:binbandit github:Brechard +github:btsouth github:chrisdeeming github:chuks-qua github:cursoragent github:D3OXY +github:dbalders github:eggfriedrice24 github:extoci +github:flamboh +github:FllipEis github:gbarros-dev github:gfsaaser24 github:github-actions[bot] github:gsimone github:GuilhermeVieiraDev github:hwanseoc +github:inayayousfi github:ipanasenko github:jakeleventhal github:jamesx0416 @@ -36,13 +42,18 @@ github:jasonLaster github:JoeEverest github:justsomelegs github:kridaydave +github:lgwacker github:lnieuwenhuis github:Lucenx9 github:mackinleysmith github:maria-rcks +github:MatthewFeroz github:maxwellyoung github:mwolson +github:myacoub91 +github:naMqe-h github:nateEc +github:naveed949 github:nmggithub github:Noojuno github:notkainoa @@ -64,6 +75,7 @@ github:tarik02 github:tris203 github:tsouth89 github:UtkarshUsername +github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 From 42bdea1c9c1d4b7c5c2e77cd23cf53fae68a6fd4 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 23:17:41 -0400 Subject: [PATCH 21/36] fix(web): stabilize right panel transitions (#9554) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 14 +---- .../components/preview/PreviewPanelShell.tsx | 53 +++++++++++++------ 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c4ed99c0e238..97e63a2bd0a1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1884,7 +1884,7 @@ function ChatViewContent(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = rightPanelPresent && rightPanelOpen; + const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( @@ -7526,15 +7526,6 @@ function ChatViewContent(props: ChatViewProps) {
{panelToggleControls}
); - const inlineRightPanelControls = ( -
- - {panelToggleControls} -
- ); const rightPanelContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( @@ -7681,6 +7672,7 @@ function ChatViewContent(props: ChatViewProps) { return (
+ {!rightPanelControlsInPanel ? panelLayoutControls : null}
- {!rightPanelControlsInPanel ? panelLayoutControls : null} (null); // Only inline non-maximized mode applies `width`/`maxWidth`; skip the // container measurement (and its re-renders) everywhere else. - const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized); + const maxWidth = useClampedMaxWidth(hostRef, isInline && !maximized); const { width, handlers } = useResizableWidth({ storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, @@ -79,33 +80,50 @@ export function PreviewPanelShell(props: { maxWidth, edge: "left", }); - const previousLayoutRef = useRef({ open, width }); + // Derive suppression before the layout commits so the browser never creates + // a width transition for resize or maximize changes. + const [layoutTransition, setLayoutTransition] = useState(() => ({ + open, + width, + maximized, + suppressed: false, + })); + if ( + layoutTransition.open !== open || + layoutTransition.width !== width || + layoutTransition.maximized !== maximized + ) { + setLayoutTransition({ + open, + width, + maximized, + suppressed: + collapsible && + layoutTransition.open === open && + (layoutTransition.width !== width || layoutTransition.maximized !== maximized), + }); + } + const suppressWidthTransition = layoutTransition.suppressed; useLayoutEffect(() => { - const previous = previousLayoutRef.current; - previousLayoutRef.current = { open, width }; - if (!collapsible || previous.open !== open || previous.width === width) return; - const host = hostRef.current; - if (!host?.closest("[data-panel-animations=true]")) return; - host.style.setProperty("transition-duration", "0ms"); + if (!suppressWidthTransition) return; let restoreFrame = 0; const paintFrame = window.requestAnimationFrame(() => { restoreFrame = window.requestAnimationFrame(() => { - host.style.removeProperty("transition-duration"); + setLayoutTransition((current) => ({ ...current, suppressed: false })); }); }); return () => { window.cancelAnimationFrame(paintFrame); window.cancelAnimationFrame(restoreFrame); - host.style.removeProperty("transition-duration"); }; - }, [collapsible, open, width]); + }, [suppressWidthTransition]); return (
- {isInline && !props.maximized ? : null} + {isInline && !maximized ? : null}
{useDragRegion ?
: null} {props.children} From 0cb02abf5b3af2985d9dd23a637a63388e98fd49 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 3 Sep 2026 22:20:10 -0500 Subject: [PATCH 22/36] fix: better shell syntax handling for labels (#9371) --- apps/mobile/src/lib/threadActivity.test.ts | 43 + apps/mobile/src/lib/threadActivity.ts | 5 + apps/web/src/session-logic.test.ts | 20 + apps/web/src/session-logic.ts | 5 + .../src/work-log/commandLabel.test.ts | 422 ++++++ .../src/work-log/commandLabel.ts | 1199 ++++++++++++++++- 6 files changed, 1683 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index a0e68e3f82b1..868aaa8db84e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1990,6 +1990,49 @@ describe("buildThreadFeed", () => { }, ); + it("preserves serialized shell wrappers with non-matching boundary quotes", () => { + const turnId = TurnId.make("turn-serialized-shell-wrapper"); + const command = + "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\""; + const thread = makeThread({ + id: ThreadId.make("thread-serialized-shell-wrapper"), + projectId: ProjectId.make("project-1"), + title: "Serialized shell wrapper", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("serialized-shell-wrapper"), + kind: "tool.updated", + tone: "tool", + summary: "Ran command", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + payload: { + itemType: "command_execution", + status: "inProgress", + data: { item: { command } }, + }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + expect(feed[0]).toMatchObject({ + type: "activity-group", + activities: [{ workEntry: { command } }], + }); + if (feed[0]?.type === "activity-group") { + expect(feed[0].activities[0]?.workEntry.rawCommand).toBeUndefined(); + } + }); + it.each([ ["inProgress", true], ["completed", false], diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5eb3c0cbf7b1..564ab5e58fff 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1001,6 +1001,11 @@ function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): stri return null; } + const openingQuote = command[0]; + if ((openingQuote === "'" || openingQuote === '"') && !command.endsWith(openingQuote)) { + return null; + } + const unwrapped = trimMatchingOuterQuotes(command); return unwrapped.length > 0 ? unwrapped : null; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index baa57e8f99fa..5dade8459e0f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1578,6 +1578,26 @@ describe("deriveWorkLogEntries", () => { expect(entry?.rawCommand).toBeUndefined(); }); + it("preserves serialized shell wrappers with non-matching boundary quotes", () => { + const command = + "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\""; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-serialized-wrapper", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { item: { command } }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.command).toBe(command); + expect(entry?.rawCommand).toBeUndefined(); + }); + it("keeps compact Codex tool metadata used for icons and labels", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index fcaa5e9e1755..c6c7410bebea 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1381,6 +1381,11 @@ function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): stri return null; } + const openingQuote = command[0]; + if ((openingQuote === "'" || openingQuote === '"') && !command.endsWith(openingQuote)) { + return null; + } + const unwrapped = trimMatchingOuterQuotes(command); return unwrapped.length > 0 ? unwrapped : null; } diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts index 1cee96e2f2b0..15380032d065 100644 --- a/packages/client-runtime/src/work-log/commandLabel.test.ts +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -17,6 +17,7 @@ describe("commandProgramName", () => { ["env CI=1 /bin/zsh -lc '\"/Applications/My Tools/bin/check\" --verbose'", "check"], ["bash -lc \"zsh -c 'git status'\"", "git"], ['"C:\\Program Files\\Git\\bin\\bash.exe" -lc "git status"', "git"], + ["/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\"", "git"], ])("unwraps shell scripts without executing them: %s", (command, program) => { expect(commandProgramName(command)).toBe(program); }); @@ -32,6 +33,9 @@ describe("commandProgramName", () => { ["bash -- -c 'git status'", "bash"], ["bash --rcfile config.sh", "bash"], ["my-shell -c 'git status'", "my-shell"], + ["$HOME/.bun/bin/bun test", "bun"], + ['"$ANDROID_HOME/emulator/emulator" -list-avds', "emulator"], + ["${ROOT}/bin/tool --version", "tool"], ])("preserves ordinary programs and actual shell launches: %s", (command, program) => { expect(commandProgramName(command)).toBe(program); }); @@ -47,6 +51,402 @@ describe("commandProgramName", () => { expect(commandProgramName(command)).toBeNull(); }); + it.each([ + "if test -f package.json; then vp test; fi", + "[ -f package.json ]", + "[[ -f package.json ]]", + "test -f package.json", + 'for file in *; do echo "$file"; done', + "while true; do sleep 1; done", + "until false; do sleep 1; done", + "case $name in test) vp test;; esac", + 'select item in one two; do echo "$item"; done', + "function check() { vp test; }", + "check() { vp test; }", + "k(){ echo ok; }; k", + "{ vp test; }", + "(vp test)", + "(( count += 1 ))", + "! vp test", + ":", + ". ./script.sh", + "source ./script.sh", + "eval 'vp test'", + "cd packages/client-runtime", + "export NODE_ENV=test", + "local name=value", + "set -e", + "alias ll='ls -la'", + "repeat 3 echo ok", + "and vp test", + "return 1", + "break", + "continue", + "true", + "false", + ])("falls back for shell syntax and internal control commands: %s", (command) => { + expect(commandProgramName(command)).toBeNull(); + }); + + it.each([ + ['rg -n "if|for|while" src', "rg"], + ["printf '%s\\n' 'a;b|c'", "printf"], + ["node -e \"if (true) console.log('ok')\"", "node"], + ["echo '$(git status)'", "echo"], + ["vp test && git status", "vp"], + ["vp test || git status", "vp"], + ["rg needle src | head", "rg"], + ["vp test; git status", "vp"], + ["vp test &", "vp"], + ["vp test\ngit status", "vp"], + ['echo "$(git status)"', "echo"], + ["echo `git status`", "echo"], + ["cat <(rg needle src)", "cat"], + ])("uses the first executable-looking program: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["cd packages/client-runtime && vp test run", "vp"], + ['cd "a path with spaces"; git status', "git"], + ["cd apps/web\npnpm test", "pnpm"], + ["cd first && cd second && bun test", "bun"], + ["CI=1 cd apps/web && npm test", "npm"], + ["PATH+=:/tools npm test", "npm"], + ["PATH+=:/tools && npm test", "npm"], + ['TMP=$(mktemp -d); cd "$TMP"; npm pack ./package', "npm"], + ["cd $(find . -type d | head -1) && git status", "git"], + ["cd `find . -type d | head -1` && node script.js", "node"], + ["cd /tmp 2>&1 && npm test", "npm"], + ["cd /tmp 2<&0 && pnpm test", "pnpm"], + ["cd /tmp &>/dev/null && bun test", "bun"], + ["cd work |& npm test", "npm"], + ["cd /tmp && # use the selected workspace\nnpm test", "npm"], + ["export CI=1; # first note\n# second note\npnpm test", "pnpm"], + ["cd&&npm test", "npm"], + ["export CI=1;pnpm test", "pnpm"], + ["cd ${ROOT:-path;with;semicolons} && bun test", "bun"], + ["cd ${ROOT:-path&&fallback} && node app.js", "node"], + ["cd @(first|second) && npm test", "npm"], + ["cd /tmp \\\n&& npm test", "npm"], + ["/bin/zsh -lc 'cd apps/web && vp test run'", "vp"], + ])("skips leading cd commands and uses the next useful program: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["source ~/.nvm/nvm.sh && nvm use", "nvm"], + [". ./.env && pnpm test", "pnpm"], + ["export CI=1 && vp test run", "vp"], + ["unset DEBUG; node app.js", "node"], + ["export CI=1 && cd apps/web && pnpm test", "pnpm"], + ["/bin/zsh -lc 'source ~/.nvm/nvm.sh && nvm use'", "nvm"], + ])("skips shell setup commands and uses the next useful program: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["set -eu; npm test", "npm"], + [": && npm test", "npm"], + ["true && npm test", "npm"], + ["false || npm test", "npm"], + ["false; npm test", "npm"], + ["sudo -n true && npm test", "npm"], + ["sudo -n true; echo checked", "echo"], + ["test -d node_modules || vp i", "vp"], + ["[ -d node_modules ] || vp i", "vp"], + ])("skips non-descriptive shell commands before a useful program: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each( + ["cd /tmp", "export CI=1", "unset DEBUG", "source env.sh", ". env.sh"].flatMap((setup) => + ["&&", " || ", ";", "\n", "|", " |& ", " & "].map( + (operator) => [`${setup}${operator}npm test`, "npm"] as const, + ), + ), + )("handles shell setup followed by every command separator: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["command git status", "git"], + ["command -p git status", "git"], + ["command -- git status", "git"], + ["builtin printf ok", "printf"], + ["builtin -- echo ok", "echo"], + ["command cd /tmp && npm test", "npm"], + ["builtin cd /tmp && pnpm test", "pnpm"], + ["exec node app.js", "node"], + ["exec -cl -a worker node app.js", "node"], + ["exec env CI=1 /opt/tools/check --verbose", "check"], + ['exec "C:\\Program Files\\nodejs\\node.exe" app.js', "node.exe"], + ["exec sh -c 'cd /tmp && npm test'", "npm"], + ["exec sh -c 'cd /tmp\nnpm test'", "npm"], + ["exec bash -c 'set -e\nnpm test'", "npm"], + ])("unwraps shell command wrappers: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["timeout 10 pnpm test", "pnpm"], + ["timeout 10s python3 script.py", "python3"], + ["gtimeout 1.5 node app.js", "node"], + ["nohup npx expo start >/tmp/metro.log 2>&1 &", "npx"], + ["nohup -- env CI=1 bun test", "bun"], + ["arch -x86_64 ./build/app-under-test", "app-under-test"], + ["arch -arch arm64 /opt/tools/check", "check"], + ["bundle exec pod install", "pod"], + ["timeout 30 nohup env CI=1 node app.js", "node"], + ["timeout 60 script -q /dev/null env CI=1 node app.js", "node"], + ])("unwraps process-launch wrappers: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["& 'C:\\Program Files\\nodejs\\node.exe' script.js", "node.exe"], + ['& "$env:WINDIR\\Microsoft.NET\\Framework64\\v4\\csc.exe" file.cs', "csc.exe"], + ])("resolves literal PowerShell call operators: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["$env:CI='1'; npm test", "npm"], + ["$value = 'configured'; node app.js", "node"], + ["$process = Get-Process node; $process.Id", "Get-Process"], + ["$tmp = Join-Path $env:TEMP repo; git clone example", "Join-Path"], + ["$html = (Invoke-WebRequest https://example.com).Content", "Invoke-WebRequest"], + ["$process = Start-Process -FilePath .\\app.exe -PassThru; $process.Id", "app.exe"], + ])("labels commands inside simple PowerShell assignments: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["cmd /c bcdedit /enum", "bcdedit"], + ["cmd /c cd C:\\work && npm test", "npm"], + ['cmd.exe /d /s /c "cd C:\\work && npm test"', "npm"], + [ + 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\\work\\scripts\\doctor.ps1"', + "doctor.ps1", + ], + ['pwsh -NoProfile -Command "Set-Location C:\\work; pnpm test"', "pnpm"], + ["pwsh -Command Set-Location C:\\work; pnpm test", "pnpm"], + ])("unwraps Windows shell launchers: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["Set-Location C:\\work; npm test", "npm"], + ["Push-Location C:\\work; node app.js", "node"], + ["Start-Process -FilePath node -ArgumentList server.js", "node"], + ["Start-Process -ArgumentList '-FilePath helper' node", "node"], + ["Start-Process -ErrorAction Stop -FilePath node", "node"], + ["Start-Process -NoNewWindow -WorkingDirectory C:\\work node", "node"], + ['Start-Process "C:\\Program Files\\Example\\app.exe"', "app.exe"], + ['Start-Process -FilePath ".\\dist\\Example App.exe" -PassThru', "Example App.exe"], + [".\\.venv\\Scripts\\python.exe script.py", "python.exe"], + ["$env:LOCALAPPDATA\\Programs\\tool.exe --version", "tool.exe"], + ['"=== CHECK FILE ==="; Get-Content file.txt', "Get-Content"], + ["$x = @'\ndata'; Get-Fake\n'@\nGet-Process", "Get-Process"], + ["@'\nprint('ok; still data')\n'@ | python -", "python"], + ])("handles common PowerShell setup and launch commands: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["timeout --help", "timeout"], + ["nohup --version", "nohup"], + ["arch", "arch"], + ["bundle install", "bundle"], + ["script output.log", "script"], + ["/usr/bin/timeout 10 node app.js", "timeout"], + ])("keeps process-launch wrappers when no safe payload is present: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + "command -v git", + "command -V git", + "command -a git", + "command -pv git", + "builtin -p", + "exec", + "exec > output.log", + "exec --", + "exec cd /tmp && npm test", + "exec env CI=1 cd /tmp && npm test", + "exec CI=1 npm test", + "env CI=1 cd /tmp && npm test", + "env CI=1; npm test", + "sudo cd /tmp && npm test", + "command CI=1 npm test", + "command false && npm test", + "cmd /c false && npm test", + "cd /tmp </tmp/log", + "(xcrun simctl io booted recordVideo /tmp/video.mp4 &) ; wait", + "export CI=1 && (bundle exec pod install || pod install)", + "$PY scripts/check.py", + "${TOOL} --version", + "%TOOL% --version", + "!TOOL! --version", + "& $tool --version", + "& { Get-Process }", + "$value = 'configured'", + "$headers = @{ 'Accept' = 'application/json'; 'Content-Type' = 'application/json' }; Invoke-WebRequest https://example.com", + '"sha256(value)=$hash"', + "broken{", + "@echo off", + ":: comment", + "time -- npm test", + "time -v npm test", + "coproc npm test", + "coproc worker { npm test; }", + "cd [first|second] && pnpm test", + "npm) --version", + "try { Invoke-WebRequest https://example.com } catch { Write-Error $_ }", + "for($i=0; $i -lt 2; $i++){ Start-Sleep 1 }", + "# comment only", + ])("does not treat shell lookup and commandless wrapper forms as executions: %s", (command) => { + expect(commandProgramName(command)).toBeNull(); + }); + + it.each([ + ["parallel -j4", "parallel"], + ["hash --help", "hash"], + ["process --help", "process"], + ["rem comment", "rem"], + ["Exec node app.js", "Exec"], + ["CD /tmp && npm test", "CD"], + ])("does not hide legitimate or case-distinct program names: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["env sh -c 'cd /tmp && npm test'", "npm"], + ["sudo zsh -lc 'export CI=1 && pnpm test'", "pnpm"], + ])("parses shell setup inside an explicitly launched shell: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["nocorrect pnpm test", "pnpm"], + ["noglob bun test", "bun"], + ["time node app.js", "node"], + ["time -p deno test", "deno"], + ["time nocorrect npm test", "npm"], + ])("skips shell precommand modifiers: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it("uses the command after leading shell comments", () => { + expect(commandProgramName("# first comment\n # second comment\ngit status")).toBe("git"); + }); + + it.each([ + ["CI=1 # note\nnpm test", "npm"], + ["CI=1 # it's configured\nnpm test", "npm"], + ['CI=1 # "unterminated quote\nbun test', "bun"], + [">/tmp/log # note\npnpm test", "pnpm"], + [">/tmp/log # it's configured\ndeno test", "deno"], + ])("skips comments after commandless shell setup: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["./cd /tmp && npm test", "cd"], + ["/opt/exec node app.js", "exec"], + ["/usr/bin/time npm test", "time"], + ["/usr/bin/test -f package.json", "test"], + ])("does not treat qualified paths as shell syntax: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it("skips a shell array assignment before the command", () => { + expect(commandProgramName("items=(one two); npm test")).toBe("npm"); + }); + + it.each([ + ['EMU="/opt/android/emulator"; "$EMU" -list-avds', "emulator"], + ["AAPT=/opt/android/aapt2\n$AAPT dump badging app.apk", "aapt2"], + [ + 'SSH=(ssh -i /tmp/key -o IdentitiesOnly=yes); HOST=user@example; "${SSH[@]}" "$HOST" uptime', + "ssh", + ], + ["SCP=(/usr/bin/scp -i /tmp/key); if true; then ${SCP[@]} file user@example:/tmp; fi", "scp"], + ['TOOL="/Applications/My Tool/bin/check"; "$TOOL" --verbose', "check"], + ])("resolves literal command aliases from earlier shell segments: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + 'TOOL=$(pick-command); "$TOOL" --version', + 'TOOL="git status"; "$TOOL"', + "# TOOL=git\n$TOOL status", + 'TOOL=git true; "$TOOL" status', + 'TOOL=git; TOOL=$(pick-command); "$TOOL" status', + 'TOOL=git; unset TOOL; "$TOOL" status', + ])("does not evaluate dynamic or non-persistent command aliases: %s", (command) => { + expect(commandProgramName(command)).toBeNull(); + }); + + it("does not retain aliases assigned inside control flow", () => { + expect(commandProgramName('TOOL=git; if false; then\nTOOL=npm\nfi\n"$TOOL" status')).toBe( + "git", + ); + }); + + it.each([ + ["ROOT=${BASE:-path with spaces}; npm test", "npm"], + ["ROOT=`printf 'path with spaces'`; pnpm test", "pnpm"], + ])("keeps expansions inside assignment words: %s", (command, program) => { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + [">/tmp/log && npm test", "npm"], + ["2>/tmp/error.log; pnpm test", "pnpm"], + ["cd /tmp && >/tmp/log npm test", "npm"], + ["cd /tmp && > /tmp/log pnpm test", "pnpm"], + ["cd /tmp && 2>&1 bun test", "bun"], + ["cd /tmp && 2>& 1 node app.js", "node"], + ["cd /tmp && &>/tmp/log git status", "git"], + ["cd /tmp && *>>/tmp/log vp test", "vp"], + ["cd /tmp && {output}>/tmp/log deno test", "deno"], + ["cd /tmp && << { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["cd /tmp < { + expect(commandProgramName(command)).toBe(program); + }); + + it.each(["cd apps/web && [ -f package.json ]", "cd apps/web || exit 1", "cd one && cd two"])( + "falls back when no useful program follows cd: %s", + (command) => { + expect(commandProgramName(command)).toBeNull(); + }, + ); + + it.each([ + "if test -f package.json\nthen\n npm test\nfi", + "[[ -d first && -d second ]] && npm test", + 'for file in *\ndo\n echo "$file"\ndone', + "while true\ndo\n sleep 1\ndone", + "cd /tmp; build () { npm test; }", + ])("does not label commands inside multiline shell control flow: %s", (command) => { + expect(commandProgramName(command)).toBeNull(); + }); + it("bounds nested shell unwrapping", () => { let command = "git status"; for (let depth = 0; depth < 9; depth += 1) { @@ -54,4 +454,26 @@ describe("commandProgramName", () => { } expect(commandProgramName(command)).toBeNull(); }); + + it("does not spend the shell nesting budget on setup commands", () => { + const command = [ + ...Array.from({ length: 10 }, (_, index) => `export VALUE_${index}=configured`), + "npm test", + ].join("\n"); + + expect(commandProgramName(command)).toBe("npm"); + }); + + it("bounds the number of top-level setup segments", () => { + const command = [ + ...Array.from({ length: 2_000 }, (_, index) => `export VALUE_${index}=configured`), + "npm test", + ].join(";"); + + expect(commandProgramName(command)).toBeNull(); + }); + + it("bounds nested command wrappers", () => { + expect(commandProgramName(`${"command ".repeat(9)}git status`)).toBeNull(); + }); }); diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index d3cd945bf3f5..c074def03eb7 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -1,7 +1,203 @@ type CommandWrapper = "env" | "sudo"; +type CommandProgramContext = "exec" | "shell"; + +const MAX_COMMAND_SEGMENTS = 64; const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]); +const WINDOWS_SHELL_PROGRAMS = new Set([ + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", +]); const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]); +const SHELL_COMMAND_WRAPPERS = new Set(["builtin", "command", "exec"]); +const SHELL_PRECOMMAND_MODIFIERS = new Set(["nocorrect", "noglob", "time"]); +const POWERSHELL_SETUP_PROGRAMS = new Set(["pop-location", "push-location", "set-location"]); +const POWERSHELL_FLAGS = new Set(["-mta", "-nologo", "-noninteractive", "-noprofile", "-sta"]); +const POWERSHELL_OPTIONS_WITH_VALUE = new Set([ + "-configurationname", + "-executionpolicy", + "-inputformat", + "-outputformat", + "-version", + "-windowstyle", + "-workingdirectory", +]); +const START_PROCESS_FLAGS = new Set([ + "-confirm", + "-debug", + "-loaduserprofile", + "-nonewwindow", + "-passthru", + "-usenewenvironment", + "-verbose", + "-wait", + "-whatif", +]); +const START_PROCESS_OPTIONS_WITH_VALUE = new Set([ + "-argumentlist", + "-credential", + "-environment", + "-erroraction", + "-errorvariable", + "-informationaction", + "-informationvariable", + "-outbuffer", + "-outvariable", + "-pipelinevariable", + "-progressaction", + "-redirectstandarderror", + "-redirectstandardinput", + "-redirectstandardoutput", + "-verb", + "-warningaction", + "-warningvariable", + "-windowstyle", + "-workingdirectory", +]); +const SKIPPABLE_SUDO_PROBES = new Set(["[", "[[", "test", "true"]); +const NON_PROGRAM_PREFIX_CHARACTERS = "<>(){}[];|&$`#!%@:"; +const NON_PROGRAM_SUFFIX_CHARACTERS = "){]}`"; + +// These tokens describe shell syntax or shell-local control flow, not a useful +// executable name. Falling back to "command" is less misleading than labels +// such as "Ran if", "Ran [", or "Ran function". +const NON_DESCRIPTIVE_SHELL_PROGRAMS = new Set([ + "!", + "#", + ".", + ":", + "[", + "[[", + "alias", + "and", + "autoload", + "begin", + "bg", + "bind", + "bindkey", + "break", + "builtin", + "caller", + "case", + "catch", + "cd", + "command", + "compgen", + "complete", + "compopt", + "continue", + "coproc", + "declare", + "dirs", + "disown", + "do", + "done", + "elif", + "else", + "enable", + "end", + "esac", + "eval", + "exec", + "exit", + "export", + "false", + "fc", + "fg", + "fi", + "finally", + "for", + "foreach", + "function", + "getopts", + "history", + "if", + "in", + "jobs", + "let", + "local", + "logout", + "mapfile", + "nocorrect", + "noglob", + "not", + "or", + "popd", + "pushd", + "read", + "readarray", + "readonly", + "repeat", + "return", + "select", + "set", + "setopt", + "shift", + "shopt", + "source", + "switch", + "suspend", + "test", + "then", + "time", + "times", + "trap", + "try", + "true", + "type", + "typeset", + "ulimit", + "umask", + "unalias", + "until", + "unset", + "unsetopt", + "wait", + "while", +]); + +// Unlike setup builtins, these can make later segments part of control flow or +// otherwise unreachable, so do not use a later program as the command label. +const TERMINAL_SHELL_PROGRAMS = new Set([ + "and", + "begin", + "break", + "case", + "catch", + "continue", + "coproc", + "do", + "done", + "elif", + "else", + "end", + "esac", + "eval", + "exec", + "exit", + "false", + "fi", + "finally", + "for", + "foreach", + "function", + "if", + "in", + "not", + "or", + "repeat", + "return", + "select", + "switch", + "then", + "try", + "until", + "while", +]); function shellCommandArgumentIndex(tokens: ReadonlyArray, start: number): number | null { for (let index = start; index < tokens.length; index += 1) { @@ -32,7 +228,9 @@ function tokenizeShellCommand(command: string): string[] | null { let current = ""; let quote: '"' | "'" | null = null; let escaping = false; + let inBackticks = false; let substitutionDepth = 0; + let parameterExpansionDepth = 0; let tokenStarted = false; for (let index = 0; index < input.length; index += 1) { @@ -45,9 +243,9 @@ function tokenizeShellCommand(command: string): string[] | null { } if (character === "\\" && quote !== "'") { const nextCharacter = input[index + 1]; - const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current); + const isWindowsPath = quote === null && /^(?:[A-Za-z]:|\.{1,2})(?:\\[^\s]*)?$/u.test(current); if ( - (quote === '"' || isWindowsDrivePath) && + (quote === '"' || isWindowsPath) && nextCharacter !== undefined && nextCharacter !== '"' && nextCharacter !== "\\" && @@ -63,6 +261,12 @@ function tokenizeShellCommand(command: string): string[] | null { tokenStarted = true; continue; } + if (inBackticks) { + current += character; + if (character === "`") inBackticks = false; + tokenStarted = true; + continue; + } if (quote !== null) { if (character === quote) { quote = null; @@ -72,6 +276,31 @@ function tokenizeShellCommand(command: string): string[] | null { tokenStarted = true; continue; } + if (character === "`") { + current += character; + inBackticks = true; + tokenStarted = true; + continue; + } + if (character === "$" && input[index + 1] === "{") { + current += "${"; + parameterExpansionDepth += 1; + tokenStarted = true; + index += 1; + continue; + } + if (character === "{" && parameterExpansionDepth > 0) { + current += character; + parameterExpansionDepth += 1; + tokenStarted = true; + continue; + } + if (character === "}" && parameterExpansionDepth > 0) { + current += character; + parameterExpansionDepth -= 1; + tokenStarted = true; + continue; + } if (character === "$" && input[index + 1] === "(") { current += "$("; substitutionDepth += 1; @@ -79,6 +308,12 @@ function tokenizeShellCommand(command: string): string[] | null { index += 1; continue; } + if (character === "(") { + current += character; + substitutionDepth += 1; + tokenStarted = true; + continue; + } if (character === ")" && substitutionDepth > 0) { current += character; substitutionDepth -= 1; @@ -91,7 +326,7 @@ function tokenizeShellCommand(command: string): string[] | null { continue; } if (/\s/u.test(character)) { - if (substitutionDepth > 0) { + if (substitutionDepth > 0 || parameterExpansionDepth > 0) { current += character; tokenStarted = true; continue; @@ -107,28 +342,843 @@ function tokenizeShellCommand(command: string): string[] | null { tokenStarted = true; } - if (quote !== null || escaping || substitutionDepth > 0) return null; + if ( + quote !== null || + escaping || + inBackticks || + substitutionDepth > 0 || + parameterExpansionDepth > 0 + ) { + return null; + } if (tokenStarted) tokens.push(current); return tokens; } -export function commandProgramName(command: string, depth = 0): string | null { - if (depth >= 8) return null; - const tokens = tokenizeShellCommand(command); +type ShellCommandSplit = { + readonly firstCommand: string; + readonly remainingCommand: string | null; + readonly separator: string | null; +}; + +type Heredoc = { + readonly delimiter: string; + readonly stripTabs: boolean; +}; + +type ShellSeparator = { + readonly index: number; + readonly length: number; +}; + +type ShellCommentRange = { + readonly start: number; + readonly end: number; +}; + +function commandWithoutShellComments( + command: string, + end: number, + comments: ReadonlyArray, +): string { + let result = ""; + let cursor = 0; + for (const comment of comments) { + if (comment.start >= end) break; + result += command.slice(cursor, comment.start); + cursor = Math.min(comment.end, end); + } + return result + command.slice(cursor, end); +} + +function readHeredocDelimiter( + command: string, + start: number, + stripTabs: boolean, +): { readonly heredoc: Heredoc; readonly end: number } | null { + let index = start; + while (command[index] === " " || command[index] === "\t") index += 1; + + let delimiter = ""; + let quote: '"' | "'" | null = null; + let escaping = false; + for (; index < command.length; index += 1) { + const character = command[index]!; + if (escaping) { + delimiter += character; + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + escaping = true; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + else delimiter += character; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (/\s/u.test(character) || ";&|<>()".includes(character)) break; + delimiter += character; + } + + if (!delimiter || quote !== null || escaping) return null; + return { heredoc: { delimiter, stripTabs }, end: index }; +} + +function commandAfterHeredocs( + command: string, + start: number, + heredocs: ReadonlyArray, +): string | null { + let cursor = start; + for (const heredoc of heredocs) { + let foundDelimiter = false; + while (cursor <= command.length) { + const newlineIndex = command.indexOf("\n", cursor); + const lineEnd = newlineIndex === -1 ? command.length : newlineIndex; + const line = command.slice(cursor, lineEnd).replace(/\r$/u, ""); + const comparableLine = heredoc.stripTabs ? line.replace(/^\t+/u, "") : line; + cursor = newlineIndex === -1 ? command.length : newlineIndex + 1; + if (comparableLine === heredoc.delimiter) { + foundDelimiter = true; + break; + } + if (newlineIndex === -1) break; + } + if (!foundDelimiter) return null; + } + + return command.slice(cursor).trim() || null; +} + +function splitFirstShellCommand(command: string): ShellCommandSplit { + let quote: '"' | "'" | null = null; + let powerShellHereStringQuote: '"' | "'" | null = null; + let escaping = false; + let inBackticks = false; + let inComment = false; + let substitutionDepth = 0; + let parameterExpansionDepth = 0; + const heredocs: Heredoc[] = []; + const comments: ShellCommentRange[] = []; + let commentStart = 0; + let separatorBeforeHeredocs: ShellSeparator | null = null; + + for (let index = 0; index < command.length; index += 1) { + const character = command[index]!; + if (powerShellHereStringQuote !== null) { + if ( + character === powerShellHereStringQuote && + command[index + 1] === "@" && + (index === 0 || command[index - 1] === "\n") + ) { + powerShellHereStringQuote = null; + index += 1; + } + continue; + } + if (inComment) { + if (character !== "\n") continue; + inComment = false; + comments.push({ start: commentStart, end: index }); + } + if (escaping) { + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + escaping = true; + continue; + } + if (inBackticks) { + if (character === "`") inBackticks = false; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + continue; + } + if ( + character === "@" && + (command[index + 1] === '"' || command[index + 1] === "'") && + (command[index + 2] === "\n" || (command[index + 2] === "\r" && command[index + 3] === "\n")) + ) { + powerShellHereStringQuote = command[index + 1] as '"' | "'"; + index += 1; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === "`") { + inBackticks = true; + continue; + } + if ( + character === "#" && + (index === 0 || /\s/u.test(command[index - 1]!) || ";&|(".includes(command[index - 1]!)) + ) { + inComment = true; + commentStart = index; + continue; + } + if (character === "$" && command[index + 1] === "{") { + parameterExpansionDepth += 1; + index += 1; + continue; + } + if (character === "{" && parameterExpansionDepth > 0) { + parameterExpansionDepth += 1; + continue; + } + if (character === "}" && parameterExpansionDepth > 0) { + parameterExpansionDepth -= 1; + continue; + } + if (character === "(") { + substitutionDepth += 1; + continue; + } + if (character === ")" && substitutionDepth > 0) { + substitutionDepth -= 1; + continue; + } + if (substitutionDepth > 0 || parameterExpansionDepth > 0) continue; + + if (character === "<" && command[index + 1] === "<" && command[index + 2] !== "<") { + const stripTabs = command[index + 2] === "-"; + const delimiter = readHeredocDelimiter(command, index + (stripTabs ? 3 : 2), stripTabs); + if (delimiter === null) { + return { firstCommand: command.trim(), remainingCommand: null, separator: null }; + } + heredocs.push(delimiter.heredoc); + index = delimiter.end - 1; + continue; + } + + const isDoubleOperator = + (character === "&" && command[index + 1] === "&") || + (character === "|" && (command[index + 1] === "|" || command[index + 1] === "&")); + const isRedirectionAmpersand = + character === "&" && + (command[index - 1] === ">" || command[index - 1] === "<" || command[index + 1] === ">"); + if ((!isDoubleOperator && !";&|\n".includes(character)) || isRedirectionAmpersand) continue; + + if (character === "\n" && heredocs.length > 0) { + const separator = separatorBeforeHeredocs; + const firstCommand = commandWithoutShellComments( + command, + separator?.index ?? index, + comments, + ).trimStart(); + const commandBeforeHeredocs = separator + ? command.slice(separator.index + separator.length, index).trim() + : ""; + const commandFollowingHeredocs = commandAfterHeredocs(command, index + 1, heredocs); + const remainingCommand = [commandBeforeHeredocs, commandFollowingHeredocs] + .filter((part): part is string => Boolean(part)) + .join("\n"); + return { + firstCommand, + remainingCommand: remainingCommand || null, + separator: separator + ? command.slice(separator.index, separator.index + separator.length) + : "\n", + }; + } + if (heredocs.length > 0) { + separatorBeforeHeredocs ??= { + index, + length: isDoubleOperator ? 2 : 1, + }; + if (isDoubleOperator) index += 1; + continue; + } + + const firstCommand = commandWithoutShellComments(command, index, comments).trimStart(); + let nextCommandIndex = index + (isDoubleOperator ? 2 : 1); + while (/\s/u.test(command[nextCommandIndex] ?? "")) nextCommandIndex += 1; + const nextCommand = command.slice(nextCommandIndex).trim(); + return { + firstCommand, + remainingCommand: nextCommand || null, + separator: isDoubleOperator ? command.slice(index, index + 2) : character, + }; + } + + if (inComment) comments.push({ start: commentStart, end: command.length }); + return { + firstCommand: commandWithoutShellComments(command, command.length, comments).trim(), + remainingCommand: null, + separator: null, + }; +} + +function commandWithoutLeadingShellComments(command: string): string | null { + let remainingCommand = command.trimStart(); + while (remainingCommand.startsWith("#")) { + const newlineIndex = remainingCommand.indexOf("\n"); + if (newlineIndex === -1) return null; + remainingCommand = remainingCommand.slice(newlineIndex + 1).trimStart(); + } + return remainingCommand || null; +} + +function withoutShellLineContinuations(command: string): string { + let normalizedCommand = ""; + let quote: '"' | "'" | null = null; + let escaping = false; + + for (let index = 0; index < command.length; index += 1) { + const character = command[index]!; + if (escaping) { + normalizedCommand += character; + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + if (command[index + 1] === "\n") { + index += 1; + continue; + } + if (command[index + 1] === "\r" && command[index + 2] === "\n") { + index += 2; + continue; + } + normalizedCommand += character; + escaping = true; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + } else if (character === '"' || character === "'") { + quote = character; + } + normalizedCommand += character; + } + + return normalizedCommand; +} + +function indexAfterShellRedirection(tokens: ReadonlyArray, index: number): number | null { + const token = tokens[index]; + if (!token || /^[<>]\(/u.test(token)) return null; + const match = token.match( + /^(?:(?:(?:\d+|\*|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|<>|>>|>\||<&|>&|<|>))|&>>|&>)(.*)$/u, + ); + if (!match) return null; + if (match[1]) return index + 1; + return tokens[index + 1] === undefined ? tokens.length + 1 : index + 2; +} + +function serializeShellTokens(tokens: ReadonlyArray): string { + return tokens.map((token) => `'${token.replaceAll("'", "'\\''")}'`).join(" "); +} + +function transparentWrapperCommandIndex( + wrapper: string, + tokens: ReadonlyArray, + index: number, +): number | null { + if (wrapper === "bundle") { + return tokens[index + 1] === "exec" && tokens[index + 2] !== undefined ? index + 2 : null; + } + + if (wrapper === "nohup") { + let targetIndex = index + 1; + if (tokens[targetIndex] === "--") targetIndex += 1; + const target = tokens[targetIndex]; + return target && !target.startsWith("-") ? targetIndex : null; + } + + if (wrapper === "script") { + // BSD `script` takes an output file before the optional command. Requiring + // an option and both operands avoids guessing about a plain `script file`. + return /^-[adkpqr]+$/u.test(tokens[index + 1] ?? "") && tokens[index + 3] !== undefined + ? index + 3 + : null; + } + + if (wrapper === "arch") { + if (/^-(?:arm64|arm64e|i386|x86_64)$/u.test(tokens[index + 1] ?? "")) { + return tokens[index + 2] !== undefined ? index + 2 : null; + } + return tokens[index + 1] === "-arch" && tokens[index + 3] !== undefined ? index + 3 : null; + } + + if (wrapper === "timeout" || wrapper === "gtimeout") { + return /^(?:\d+(?:\.\d*)?|\.\d+)[smhd]?$/u.test(tokens[index + 1] ?? "") && + tokens[index + 2] !== undefined + ? index + 2 + : null; + } + + return null; +} + +function staticProgramName(value: string): string | null { + const trimmedValue = value.trim(); + if (!trimmedValue || /^[A-Za-z][A-Za-z0-9+.-]*:(?![\\/])/u.test(trimmedValue)) return null; + const program = trimmedValue.split(/[\\/]/u).at(-1); + if ( + !program || + (/\s/u.test(program) && !/[\\/]/u.test(trimmedValue)) || + NON_PROGRAM_PREFIX_CHARACTERS.includes(program[0] ?? "") || + NON_PROGRAM_SUFFIX_CHARACTERS.includes(program.at(-1) ?? "") + ) { + return null; + } + return program; +} + +function leadingPowerShellLiteral(command: string): string | null { + const input = command.trimStart(); + const quote = input[0]; + if (quote !== '"' && quote !== "'") return staticProgramName(input.match(/^\S+/u)?.[0] ?? ""); + + let value = ""; + for (let index = 1; index < input.length; index += 1) { + const character = input[index]!; + if (character === "`" && input[index + 1] !== undefined) { + value += input[index + 1]; + index += 1; + continue; + } + if (character === quote) { + if (quote === "'" && input[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return staticProgramName(value); + } + value += character; + } + return null; +} + +function powerShellCallOperatorProgramName(command: string): string | null | undefined { + const match = command.match(/^\s*&\s+([\s\S]*)$/u); + return match ? leadingPowerShellLiteral(match[1]!) : undefined; +} + +type PowerShellAssignment = { + readonly matched: boolean; + readonly program: string | null; +}; + +function powerShellAssignmentProgramName( + command: string, + depth: number, + remainingCommand: string | null, + segmentsRemaining: number, +): PowerShellAssignment { + const assignment = command.match( + /^\s*\$(?:(env|global|local|script):)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*([\s\S]*)$/iu, + ); + if (!assignment) return { matched: false, program: null }; + + // Environment assignments are setup. Their right-hand side is a value, not + // a command, so prefer the next top-level segment when one exists. + if (assignment[1]?.toLowerCase() === "env") { + return { + matched: true, + program: remainingCommand + ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1) + : null, + }; + } + + const value = assignment[2]!.trim(); + // The POSIX-oriented segment splitter does not balance PowerShell arrays or + // hashtables. Do not mistake a key after an internal semicolon for a command. + if (/^(?:\[ordered\]\s*)?@\s*[{(]/iu.test(value)) { + return { matched: true, program: null }; + } + const calledProgram = powerShellCallOperatorProgramName(value); + if (calledProgram !== undefined) return { matched: true, program: calledProgram }; + + const directCommand = value.match(/^(?:@?\(\s*)?([A-Za-z][A-Za-z0-9_.-]*)\b/u)?.[1]; + if (directCommand && !NON_DESCRIPTIVE_SHELL_PROGRAMS.has(directCommand.toLowerCase())) { + const parsedCommand = commandProgramNameInternal(value, depth + 1, "shell", segmentsRemaining); + return { matched: true, program: parsedCommand ?? directCommand }; + } + + return { + matched: true, + program: remainingCommand + ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1) + : null, + }; +} + +type WindowsShellPayload = { + readonly matched: boolean; + readonly program: string | null; +}; + +function windowsShellPayloadProgramName( + shell: string, + tokens: ReadonlyArray, + start: number, + depth: number, + remainingCommand: string | null, + separator: string | null, + segmentsRemaining: number, +): WindowsShellPayload { + const parsePayload = (payload: string | undefined): string | null => { + if (!payload) return null; + const command = + remainingCommand && separator ? `${payload} ${separator} ${remainingCommand}` : payload; + return commandProgramNameInternal(command, depth + 1, "shell", segmentsRemaining); + }; + + if (shell === "cmd" || shell === "cmd.exe") { + for (let index = start; index < tokens.length; index += 1) { + const option = tokens[index]!.toLowerCase(); + if (option !== "/c" && option !== "/k") continue; + const payload = tokens[index + 1]; + return { + matched: true, + program: parsePayload(payload), + }; + } + return { matched: false, program: null }; + } + + for (let index = start; index < tokens.length; index += 1) { + const option = tokens[index]!.toLowerCase(); + if (option === "-command" || option === "-c") { + const payload = tokens[index + 1]; + return { + matched: true, + program: parsePayload(payload), + }; + } + if (option === "-file" || option === "-f") { + return { matched: true, program: staticProgramName(tokens[index + 1] ?? "") }; + } + if (option === "-encodedcommand" || option === "-enc" || option === "-e") { + return { matched: true, program: null }; + } + if (POWERSHELL_OPTIONS_WITH_VALUE.has(option)) { + index += 1; + continue; + } + if (POWERSHELL_FLAGS.has(option)) continue; + if (!option.startsWith("-")) { + return { matched: true, program: staticProgramName(tokens[index]!) }; + } + } + return { matched: false, program: null }; +} + +function startProcessProgramName(tokens: ReadonlyArray, start: number): string | null { + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + const option = token.toLowerCase(); + if (option === "-filepath") return staticProgramName(tokens[index + 1] ?? ""); + if (START_PROCESS_FLAGS.has(option)) continue; + if (START_PROCESS_OPTIONS_WITH_VALUE.has(option)) { + if (tokens[index + 1] === undefined) return null; + index += 1; + continue; + } + if (token.startsWith("-")) return null; + return staticProgramName(token); + } + return null; +} + +function literalAssignmentProgram( + token: string, +): { readonly name: string; readonly program: string | null } | null { + const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/su); + if (!assignment) return null; + const name = assignment[1]!; + let value = assignment[2]!.trim(); + if (!value || /[$`]/u.test(value)) return { name, program: null }; + + if (value.startsWith("(") && value.endsWith(")")) { + const arrayTokens = tokenizeShellCommand(value.slice(1, -1)); + value = arrayTokens?.[0] ?? ""; + } else if (/\s/u.test(value) && !/[\\/]/u.test(value)) { + return { name, program: null }; + } + + return { name, program: staticProgramName(value) }; +} + +function referencedCommandAlias(token: string): string | null { + const reference = token.match( + /^\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)(?:\[@\])?\})$/u, + ); + return reference?.[1] ?? reference?.[2] ?? null; +} + +// Recover only literal aliases declared by an earlier top-level shell segment. +// This covers common `SSH=(ssh ...)` and `TOOL=/path/to/tool` forms without +// evaluating expansions or trying to model general shell state. +function literalCommandAliasProgramName(command: string): string | null { + const aliases = new Map(); + let remainingCommand: string | null = command; + let controlFlowDepth = 0; + + for (let segmentCount = 0; remainingCommand && segmentCount < 64; segmentCount += 1) { + const commandWithoutComments = commandWithoutLeadingShellComments(remainingCommand); + if (commandWithoutComments === null) return null; + const commandSplit = splitFirstShellCommand(commandWithoutComments); + const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand)); + if (tokens === null) return null; + + let commandIndex = tokens[0] === "do" || tokens[0] === "then" ? 1 : 0; + while (commandIndex < tokens.length) { + const indexAfterRedirection = indexAfterShellRedirection(tokens, commandIndex); + if (indexAfterRedirection !== null && indexAfterRedirection <= tokens.length) { + commandIndex = indexAfterRedirection; + continue; + } + if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(tokens[commandIndex] ?? "")) { + commandIndex += 1; + continue; + } + break; + } + + const aliasName = referencedCommandAlias(tokens[commandIndex] ?? ""); + const aliasedProgram = aliasName ? aliases.get(aliasName) : undefined; + if (aliasedProgram) return aliasedProgram; + + const leadingToken = tokens[0]; + if (leadingToken === "fi" || leadingToken === "done" || leadingToken === "esac") { + controlFlowDepth = Math.max(0, controlFlowDepth - 1); + } + if ( + leadingToken === "if" || + leadingToken === "for" || + leadingToken === "while" || + leadingToken === "until" || + leadingToken === "select" || + leadingToken === "case" + ) { + controlFlowDepth += 1; + } + + if (controlFlowDepth === 0 && leadingToken === "unset") { + for (const name of tokens.slice(1)) aliases.delete(name); + } + + const assignmentStart = tokens[0] === "export" ? 1 : 0; + const assignments = tokens.slice(assignmentStart).map(literalAssignmentProgram); + if ( + controlFlowDepth === 0 && + assignments.length > 0 && + assignments.every((assignment) => assignment !== null) + ) { + for (const assignment of assignments) { + if (assignment.program) aliases.set(assignment.name, assignment.program); + else aliases.delete(assignment.name); + } + } + + remainingCommand = commandSplit.remainingCommand; + } + + return null; +} + +function wrappedShellCommandProgramName( + wrapper: string, + tokens: ReadonlyArray, + start: number, + depth: number, + remainingCommand: string | null, + segmentsRemaining: number, +): string | null { + let index = start; + + if (wrapper === "command") { + while (index < tokens.length) { + const option = tokens[index]!; + if (option === "--") { + index += 1; + break; + } + if (!option.startsWith("-") || option === "-") break; + if (option !== "-p") return null; + index += 1; + } + } else if (wrapper === "builtin") { + if (tokens[index] === "--") index += 1; + else if (tokens[index]?.startsWith("-")) return null; + } else if (wrapper === "exec") { + while (index < tokens.length) { + const option = tokens[index]!; + if (option === "--") { + index += 1; + break; + } + if (option === "-a") { + if (tokens[index + 1] === undefined) return null; + index += 2; + continue; + } + if (/^-a.+/u.test(option) || /^-[cl]+$/u.test(option)) { + index += 1; + continue; + } + if (option.startsWith("-") && option !== "-") return null; + break; + } + } + + const wrappedTokens = tokens.slice(index); + if (wrappedTokens.length === 0) return null; + let targetIndex = 0; + while (targetIndex < wrappedTokens.length) { + const indexAfterRedirection = indexAfterShellRedirection(wrappedTokens, targetIndex); + if (indexAfterRedirection === null || indexAfterRedirection > wrappedTokens.length) break; + targetIndex = indexAfterRedirection; + } + const target = wrappedTokens[targetIndex]; + if (target && /^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(target)) return null; + + const wrappedProgram = commandProgramNameInternal( + serializeShellTokens(wrappedTokens), + depth + 1, + wrapper === "exec" ? "exec" : "shell", + segmentsRemaining, + ); + if (wrappedProgram !== null) return wrappedProgram; + + if ( + wrapper !== "exec" && + target && + target === target.toLowerCase() && + NON_DESCRIPTIVE_SHELL_PROGRAMS.has(target) && + !TERMINAL_SHELL_PROGRAMS.has(target) && + remainingCommand + ) { + return commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1); + } + return null; +} + +function parseCommandProgramName( + command: string, + depth: number, + context: CommandProgramContext, + segmentsRemaining: number, +): string | null { + if (depth >= 8 || segmentsRemaining <= 0) return null; + const commandWithoutComments = commandWithoutLeadingShellComments(command); + if (commandWithoutComments === null) return null; + if ( + /^(?:catch|finally|for|foreach|function|if|param|switch|try|while)\s*[{(]/iu.test( + commandWithoutComments, + ) + ) { + return null; + } + if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)\s*\{/u.test(commandWithoutComments)) return null; + // `&&` and `||` inside a `[[ ... ]]` expression are not top-level command + // separators. Keep the label conservative instead of scanning the test body. + if (commandWithoutComments.startsWith("[[")) return null; + const commandSplit = splitFirstShellCommand(commandWithoutComments); + if (/^@["'](?:\r?\n)/u.test(commandSplit.firstCommand.trimStart())) { + return commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + "shell", + segmentsRemaining - 1, + ) + : null; + } + const powerShellAssignment = powerShellAssignmentProgramName( + commandSplit.firstCommand, + depth, + commandSplit.remainingCommand, + segmentsRemaining, + ); + if (powerShellAssignment.matched) return powerShellAssignment.program; + const windowsPath = commandSplit.firstCommand.match( + /^\s*((?:\.{1,2}|%[A-Za-z_][A-Za-z0-9_]*%|\$env:[A-Za-z_][A-Za-z0-9_]*)\\\S+)/iu, + )?.[1]; + if (windowsPath) return staticProgramName(windowsPath); + const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand)); if (tokens === null) return null; + const firstCharacter = commandSplit.firstCommand.trimStart()[0]; + if ( + tokens.length === 1 && + (firstCharacter === '"' || firstCharacter === "'") && + /[\s()=]/u.test(tokens[0] ?? "") && + !/[\\/]/u.test(tokens[0] ?? "") + ) { + return commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + context, + segmentsRemaining - 1, + ) + : null; + } let index = 0; let wrapper: CommandWrapper | null = null; + let executionContext = context; + let sawAssignment = false; + let sawRedirection = false; while (index < tokens.length) { const token = tokens[index]; if (!token) return null; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + const indexAfterRedirection = indexAfterShellRedirection(tokens, index); + if (indexAfterRedirection !== null) { + if (indexAfterRedirection > tokens.length) return null; + sawRedirection = true; + index = indexAfterRedirection; + continue; + } + if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token)) { + sawAssignment = true; index += 1; continue; } + if (executionContext === "shell" && token === ":") { + return commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + executionContext, + segmentsRemaining - 1, + ) + : null; + } + if ( + NON_PROGRAM_PREFIX_CHARACTERS.includes(token[0] ?? "") && + !(token.startsWith("$") && token.includes("/")) + ) { + return executionContext === "shell" && token.startsWith("[") && commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + executionContext, + segmentsRemaining - 1, + ) + : null; + } const tokenProgram = token.split(/[\\/]/).at(-1); + const isUnqualifiedToken = token === tokenProgram; if (tokenProgram === "env" || tokenProgram === "sudo") { wrapper = tokenProgram; + executionContext = "exec"; index += 1; continue; } @@ -140,10 +1190,17 @@ export function commandProgramName(command: string, depth = 0): string | null { if (wrapper !== null && token.startsWith("-")) { if (wrapper === "env" && (token === "-S" || token === "--split-string")) { const splitCommand = tokens[index + 1]; - return splitCommand ? commandProgramName(splitCommand, depth + 1) : null; + return splitCommand + ? commandProgramNameInternal(splitCommand, depth + 1, executionContext, segmentsRemaining) + : null; } if (wrapper === "env" && token.startsWith("--split-string=")) { - return commandProgramName(token.slice("--split-string=".length), depth + 1); + return commandProgramNameInternal( + token.slice("--split-string=".length), + depth + 1, + executionContext, + segmentsRemaining, + ); } if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { if (tokens[index + 1] === undefined) return null; @@ -182,11 +1239,131 @@ export function commandProgramName(command: string, depth = 0): string | null { const scriptIndex = shellCommandArgumentIndex(tokens, index + 1); if (scriptIndex !== null) { const script = tokens[scriptIndex]; - return script ? commandProgramName(script, depth + 1) : null; + return script + ? commandProgramNameInternal(script, depth + 1, "shell", segmentsRemaining) + : null; + } + } + const lowerTokenProgram = tokenProgram?.toLowerCase(); + if (lowerTokenProgram && WINDOWS_SHELL_PROGRAMS.has(lowerTokenProgram)) { + const payload = windowsShellPayloadProgramName( + lowerTokenProgram, + tokens, + index + 1, + depth, + commandSplit.remainingCommand, + commandSplit.separator, + segmentsRemaining, + ); + if (payload.matched) return payload.program; + } + if (lowerTokenProgram === "start-process") { + const startedProgram = startProcessProgramName(tokens, index + 1); + if (startedProgram !== null) return startedProgram; + } + if ( + executionContext === "shell" && + isUnqualifiedToken && + tokenProgram && + SHELL_PRECOMMAND_MODIFIERS.has(tokenProgram) + ) { + index += 1; + if (tokenProgram === "time" && tokens[index] === "-p") index += 1; + if (tokens[index]?.startsWith("-")) return null; + continue; + } + if (isUnqualifiedToken && tokenProgram) { + const targetIndex = transparentWrapperCommandIndex(tokenProgram, tokens, index); + if (targetIndex !== null) { + const wrappedProgram = commandProgramNameInternal( + serializeShellTokens(tokens.slice(targetIndex)), + depth + 1, + "exec", + segmentsRemaining, + ); + if (wrappedProgram !== null) return wrappedProgram; } } + if (isUnqualifiedToken && tokenProgram && SHELL_COMMAND_WRAPPERS.has(tokenProgram)) { + return wrappedShellCommandProgramName( + tokenProgram, + tokens, + index + 1, + depth, + commandSplit.remainingCommand, + segmentsRemaining, + ); + } + if ( + (executionContext === "shell" || + (wrapper === "sudo" && tokenProgram && SKIPPABLE_SUDO_PROBES.has(tokenProgram))) && + isUnqualifiedToken && + tokenProgram && + NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram) && + (!TERMINAL_SHELL_PROGRAMS.has(tokenProgram) || + (tokenProgram === "false" && commandSplit.separator !== "&&")) && + commandSplit.remainingCommand + ) { + return commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + "shell", + segmentsRemaining - 1, + ); + } + if ( + executionContext === "shell" && + isUnqualifiedToken && + lowerTokenProgram && + POWERSHELL_SETUP_PROGRAMS.has(lowerTokenProgram) && + commandSplit.remainingCommand + ) { + return commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + "shell", + segmentsRemaining - 1, + ); + } + if ( + !tokenProgram || + (isUnqualifiedToken && NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram)) || + NON_PROGRAM_PREFIX_CHARACTERS.includes(tokenProgram[0] ?? "") || + NON_PROGRAM_SUFFIX_CHARACTERS.includes(tokenProgram.at(-1) ?? "") || + tokenProgram.endsWith("()") || + /^[A-Za-z_][A-Za-z0-9_]*\(\)\{$/u.test(tokenProgram) + ) { + return null; + } return tokenProgram || null; } + if ((sawAssignment || sawRedirection) && wrapper === null && commandSplit.remainingCommand) { + return commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + executionContext, + segmentsRemaining - 1, + ); + } return null; } + +function commandProgramNameInternal( + command: string, + depth: number, + context: CommandProgramContext, + segmentsRemaining = MAX_COMMAND_SEGMENTS, +): string | null { + if (segmentsRemaining <= 0) return null; + const calledProgram = powerShellCallOperatorProgramName(command); + if (calledProgram !== undefined) return calledProgram; + return ( + parseCommandProgramName(command, depth, context, segmentsRemaining) ?? + (context === "shell" ? literalCommandAliasProgramName(command) : null) + ); +} + +export function commandProgramName(command: string, depth = 0): string | null { + return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS); +} From bf40fa786c521b552eb554bbd4f2c75c4123cd03 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 23:26:31 -0400 Subject: [PATCH 23/36] fix(web): align the sidebar wordmark by baseline (#9578) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/components/sidebar/SidebarChrome.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 55c6863ec1a2..4f115a751422 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -86,19 +86,21 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { - - - Code + + + + Code + ); From 2675e3c70327719a99af4ae6e53e7b74fb8a9be0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 20:29:56 -0700 Subject: [PATCH 24/36] fix(antigravity): keep subagent batches active after launch (#9579) --- .../Layers/AntigravityAdapter.test.ts | 329 ++++++++---------- .../src/provider/Layers/AntigravityAdapter.ts | 49 ++- .../provider/acp/AntigravityProtocol.test.ts | 10 +- .../src/provider/acp/AntigravityProtocol.ts | 2 +- docs/user/providers-antigravity.md | 17 +- .../src/state/subagentRuntime.test.ts | 28 ++ .../src/state/subagentRuntime.ts | 2 + 7 files changed, 227 insertions(+), 210 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 21f16d40a626..92a5d9010814 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -761,81 +761,70 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); - it.effect( - "shows concurrent native subagent calls and their results without inventing metadata", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - yield* h.adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const sending = yield* h.adapter - .sendTurn({ threadId, input: "Review with subagents" }) - .pipe(Effect.forkChild); - const prompt = yield* h.nextPrompt; - for (const id of ["trajectory:4", "trajectory:5"]) { - yield* h.emitNative( - nativeToolUpdate({ - sessionUpdate: "tool_call", - toolCallId: id, - title: "Running start_subagent", - kind: "other", - status: "in_progress", - rawInput: {}, - }), - ); - const running = yield* h.waitForEvent((event) => event.type === "task.progress"); - expect(running.payload).toEqual({ - taskId: id, - taskType: "subagent", - toolUseId: id, - title: "Antigravity subagent", - description: "Antigravity subagent", - status: "running", - }); - yield* h.emitNative( - nativeToolUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: id, - status: "in_progress", - }), - ); - } - for (const [id, status, result] of [ - ["trajectory:4", "completed", "No defects found."], - ["trajectory:5", "failed", "Subagent exceeded its limit."], - ] as const) { - yield* h.emitNative( - nativeToolUpdate({ - sessionUpdate: "tool_call_update", - toolCallId: id, - status, - rawOutput: result, - }), - ); - const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); - expect(completed.payload).toEqual({ - taskId: id, - taskType: "subagent", - toolUseId: id, - title: "Antigravity subagent", - status, - summary: result, - }); - } - yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); - const turn = yield* Fiber.join(sending); - yield* h.waitForEvent((event) => event.type === "turn.completed"); - expect( - h.seen - .filter((event) => event.type.startsWith("task.")) - .every((event) => event.turnId === turn.turnId), - ).toBe(true); - expect(h.seen.filter((event) => event.type.startsWith("item."))).toHaveLength(0); - expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(2); - }), + it.effect("keeps a launched batch active while child tools continue", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Run two readers in one batch" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + // ACP 1.1.1 capture: one launch call covers both children and returns + // only its description before either child finishes. + const started = nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: `${nativeSessionId}:2`, + title: "Running start_subagent", + kind: "other", + status: "in_progress", + rawInput: {}, + }); + yield* h.emitNative(started); + yield* h.waitForEvent((event) => event.type === "task.progress"); + yield* h.emitNative( + nativeToolUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: started.toolCall.toolCallId, + status: "completed", + rawOutput: "Launch subagents", + }, + started.toolCall, + ), + ); + const launched = yield* h.waitForEvent((event) => event.type === "task.progress"); + expect(launched.payload).toMatchObject({ + taskId: started.toolCall.toolCallId, + title: "Antigravity subagent batch", + description: "Launch subagents", + status: "running", + }); + for (const child of ["alpha", "beta"]) { + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: `${child}:1`, + title: "Read file", + kind: "read", + status: "completed", + rawOutput: "File contents", + }), + ); + } + yield* h.drainEvents; + expect(h.seen.filter((event) => event.type === "task.completed")).toHaveLength(0); + expect(h.seen.filter((event) => event.type === "task.updated")).toHaveLength(0); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(sending); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + expect(h.seen.filter((event) => event.type === "task.completed")).toHaveLength(0); + expect(h.seen.find((event) => event.type === "task.updated")?.payload).toMatchObject({ + taskId: started.toolCall.toolCallId, + status: "idle", + description: "Turn ended. Individual agent status is unavailable.", + timelineBypass: true, + }); + }), ); it.effect("waits for a replayed subagent's final status and result", () => @@ -870,7 +859,7 @@ it.layer(layer)("AntigravityAdapter", (it) => { taskId: "replayed:4", taskType: "subagent", toolUseId: "replayed:4", - title: "Antigravity subagent", + title: "Antigravity subagent batch", status: "failed", summary: "Review failed.", }); @@ -878,128 +867,93 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); - it.effect("completes a live subagent delivered in one tool call", () => + it.effect("keeps one-message launches active and ignores late updates after settlement", () => Effect.gen(function* () { const h = yield* makeHarness(); - yield* h.adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const sending = yield* h.adapter - .sendTurn({ threadId, input: "Review with subagents" }) + yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const first = yield* h.adapter + .sendTurn({ threadId, input: "Start readers" }) .pipe(Effect.forkChild); - const prompt = yield* h.nextPrompt; - for (const [id, rawOutput] of [ - ["live:4", "Review complete."], - ["live:5", undefined], - ] as const) { - yield* h.emitNative( - nativeToolUpdate({ - sessionUpdate: "tool_call", - toolCallId: id, - title: "Running start_subagent", - kind: "other", - status: "completed", - rawInput: {}, - ...(rawOutput ? { rawOutput } : {}), - }), - ); - const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); - expect(completed.payload).toMatchObject({ taskId: id, status: "completed" }); - expect(completed.payload.summary).toBe(rawOutput); - } - yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); - yield* Fiber.join(sending); - yield* h.waitForEvent((event) => event.type === "turn.completed"); - expect(h.seen.filter((event) => event.type === "task.updated")).toHaveLength(0); - }), - ); - - for (const settlement of ["cancelled", "interrupted", "completed"] as const) { - it.effect(`does not reopen a ${settlement} subagent on late merged updates`, () => - Effect.gen(function* () { - const h = yield* makeHarness(); - yield* h.adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const first = yield* h.adapter - .sendTurn({ threadId, input: "Start review" }) - .pipe(Effect.forkChild); - const firstPrompt = yield* h.nextPrompt; - const started = nativeToolUpdate({ + const firstPrompt = yield* h.nextPrompt; + const launches = ["Launch readers", undefined].map((rawOutput, index) => + nativeToolUpdate({ sessionUpdate: "tool_call", - toolCallId: "old:4", + toolCallId: `old:${index}`, title: "Running start_subagent", kind: "other", - status: "in_progress", + status: "completed", rawInput: {}, - }); - yield* h.emitNative(started); - yield* h.waitForEvent((event) => event.type === "task.progress"); - if (settlement === "cancelled") { - yield* h.adapter.interruptTurn(threadId); - } else { - if (settlement === "completed") { - yield* h.emitNative( - nativeToolUpdate( - { - sessionUpdate: "tool_call_update", - toolCallId: "old:4", - status: "completed", - rawOutput: "Original result.", - }, - started.toolCall, - ), - ); - } - yield* Deferred.succeed(firstPrompt.result, { stopReason: "end_turn" }); - } - yield* Fiber.join(first); - yield* h.waitForEvent((event) => event.type === "turn.completed"); - const second = yield* h.adapter - .sendTurn({ threadId, input: "Next review" }) - .pipe(Effect.forkChild); - const secondPrompt = yield* h.nextPrompt; - for (const status of ["in_progress", "completed"] as const) { + ...(rawOutput ? { rawOutput } : {}), + }), + ); + for (const launch of launches) yield* h.emitNative(launch); + yield* h.drainEvents; + expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(2); + expect(h.seen.filter((event) => event.type === "task.completed")).toHaveLength(0); + yield* Deferred.succeed(firstPrompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(first); + yield* h.waitForEvent((event) => event.type === "turn.completed"); + const second = yield* h.adapter + .sendTurn({ threadId, input: "Next task" }) + .pipe(Effect.forkChild); + const secondPrompt = yield* h.nextPrompt; + for (const launch of launches) { + for (const status of ["in_progress", "completed", "failed"] as const) { yield* h.emitNative( nativeToolUpdate( { sessionUpdate: "tool_call_update", - toolCallId: "old:4", + toolCallId: launch.toolCall.toolCallId, status, - rawOutput: "Late result.", + rawOutput: "Late update", }, - started.toolCall, + launch.toolCall, ), ); } - yield* h.emitNative( - nativeToolUpdate({ - sessionUpdate: "tool_call", - toolCallId: "new:4", - title: "Running start_subagent", - kind: "other", + } + yield* h.drainEvents; + expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(2); + expect(h.seen.filter((event) => event.type === "task.updated")).toHaveLength(2); + expect(h.seen.filter((event) => event.type === "task.completed")).toHaveLength(0); + yield* Deferred.succeed(secondPrompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(second); + }), + ); + + it.effect("does not report a historical launch as running or completed work", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const launch = nativeToolUpdate({ + sessionUpdate: "tool_call", + toolCallId: "history:2", + title: "Running start_subagent", + kind: "other", + status: "completed", + rawInput: {}, + }); + yield* h.emitNative(launch); + yield* h.emitNative( + nativeToolUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: launch.toolCall.toolCallId, status: "completed", - rawOutput: "New result.", - }), - ); - const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); - expect(completed.payload.taskId).toBe("new:4"); - yield* Deferred.succeed(secondPrompt.result, { stopReason: "end_turn" }); - yield* Fiber.join(second); - yield* h.waitForEvent((event) => event.type === "turn.completed"); - expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(1); - expect( - h.seen.filter( - (event) => event.type === "task.completed" && event.payload.taskId === "old:4", - ), - ).toHaveLength(settlement === "completed" ? 1 : 0); - }), - ); - } + rawOutput: "Launch readers", + }, + launch.toolCall, + ), + ); + yield* h.drainEvents; + expect(h.seen.filter((event) => event.type.startsWith("task."))).toMatchObject([ + { + type: "task.updated", + payload: { status: "idle", timelineBypass: true }, + }, + ]); + }), + ); it.effect("keeps MCP identity when later updates omit metadata", () => Effect.gen(function* () { @@ -1101,6 +1055,15 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); yield* h.waitForEvent((event) => event.type === "task.progress"); + yield* h.emitNative( + nativeToolUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "trajectory:4", + status: "completed", + rawOutput: "Launch subagents", + }), + ); + yield* h.waitForEvent((event) => event.type === "task.progress"); if (stop === "disconnect") { yield* h.emitNative({ _tag: "ConnectionTerminated", @@ -1121,14 +1084,14 @@ it.layer(layer)("AntigravityAdapter", (it) => { const settled = yield* h.waitForEvent((event) => event.type === "task.updated"); expect(settled.payload).toMatchObject({ taskId: "trajectory:4", - title: "Antigravity subagent", + title: "Antigravity subagent batch", taskType: "subagent", status: stop === "disconnect" ? "failed" : stop === "cancel" || stop === "steer" ? "cancelled" - : "interrupted", + : "idle", }); if (stop === "disconnect") yield* h.waitForEvent((event) => event.type === "session.exited"); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 2541d3ee1670..b6c3c785986f 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -71,7 +71,7 @@ import { } from "../acp/AntigravityAcpSupport.ts"; import { antigravityApprovalOptions, - antigravitySubagentResult, + antigravitySubagentOutput, classifyAntigravitySubagentToolCall, extractAntigravityUserInputQuestion, isAntigravityOpenCommand, @@ -170,6 +170,7 @@ interface OpenCommand { interface OpenSubagent { readonly turnId: TurnId | undefined; readonly status: "pending" | "running" | undefined; + readonly description?: string; } function subagentLinkage(toolCallId: string) { @@ -177,7 +178,7 @@ function subagentLinkage(toolCallId: string) { taskId: RuntimeTaskId.make(toolCallId), taskType: "subagent", toolUseId: toolCallId, - title: "Antigravity subagent", + title: "Antigravity subagent batch", }; } @@ -389,7 +390,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const finishSubagents = ( context: SessionContext, - status: Extract, + status: Extract, error?: string, ) => context.commandLock.withPermit( @@ -405,6 +406,12 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi payload: { ...subagentLinkage(id), status, + ...(status === "idle" + ? { + description: "Turn ended. Individual agent status is unavailable.", + timelineBypass: true, + } + : {}), ...(error ? { error } : {}), }, }); @@ -628,8 +635,8 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi context.subagents.set(toolCall.toolCallId, { turnId, status: undefined }); return; } - if (toolCall.status === "completed" || toolCall.status === "failed") { - const summary = antigravitySubagentResult(toolCall); + if (toolCall.status === "failed") { + const summary = antigravitySubagentOutput(toolCall); yield* emit({ type: "task.completed", ...(yield* stamp), @@ -643,19 +650,38 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi }, }); context.subagents.set(toolCall.toolCallId, "finished"); + } else if (context.activeTurnId === undefined && toolCall.status === "completed") { + yield* emit({ + type: "task.updated", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + payload: { + ...linkage, + status: "idle", + description: "Individual agent status is unavailable for this earlier batch.", + timelineBypass: true, + }, + }); + context.subagents.set(toolCall.toolCallId, "finished"); } else { + // start_subagent returns after launching a batch. Its output is + // the launch description, not a child result or completion. const status = toolCall.status === "pending" ? "pending" : "running"; - if (subagent?.status !== status) { + const description = + antigravitySubagentOutput(toolCall) ?? subagent?.description ?? linkage.title; + if (subagent?.status !== status || subagent?.description !== description) { yield* emit({ type: "task.progress", ...(yield* stamp), provider: PROVIDER, threadId: context.threadId, turnId, - payload: { ...linkage, description: linkage.title, status }, + payload: { ...linkage, description, summary: description, status }, }); } - context.subagents.set(toolCall.toolCallId, { turnId, status }); + context.subagents.set(toolCall.toolCallId, { turnId, status, description }); } return; } @@ -972,11 +998,8 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi ? "cancelled" : payload.state === "failed" ? "failed" - : "interrupted", - payload.errorMessage ?? - (payload.state === "completed" - ? "Antigravity ended the turn before reporting a subagent result." - : undefined), + : "idle", + payload.errorMessage, ); context.activeTurnId = undefined; context.promptFiber = undefined; diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 4f3368353ddf..4e7541ee8c10 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -6,7 +6,7 @@ import { extractAntigravityUserInputQuestion, isAntigravityOpenCommand, antigravityApprovalOptions, - antigravitySubagentResult, + antigravitySubagentOutput, isAntigravitySubagentReplayStart, classifyAntigravitySubagentToolCall, isAntigravityUserInputRequest, @@ -48,7 +48,7 @@ describe("native Antigravity subagent tools", () => { ).toBeUndefined(); }); - it("recognizes history starts and bounds the native result", () => { + it("recognizes history starts and bounds the launch output", () => { expect( isAntigravitySubagentReplayStart({ update: { sessionUpdate: "tool_call", status: "completed", rawOutput: "Done." }, @@ -65,19 +65,19 @@ describe("native Antigravity subagent tools", () => { }), ).toBe(false); expect( - antigravitySubagentResult({ + antigravitySubagentOutput({ toolCallId: "trajectory:4", data: { rawOutput: " Finished review. " }, }), ).toBe("Finished review."); - const result = antigravitySubagentResult({ + const result = antigravitySubagentOutput({ toolCallId: "trajectory:4", data: { rawOutput: `${"x".repeat(16_000)}The result.` }, }); expect(result?.length).toBeLessThan(8_100); expect(result?.endsWith("The result.")).toBe(true); expect( - antigravitySubagentResult({ toolCallId: "trajectory:4", data: { rawOutput: {} } }), + antigravitySubagentOutput({ toolCallId: "trajectory:4", data: { rawOutput: {} } }), ).toBeUndefined(); }); }); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 72048a8bb7e9..81ec91d265b0 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -376,7 +376,7 @@ export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean { ); } -export function antigravitySubagentResult(toolCall: AcpToolCallState): string | undefined { +export function antigravitySubagentOutput(toolCall: AcpToolCallState): string | undefined { const output = toolCall.data.rawOutput; return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined; } diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 9c5297daf866..83ebe8034401 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -141,14 +141,15 @@ follow-up message or start a new thread instead. ### Subagents -Antigravity subagent calls appear in **Agents** on web and desktop, and in the work log on -mobile. Each call shows its status and the result or error returned by Antigravity. Calls -that run at the same time have separate entries. - -The official ACP agent does not report subagent names, models, token usage, or parent links. -Entries use the name **Antigravity subagent**. Child tool calls cannot be assigned to an entry -because ACP does not include their owning subagent. These entries track each invocation, -not a separate thread you can open or control. +Subagent launches appear as **Antigravity subagent batch** in **Agents** on web and desktop, +and in the work log on mobile. One launch can start several agents. The batch stays active +after launch while the parent turn runs. When that turn ends, the entry becomes idle and +states that individual agent status is unavailable. Launch errors remain visible. + +The official ACP agent does not send individual child status, names, models, token usage, +or reply ownership. T3 Code cannot show separate child entries or separate child replies +from the parent conversation. The launch description is not a child result. Batch entries +cannot be opened or controlled as separate threads. ## Accounts and removal diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index f87531f15808..2bd644c12cb9 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -66,6 +66,34 @@ function fold(rows: ReadonlyArray) { } describe("foldSubagentActivities", () => { + it("shows the batch status limit after its parent turn ends without claiming a result", () => { + const running = activity("task.progress", { + taskId: "batch-1", + taskType: "subagent", + title: "Antigravity subagent batch", + status: "running", + summary: "Launch readers", + }); + const agents = fold([ + running, + activity("task.updated", { + taskId: "batch-1", + taskType: "subagent", + status: "idle", + detail: "Turn ended. Individual agent status is unavailable.", + timelineBypass: true, + }), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + title: "Antigravity subagent batch", + status: "idle", + progress: "Turn ended. Individual agent status is unavailable.", + result: null, + error: null, + }); + }); + it("builds an agent from start → progress → completion", () => { const agents = fold([ activity("task.started", { diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index c1ea1cc2b15d..cd9970f3f8ce 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -546,6 +546,8 @@ export function foldSubagentActivities( if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); + const detail = asString(payload.detail); + if (detail) agent.progress = bounded(detail); // A task first seen via task.updated (start row aged out) has run at // least once — zero activations would misreport "run 0" and let a // later start row treat it as never-started (review finding). From 09b81a34954c990f70257ae05efbb602c90aac97 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:27:16 -0400 Subject: [PATCH 25/36] fix(mobile): render workspace images in markdown file previews (#8769) --- .../features/files/FileMarkdownPreview.tsx | 56 ++++- .../features/files/ThreadFilesRouteScreen.tsx | 15 +- .../src/features/threads/ThreadFeed.tsx | 187 +---------------- .../features/threads/ThreadMarkdownImage.tsx | 193 ++++++++++++++++++ 4 files changed, 265 insertions(+), 186 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadMarkdownImage.tsx diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index b7497debc524..c3118c1dfa77 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,3 +1,6 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; +import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; import { useCallback, useMemo, useState } from "react"; import { Markdown, @@ -14,12 +17,18 @@ import { resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { + ThreadMarkdownImage, + ThreadMarkdownImageUnavailable, +} from "../threads/ThreadMarkdownImage"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownImageRenderer, type NativeMarkdownTextStyle, } from "../../native/SelectableMarkdownText"; +import { resolveWorkspaceFilePath } from "./filePath"; interface MarkdownPreviewStyles { readonly theme: PartialMarkdownTheme; @@ -28,7 +37,7 @@ interface MarkdownPreviewStyles { readonly nativeTextStyle: NativeMarkdownTextStyle; } -function useMarkdownPreviewStyles(): MarkdownPreviewStyles { +function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): MarkdownPreviewStyles { const { appearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), @@ -69,6 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { {children} ), + image: ({ node }) => + node.href && renderImage + ? (renderImage({ + href: node.href, + alt: node.alt ?? null, + title: node.title ?? null, + }) ?? undefined) + : undefined, }; return { @@ -166,13 +183,18 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { mediumFontFamily, nativeMarkdownTypography, regularFontFamily, + renderImage, strong, boldFontFamily, ]); } export function FileMarkdownPreview(props: { + readonly cwd: string; + readonly environmentId: EnvironmentId; readonly markdown: string; + readonly relativePath: string; + readonly threadId: ThreadId; readonly onRefresh?: () => Promise | void; }) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); @@ -187,7 +209,36 @@ export function FileMarkdownPreview(props: { setIsPullRefreshing(false); } }, [props.onRefresh]); - const styles = useMarkdownPreviewStyles(); + const markdownDirectory = useMemo( + () => getBrowseDirectoryPath(resolveWorkspaceFilePath(props.cwd, props.relativePath)), + [props.cwd, props.relativePath], + ); + const renderImage = useCallback( + (image) => { + const media = resolveMediaSource(image.href, { + threadId: props.threadId, + workspaceRoot: markdownDirectory, + imageEmbed: true, + }); + if (media?.access === "direct") { + return null; + } + if (media === null || media.kind !== "image" || media.access === "unavailable") { + return ; + } + return ( + undefined} + /> + ); + }, + [markdownDirectory, props.environmentId, props.threadId], + ); + const styles = useMarkdownPreviewStyles(renderImage); const onLinkPress = useCallback((href: string) => { void tryOpenExternalUrl(href, "markdown-link"); }, []); @@ -210,6 +261,7 @@ export function FileMarkdownPreview(props: { ) : ( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 91b832e68d15..2c3422673b00 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -106,7 +106,8 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; - readonly environmentId: EnvironmentId | null; + readonly cwd: string; + readonly environmentId: EnvironmentId; readonly previewUri: string | null; readonly previewFailure: AssetUrlFailureReason | null; readonly onRetryPreview: () => void; @@ -116,6 +117,7 @@ function FileContent(props: { readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; + readonly threadId: ThreadId; readonly initialLine: number | null; readonly truncated: boolean; readonly onRefresh?: () => Promise | void; @@ -199,7 +201,14 @@ function FileContent(props: { ) : null} {props.activeMode === "preview" && isMarkdown ? ( - + ) : ( fileQuery.refresh()} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a25045c60112..ab48046fbd96 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2,7 +2,6 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import type { - AssetResource, ChatAttachment, ChatFileAttachment, ChatImageAttachment, @@ -68,7 +67,6 @@ import { type ColorValue, useWindowDimensions, View, - type ViewStyle, } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { isPdfFile } from "../../lib/filePreview"; @@ -102,8 +100,6 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; -import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; -import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { attachmentVideoPreviewSource, mediaVideoPreviewUri, @@ -178,8 +174,12 @@ import { isAbsolutePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; +import { + ThreadMarkdownImage, + ThreadMarkdownImageUnavailable, + ThreadMarkdownImageView, +} from "./ThreadMarkdownImage"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout @@ -505,171 +505,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) { ); } -function ThreadMarkdownImageView(props: { - readonly uri: string | null; - readonly sourceKey: string; - readonly unavailable: boolean; - readonly alt: string | null; - readonly actionsSource?: MediaActionsSource; - readonly onPressPreview: (source: FilePreviewSource) => void; -}) { - const sourceIdentifier = useId(); - const mediaActions = useMediaActions(props.actionsSource); - const [availableWidth, setAvailableWidth] = useState(0); - const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); - const [failedUri, setFailedUri] = useState(null); - - useEffect(() => { - setSourceSize(null); - }, [props.sourceKey]); - - useEffect(() => { - setFailedUri(null); - }, [props.uri]); - - const displaySize = - sourceSize === null - ? null - : resolveMarkdownImageDisplaySize({ - sourceWidth: sourceSize.width, - sourceHeight: sourceSize.height, - availableWidth, - }); - const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); - const placeholderWidth: ViewStyle["width"] = - availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; - const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; - - return ( - setAvailableWidth(event.nativeEvent.layout.width)} - style={{ alignSelf: "stretch", gap: 6 }} - > - {props.uri === null || failed ? ( - - 0 ? "Touch and hold for media actions" : undefined - } - className="items-center justify-center rounded-[10px] bg-md-code-bg" - style={frameStyle} - > - {failed ? ( - Image unavailable - ) : ( - - )} - - - ) : ( - - - 0 ? "Touch and hold for media actions" : undefined - } - onPress={() => - // Quick Look picks the viewer from the name's extension, so it needs the - // file name rather than the alt text. - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.actionsSource?.name ?? props.alt ?? "Image", - sourceIdentifier, - actionsSource: props.actionsSource, - }) - } - style={{ alignSelf: "flex-start" }} - > - - setFailedUri(props.uri)} - /> - - - - - )} - {props.alt ? ( - - {props.alt} - - ) : null} - - ); -} - -function ThreadMarkdownImageRequest(props: { - readonly uri: string; - readonly onLoad: (sourceSize: { width: number; height: number }) => void; - readonly onError: () => void; -}) { - const [loaded, setLoaded] = useState(false); - - return ( - <> - { - setLoaded(true); - props.onLoad(event.nativeEvent.source); - }} - onError={props.onError} - style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} - /> - {loaded ? null : ( - - Loading image… - - )} - - ); -} - -/** Environment-hosted image that loads through a signed asset URL. */ -function ThreadMarkdownImage(props: { - readonly environmentId: EnvironmentId; - readonly resource: Extract; - readonly alt: string | null; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; - readonly onPressPreview: (source: FilePreviewSource) => void; -}) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - - return ( - - ); -} - const ThreadMediaVisibleContext = createContext(false); // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; @@ -713,18 +548,6 @@ function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource } ); } -function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { - return ( - undefined} - /> - ); -} - const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx new file mode 100644 index 000000000000..c506bf1875eb --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -0,0 +1,193 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useId, useState } from "react"; +import { + ActivityIndicator, + Image, + Pressable, + StyleSheet, + View, + type ViewStyle, +} from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import type { FilePreviewSource } from "../../components/FilePreviewModal"; +import { MediaActionsMenu } from "../../components/MediaActionsMenu"; +import { PresentationSource } from "../../components/NativePresentation"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; +import { useAssetUrlState } from "../../state/assets"; +import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; + +export function ThreadMarkdownImageView(props: { + readonly uri: string | null; + readonly sourceKey: string; + readonly unavailable: boolean; + readonly alt: string | null; + readonly actionsSource?: MediaActionsSource; + readonly onPressPreview: (source: FilePreviewSource) => void; +}) { + const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); + const [availableWidth, setAvailableWidth] = useState(0); + const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const [failedUri, setFailedUri] = useState(null); + + useEffect(() => { + setSourceSize(null); + }, [props.sourceKey]); + + useEffect(() => { + setFailedUri(null); + }, [props.uri]); + + const displaySize = + sourceSize === null + ? null + : resolveMarkdownImageDisplaySize({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + availableWidth, + }); + const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const placeholderWidth: ViewStyle["width"] = + availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; + const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; + + return ( + setAvailableWidth(event.nativeEvent.layout.width)} + style={{ alignSelf: "stretch", gap: 6 }} + > + {props.uri === null || failed ? ( + + 0 ? "Touch and hold for media actions" : undefined + } + className="items-center justify-center rounded-[10px] bg-md-code-bg" + style={frameStyle} + > + {failed ? ( + Image unavailable + ) : ( + + )} + + + ) : ( + + + 0 ? "Touch and hold for media actions" : undefined + } + onPress={() => + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.actionsSource?.name ?? props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + + + )} + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + +function ThreadMarkdownImageRequest(props: { + readonly uri: string; + readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onError: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + return ( + <> + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : ( + + Loading image… + + )} + + ); +} + +/** Environment-hosted image that loads through a signed asset URL. */ +export function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly resource: Extract; + readonly alt: string | null; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; + readonly onPressPreview: (source: FilePreviewSource) => void; +}) { + const assetUrl = useAssetUrlState(props.environmentId, props.resource); + + return ( + + ); +} + +export function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { + return ( + undefined} + /> + ); +} From b34ff8f56469afa8f3f85d89894e1b4cf49b5213 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 21:38:45 -0700 Subject: [PATCH 26/36] fix(usage): deduplicate CLI proxy subscription accounts (#9584) --- .../src/features/usage/UsageLimitsSection.tsx | 23 +++- apps/web/src/components/usage/UsageLimits.tsx | 55 ++++---- docs/user/usage.md | 11 +- packages/shared/src/usageLimits.test.ts | 129 +++++++++++++++++- packages/shared/src/usageLimits.ts | 47 ++++++- 5 files changed, 215 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 8ed89b1a94cc..411fbc145519 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -28,6 +28,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; /** * One window as a bar spanning its whole duration: the fill is quota spent, @@ -74,6 +75,7 @@ function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly function AccountLimits(props: { readonly label: string; + readonly instanceLabel: string; readonly detail: string | undefined; readonly limits: ServerProvider["usageLimits"]; readonly now: number; @@ -85,10 +87,13 @@ function AccountLimits(props: { const notice = limitsNotice(limits); return ( - + {props.label} + {props.instanceLabel !== props.label ? ( + · {props.instanceLabel} + ) : null} {props.detail ? ( - {props.detail} + · {props.detail} ) : null} {notice ? ( @@ -195,7 +200,8 @@ function ProviderLimits(props: { const credits = provider.usageLimits?.resetCredits; return ( undefined)} + label={DRIVER_LABEL[provider.driver] ?? String(provider.driver)} + instanceLabel={providerLimitsLabel(provider, (driver) => DRIVER_LABEL[driver])} detail={provider.auth.label} limits={provider.usageLimits} now={now} @@ -214,8 +220,6 @@ function ProviderLimits(props: { ); } -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; - /** Emails stay off the phone screen; the plan and driver identify the row. */ function SourceAccountLimits(props: { readonly account: UsageLimitSourceAccount; @@ -226,6 +230,7 @@ function SourceAccountLimits(props: { return ( {sources.map((source) => ( - + {source.error ? ( {source.error} ) : source.accounts.length === 0 ? ( - No accounts reported. + + {source.hiddenAccountCount > 0 + ? "All accounts are shown by connected providers." + : "No accounts reported."} + ) : ( source.accounts.map((account, index) => ( +

{label} - {plan ? · {plan} : null} + {instanceLabel !== label ? ( + + · {instanceLabel} + + ) : null} + {plan ? · {plan} : null} {email ? ( ) : null} - {badge ? ( - - {badge} - - ) : null}

); } @@ -273,7 +272,8 @@ function ProviderLimits({
getDriverOption(driver)?.label)} + label={getDriverOption(provider.driver)?.label ?? String(provider.driver)} + instanceLabel={providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label)} plan={provider.auth.label} email={provider.auth.email} accentColor={provider.accentColor} @@ -394,9 +394,9 @@ function SourceAccountLimits({ {notice ? ( {notice} @@ -413,9 +413,11 @@ function SourceAccountLimits({ * environment can run a turn against them. */ const SOURCE_KIND_LABEL: Record = { - cliproxy: "CLIProxyAPI", + cliproxy: "CLI Proxy", }; +type LimitsSource = ReturnType[number]; + /** * Removing a hub also deletes its management key from the server, so it * asks first and says so. A bare icon that acted on click was too easy to @@ -467,7 +469,7 @@ function SourceLimits({ now, onRemove, }: { - readonly source: UsageLimitSourceSnapshot; + readonly source: LimitsSource; readonly now: number; readonly onRemove: (() => void) | null; }) { @@ -475,15 +477,17 @@ function SourceLimits({ return (
-

- {source.label} · {kind} -

+

{source.label}

{onRemove ? : null}
{source.error ? ( {source.error} ) : source.accounts.length === 0 ? ( - No accounts reported. + + {source.hiddenAccountCount > 0 + ? "All accounts are shown by connected providers." + : "No accounts reported."} + ) : ( source.accounts.map((account) => ( @@ -524,16 +528,7 @@ function useCanOperateEnvironment(environment: EnvironmentPresentation | null): } /** One source with a remove control bound to the environment it lives in. */ -function SourceLimitsRow({ - source, - now, -}: { - readonly source: UsageLimitSourceSnapshot & { - readonly key: string; - readonly environmentId: EnvironmentId; - }; - readonly now: number; -}) { +function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) { const updateSettings = useUpdateEnvironmentSettings(source.environmentId); const { environments } = useEnvironments(); const environment = diff --git a/docs/user/usage.md b/docs/user/usage.md index 5b9e474c6aad..2fcb5c54210b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,10 +18,13 @@ provider health-check interval and update live while a turn runs. API-key accoun subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. -If you pool accounts behind a CLIProxyAPI hub, **Add CLIProxyAPI hub** on the Limits view shows -every account the hub manages, each marked _via CLIProxyAPI_ so it is not mistaken for the provider -signed in on this machine. Enter the hub's URL and management key; the key is stored on the server -and never sent back to a client. Emails are blurred until clicked, as in provider settings. +If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts +the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for +hub accounts. When a connected provider reports limits for the same provider and email, its row +replaces the hub copy, keeping details such as banked reset credits. The hub copy remains visible +if the connected provider cannot report limits. Enter the hub's URL and management key; the key +is stored on the server and never sent back to a client. Emails are blurred until clicked, as in +provider settings. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 33f180ac9d6c..83ac6906c61e 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,11 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UsageLimitSourceAccount, + UsageLimitSourceId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => { describe("collectLimitSources", () => { const source = { - id: "cliproxy-hub" as never, + id: UsageLimitSourceId.make("cliproxy-hub"), kind: "cliproxy" as const, label: "hub", checkedAt: "2026-09-03T11:00:00.000Z", accounts: [], }; + const limits = { checkedAt: source.checkedAt, windows: [window] }; + const account: UsageLimitSourceAccount = { + id: "codex-personal", + driver: ProviderDriverKind.make("codex"), + email: "person@example.com", + plan: "ChatGPT Pro Subscription", + usageLimits: limits, + }; + const native = provider({ + displayName: "Personal", + auth: { status: "authenticated", email: account.email }, + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }); + + function presentations( + providers: readonly ServerProvider[], + accounts: readonly UsageLimitSourceAccount[] = [account], + ) { + return new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { providers, usageLimitSources: [{ ...source, accounts }] }, + }, + ], + ]); + } + + it.each(["codex", "claudeAgent"])( + "prefers native %s limits by email without changing provider rows or source snapshots", + (kind) => { + const driver = ProviderDriverKind.make(kind); + const first = { ...native, driver }; + const second = { ...first, instanceId: ProviderInstanceId.make("work") }; + const accounts = [{ ...account, driver, email: " Person@Example.COM " }]; + const input = presentations([first, second], accounts); + + expect(collectLimitSources(input)).toMatchObject([{ accounts: [], hiddenAccountCount: 1 }]); + expect(collectLimitsGroups(input)[0]?.providers).toEqual([first, second]); + expect(accounts).toHaveLength(1); + expect(first.usageLimits?.resetCredits?.availableCount).toBe(2); + }, + ); + + it("matches across environments even when the hub is visited before the native provider", () => { + const input = presentations([]); + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [native], usageLimitSources: [] }, + }); + + expect(collectLimitSources(input)).toMatchObject([ + { accounts: [], hiddenAccountCount: 1, environmentId: "env-a" }, + ]); + }); + + it("keeps other providers, other emails, and unidentified accounts with the same plan", () => { + const accounts = [ + account, + { ...account, id: "other-provider", driver: ProviderDriverKind.make("claudeAgent") }, + { ...account, id: "other-email", email: "other@example.com" }, + { ...account, id: "unknown-email", email: undefined }, + ]; + + expect(collectLimitSources(presentations([native], accounts))).toMatchObject([ + { accounts: accounts.slice(1), hiddenAccountCount: 1 }, + ]); + expect( + collectLimitSources( + presentations([{ ...native, auth: { status: "authenticated" } }], accounts), + )[0]?.accounts, + ).toEqual(accounts); + }); + + it.each([ + { enabled: false }, + { installed: false }, + { availability: "unavailable" }, + { usageLimits: undefined }, + { usageLimits: { ...limits, windows: [] } }, + { usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }, + { usageLimits: { ...limits, unavailable: { reason: "unsupported" } } }, + ] satisfies Partial[])( + "retains hub limits when the native provider cannot show them: %j", + (overrides) => { + expect(collectLimitSources(presentations([{ ...native, ...overrides }]))).toMatchObject([ + { accounts: [account], hiddenAccountCount: 0 }, + ]); + }, + ); + + it("restores the hub account when the matching provider disappears", () => { + const input = presentations([native]); + expect(collectLimitSources(input)[0]?.accounts).toEqual([]); + input.delete(EnvironmentId.make("env-a")); + for (const [id, entry] of presentations([])) input.set(id, entry); + + expect(collectLimitSources(input)[0]?.accounts).toEqual([account]); + }); + + it("keeps source errors and genuinely empty sources distinguishable from hidden accounts", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { + providers: [native], + usageLimitSources: [{ ...source, error: "Hub unavailable" }], + }, + }, + ], + ]); + expect(collectLimitSources(input)).toMatchObject([ + { accounts: [], hiddenAccountCount: 0, error: "Hub unavailable" }, + ]); + }); it("keys sources per environment and names the environment only when several have some", () => { const one = new Map([ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 3c5b39ea06a7..8341796e39c3 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -70,6 +70,8 @@ export function collectLimitsGroups( * Every usage-limit source across connected environments, keyed so two * environments pointing at the same hub still get their own rows. The label * carries the environment only when more than one environment has sources. + * A native provider with usable limits takes precedence over the same account + * in a source, even when it belongs to another connected environment. */ export function collectLimitSources( presentations: ReadonlyMap< @@ -77,13 +79,31 @@ export function collectLimitSources( { readonly entry: { readonly target: { readonly label: string } }; readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; } | null; } >, ): ReadonlyArray< - UsageLimitSourceSnapshot & { readonly key: string; readonly environmentId: EnvironmentId } + UsageLimitSourceSnapshot & { + readonly key: string; + readonly environmentId: EnvironmentId; + readonly hiddenAccountCount: number; + } > { + const nativeAccounts = new Set(); + for (const presentation of presentations.values()) { + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + const key = accountKey(provider.driver, provider.auth.email); + if ( + key !== null && + provider.usageLimits?.windows.length && + !provider.usageLimits.unavailable + ) { + nativeAccounts.add(key); + } + } + } const perEnvironment: Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -100,15 +120,28 @@ export function collectLimitSources( } const labelEnvironment = perEnvironment.length > 1; return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => - sources.map((source) => ({ - ...source, - environmentId, - key: `${environmentId}:${source.id}`, - label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, - })), + sources.map((source) => { + const accounts = source.accounts.filter((account) => { + const key = accountKey(account.driver, account.email); + return key === null || !nativeAccounts.has(key); + }); + return { + ...source, + accounts, + hiddenAccountCount: source.accounts.length - accounts.length, + environmentId, + key: `${environmentId}:${source.id}`, + label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, + }; + }), ); } +function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { + const normalizedEmail = email?.trim().toLowerCase(); + return normalizedEmail ? `${driver}:${normalizedEmail}` : null; +} + /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( provider: ServerProvider, From 07891e9569c88457516b44c08c820471762969e8 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 00:47:09 -0400 Subject: [PATCH 27/36] fix(web): bound disconnected send toasts (#9592) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 97e63a2bd0a1..1b9c106fe523 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1335,6 +1335,8 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; + /** * Drops the send-time anchored end space. That space is what holds a sent * message near the top while its turn streams, and it keeps LegendList's @@ -1641,6 +1643,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); @@ -6073,13 +6076,17 @@ function ChatViewContent(props: ChatViewProps) { return; } if (activeEnvironmentUnavailable) { - toastManager.add( - stackedThreadToast({ + const toastSlot = environmentUnavailableSendToastSlotRef.current; + environmentUnavailableSendToastSlotRef.current = + (toastSlot + 1) % ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE; + toastManager.add({ + ...stackedThreadToast({ type: "warning", title: "Not connected: message not sent", description: "Reconnecting to the environment. Try again once it is connected.", }), - ); + id: `chat-send-environment-unavailable:${toastSlot}`, + }); return; } if (activePendingProgress) { From 57832803eed4c87c462de92892777a0934019721 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 01:07:11 -0400 Subject: [PATCH 28/36] fix(desktop): restore panel titlebar interactions (#9591) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 10 +++++++++- apps/web/src/components/RightPanelTabs.tsx | 12 ++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1b9c106fe523..6cd83054b8e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1888,6 +1888,7 @@ function ChatViewContent(props: ChatViewProps) { ); const rightPanelPresent = rightPanelPresence.present; const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; + const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( @@ -7679,7 +7680,7 @@ function ChatViewContent(props: ChatViewProps) { return (
- {!rightPanelControlsInPanel ? panelLayoutControls : null} + {rightPanelControlsAtRoot ? panelLayoutControls : null}
+ {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null}
@@ -1031,6 +1028,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onContextMenu={(event) => void handleTabContextMenu(event, surface)} className={cn( "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", @@ -1235,6 +1233,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
) : null} {props.layoutControls} + {ownsDesktopTitleBar ? ( + + ) : null}
{props.activeSurfaceId === null ? ( From 39abb9d1d6ae6501c573b9dc0cb9c28e2f75659c Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 01:07:49 -0400 Subject: [PATCH 29/36] fix(connect): refresh authorization without disconnecting (#9582) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/connection/supervisor.test.ts | 167 +++++++++- .../src/connection/supervisor.ts | 309 +++++++++++++++--- 2 files changed, 414 insertions(+), 62 deletions(-) diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index ca7f83a213a6..8f38633d55f6 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1097,40 +1097,183 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("renews a relay connection before its DPoP access token expires", () => + it.effect("hands off relay authorization after the replacement session is ready", () => Effect.gen(function* () { const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; + const replacementStarted = yield* Deferred.make(); + const releaseReplacement = yield* Deferred.make(); const harness = yield* makeHarness({ prepare: (attempt) => - Effect.succeed({ - ...PREPARED_CONNECTION, - target: RELAY_TARGET, - httpAuthorization: { - _tag: "Dpop", - accessToken: `access-token-${attempt}`, - expiresAtEpochMs: tokenLifetimeMs * attempt, - }, - }), + attempt === 2 + ? Effect.fail(transient("Authorization refresh failed.")) + : Effect.succeed({ + ...PREPARED_CONNECTION, + target: RELAY_TARGET, + httpAuthorization: { + _tag: "Dpop", + accessToken: `access-token-${attempt}`, + expiresAtEpochMs: tokenLifetimeMs * attempt, + }, + }), + ready: (attempt) => + attempt === 2 + ? Deferred.succeed(replacementStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseReplacement)), + ) + : Effect.void, }); const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); + const firstPrepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)); yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - 1); expect(yield* Ref.get(harness.sessionCount)).toBe(1); yield* TestClock.adjust(1); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + yield* Effect.yieldNow; + + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + yield* TestClock.adjust("1 milli"); + yield* Deferred.await(replacementStarted); + + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "connected", + generation: 1, + }); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared))).toBe( + firstPrepared, + ); + + yield* Deferred.succeed(releaseReplacement, undefined); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); - expect(yield* Ref.get(harness.sessionCount)).toBe(2); expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).not.toBe( + firstSession, + ); expect( Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).httpAuthorization, - ).toMatchObject({ accessToken: "access-token-2" }); + ).toMatchObject({ accessToken: "access-token-3" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("cleans up timed-out replacements and an interrupted retry", () => + Effect.gen(function* () { + const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; + const replacementStarted = yield* Deferred.make(); + const retryStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: (attempt) => + Effect.succeed({ + ...PREPARED_CONNECTION, + target: RELAY_TARGET, + httpAuthorization: { + _tag: "Dpop", + accessToken: `access-token-${attempt}`, + expiresAtEpochMs: tokenLifetimeMs * attempt, + }, + }), + ready: (attempt) => { + if (attempt === 2) { + return Deferred.succeed(replacementStarted, undefined).pipe( + Effect.andThen(Effect.never), + ); + } + if (attempt === 3) { + return Deferred.succeed(retryStarted, undefined).pipe(Effect.andThen(Effect.never)); + } + return Effect.void; + }, + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); + yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS); + yield* Deferred.await(replacementStarted); + + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + + yield* TestClock.adjust("15 seconds"); + yield* Effect.yieldNow; + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "connected", + generation: 1, + }); + + yield* TestClock.adjust("3 seconds"); + yield* Deferred.await(retryStarted); + expect(yield* Ref.get(harness.sessionCount)).toBe(3); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession); + + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(3); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not keep a relay session past its authorization expiry", () => + Effect.gen(function* () { + const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Effect.succeed({ + ...PREPARED_CONNECTION, + target: RELAY_TARGET, + httpAuthorization: { + _tag: "Dpop", + accessToken: "access-token-1", + expiresAtEpochMs: tokenLifetimeMs, + }, + }) + : Effect.fail(transient("Authorization refresh failed.")), + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS); + yield* Effect.yieldNow; + for (const delay of [3_000, 4_000, 8_000, 16_000, 16_000, 13_000]) { + yield* TestClock.adjust(delay); + yield* Effect.yieldNow; + } + + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "backoff", + generation: 1, + }); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 6766d28f88bc..9d5452344499 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -60,16 +60,24 @@ interface TracedAttemptFailure { readonly attemptSpan: Option.Option; } +interface ScopedConnection { + readonly attemptSpan: Option.Option; + readonly lease: ConnectionDriver.EnvironmentConnectionLease; + readonly scope: Scope.Closeable; +} + type AttemptOutcome = | { readonly _tag: "Interrupted"; readonly established: boolean; + readonly generation: number; readonly stable: boolean; readonly resetRetry: boolean; } | { readonly _tag: "Failure"; readonly established: boolean; + readonly generation: number; readonly stable: boolean; readonly failure: TracedAttemptFailure; }; @@ -77,17 +85,26 @@ type AttemptOutcome = type EstablishmentEvent = | { readonly _tag: "Completed"; - readonly exit: Exit.Exit< - { - readonly attemptSpan: Option.Option; - readonly lease: ConnectionDriver.EnvironmentConnectionLease; - }, - TracedAttemptFailure - >; + readonly exit: Exit.Exit; } | { readonly _tag: "Interrupted"; readonly resetRetry: boolean } | { readonly _tag: "TimedOut" }; +type ReplacementPreparationEvent = + | { readonly _tag: "Completed"; readonly exit: Exit.Exit } + | { readonly _tag: "TimedOut" } + | { readonly _tag: "AuthorizationExpired" }; + +type ConnectedLeaseEvent = + | { + readonly _tag: "ActiveCompleted"; + readonly exit: Exit.Exit; + } + | { + readonly _tag: "ReplacementCompleted"; + readonly exit: Exit.Exit, TracedAttemptFailure>; + }; + function exitUnlessInterrupted( effect: Effect.Effect, ): Effect.Effect, never, R> { @@ -168,16 +185,18 @@ function failureFromExit( target: ConnectionTarget, exit: Exit.Exit, established: boolean, + generation: number, stable: boolean, ): AttemptOutcome { if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { - return { _tag: "Interrupted", established, stable, resetRetry: false }; + return { _tag: "Interrupted", established, generation, stable, resetRetry: false }; } const typedFailure = exit.cause.reasons.find(Cause.isFailReason); if (typedFailure) { return { _tag: "Failure", established, + generation, stable, failure: typedFailure.error, }; @@ -185,6 +204,7 @@ function failureFromExit( return { _tag: "Failure", established, + generation, stable, failure: { error: new ConnectionTransientError({ @@ -289,9 +309,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( attempt: number, generation: number, lastFailure: ConnectionAttemptError | null, + publishProgress: boolean, ) { return yield* driver.connect(entry, (progress) => - reportProgress(attempt, generation, lastFailure, progress), + publishProgress ? reportProgress(attempt, generation, lastFailure, progress) : Effect.void, ); }); @@ -342,16 +363,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( generation: number, lastFailure: ConnectionAttemptError | null, pendingRetry: Option.Option, + publishProgress: boolean, ) { if (target._tag === "RelayConnectionTarget") { return yield* traceRelayEstablishment( - establishConnection(attempt, generation, lastFailure), + establishConnection(attempt, generation, lastFailure, publishProgress), attempt, generation, pendingRetry, ); } - return yield* establishConnection(attempt, generation, lastFailure).pipe( + return yield* establishConnection(attempt, generation, lastFailure, publishProgress).pipe( Effect.map((lease) => ({ attemptSpan: Option.none(), lease, @@ -363,6 +385,37 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); }); + const forkScopedTracedConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + publishProgress: boolean, + ) { + const parentScope = yield* Scope.Scope; + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const connectionScope = yield* Scope.fork(parentScope, "sequential"); + const fiber = yield* restore( + establishTracedConnection( + attempt, + generation, + lastFailure, + pendingRetry, + publishProgress, + ).pipe( + Scope.provide(connectionScope), + Effect.map( + (established) => + ({ ...established, scope: connectionScope }) satisfies ScopedConnection, + ), + ), + ).pipe(Effect.forkChild); + return { fiber, scope: connectionScope }; + }), + ); + }); + const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { for (;;) { const next = yield* Queue.take(signals); @@ -484,32 +537,136 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); - const waitForAuthorizationRefresh = Effect.fnUntraced(function* ( + const waitForAuthorizationDeadline = Effect.fnUntraced(function* ( preparedConnection: PreparedConnection, + skewMs: number, ) { const authorization = preparedConnection.httpAuthorization; if (authorization?._tag !== "Dpop") { return yield* Effect.never; } const now = yield* Clock.currentTimeMillis; - yield* Effect.sleep( - Math.max(0, authorization.expiresAtEpochMs - now - DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS), + yield* Effect.sleep(Math.max(0, authorization.expiresAtEpochMs - now - skewMs)); + }); + + const waitForActiveCompletion = Effect.fnUntraced(function* (active: ScopedConnection) { + const exit = yield* exitUnlessInterrupted( + Effect.raceAllFirst([ + active.lease.session.closed.pipe( + Effect.mapError((error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + })), + ), + monitorConnectedLease(active.lease).pipe( + Effect.mapError((error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + })), + ), + ]), ); - yield* Effect.logDebug("Refreshing the environment connection before its DPoP token expires."); - return true; + return { _tag: "ActiveCompleted", exit } satisfies ConnectedLeaseEvent; + }); + + const prepareReplacement = Effect.fnUntraced(function* ( + active: ScopedConnection, + generation: number, + ) { + yield* waitForAuthorizationDeadline(active.lease.prepared, DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS); + yield* Effect.logDebug( + "Preparing a replacement environment connection before its DPoP token expires.", + ); + + let failureCount = 0; + for (;;) { + const candidate = yield* forkScopedTracedConnection( + failureCount + 1, + generation, + null, + Option.none(), + false, + ); + const replacement = yield* Effect.raceAllFirst([ + Fiber.await(candidate.fiber).pipe( + Effect.map((exit): ReplacementPreparationEvent => ({ _tag: "Completed", exit })), + ), + waitForAuthorizationDeadline(active.lease.prepared, 0).pipe( + Effect.as({ _tag: "AuthorizationExpired" }), + ), + Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( + Effect.as({ _tag: "TimedOut" }), + ), + ]); + + if (replacement._tag !== "Completed") { + yield* Fiber.interrupt(candidate.fiber); + yield* Fiber.await(candidate.fiber); + yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore); + } else if (Exit.isFailure(replacement.exit)) { + yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore); + } + if (replacement._tag === "AuthorizationExpired") { + return Option.none(); + } + if (replacement._tag === "Completed" && Exit.isSuccess(replacement.exit)) { + return Option.some(replacement.exit.value); + } + + let replacementError: ConnectionTransientError; + if (replacement._tag === "Completed") { + if (Exit.isSuccess(replacement.exit)) { + return yield* Effect.die("A successful replacement was not installed."); + } + const failure = Cause.findErrorOption(replacement.exit.cause); + if (Option.isNone(failure) || failure.value.error._tag === "ConnectionBlockedError") { + return yield* Effect.failCause(replacement.exit.cause); + } + replacementError = failure.value.error; + } else { + replacementError = new ConnectionTransientError({ + reason: "timeout", + detail: `${target.label} did not respond during connection setup.`, + }); + } + + const retryDelay = retryDelayMs(failureCount); + failureCount += 1; + yield* Effect.logWarning( + "Could not prepare a replacement environment connection; keeping the active connection.", + ).pipe( + Effect.annotateLogs({ + "authorization.refresh.retry_delay_ms": retryDelay, + ...safeErrorLogAttributes(replacementError), + }), + ); + const retryBeforeExpiry = yield* Effect.raceFirst( + Effect.sleep(retryDelay).pipe(Effect.as(true)), + waitForAuthorizationDeadline(active.lease.prepared, 0).pipe(Effect.as(false)), + ); + if (!retryBeforeExpiry) { + return Option.none(); + } + } }); const runAttempt = Effect.fnUntraced(function* ( attempt: number, - generation: number, + previousGeneration: number, lastFailure: ConnectionAttemptError | null, pendingRetry: Option.Option, ) { + const initialGeneration = previousGeneration + 1; yield* SubscriptionRef.set(prepared, Option.none()); + const initial = yield* forkScopedTracedConnection( + attempt, + initialGeneration, + lastFailure, + pendingRetry, + true, + ); const establishment = yield* Effect.raceAllFirst([ - exitUnlessInterrupted( - establishTracedConnection(attempt, generation, lastFailure, pendingRetry), - ).pipe( + Fiber.await(initial.fiber).pipe( Effect.map((exit): EstablishmentEvent => ({ _tag: "Completed", exit, @@ -526,10 +683,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ]); + if (establishment._tag !== "Completed") { + yield* Fiber.interrupt(initial.fiber); + yield* Fiber.await(initial.fiber); + yield* Scope.close(initial.scope, Exit.void).pipe(Effect.ignore); + } else if (Exit.isFailure(establishment.exit)) { + yield* Scope.close(initial.scope, Exit.void).pipe(Effect.ignore); + } if (establishment._tag === "Interrupted") { return { _tag: "Interrupted", established: false, + generation: previousGeneration, stable: false, resetRetry: establishment.resetRetry, } satisfies AttemptOutcome; @@ -538,6 +703,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( return { _tag: "Failure", established: false, + generation: previousGeneration, stable: false, failure: { error: new ConnectionTransientError({ @@ -552,7 +718,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const isUnexpectedDefect = !Cause.hasInterruptsOnly(establishment.exit.cause) && !establishment.exit.cause.reasons.some(Cause.isFailReason); - const outcome = failureFromExit(target, establishment.exit, false, false); + const outcome = failureFromExit(target, establishment.exit, false, previousGeneration, false); if (isUnexpectedDefect) { const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect; yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( @@ -567,18 +733,20 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( return outcome; } - const active = establishment.exit.value; const currentIntent = yield* Ref.get(intent); if (!currentIntent.desired || currentIntent.network === "offline") { return { _tag: "Interrupted", established: false, + generation: previousGeneration, stable: false, resetRetry: false, } satisfies AttemptOutcome; } const connectedAt = yield* Clock.currentTimeMillis; + let active = establishment.exit.value; + let activeGeneration = initialGeneration; yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); yield* SubscriptionRef.set(session, Option.some(active.lease.session)); yield* setState({ @@ -587,36 +755,78 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( phase: "connected", stage: null, attempt, - generation, + generation: activeGeneration, lastFailure: null, retryAt: null, }); - const connectedExit = yield* Effect.raceAllFirst([ - active.lease.session.closed.pipe( - Effect.mapError((error): TracedAttemptFailure => ({ - error, - attemptSpan: active.attemptSpan, - })), - ), - monitorConnectedLease(active.lease).pipe( - Effect.mapError((error): TracedAttemptFailure => ({ - error, - attemptSpan: active.attemptSpan, - })), - ), - waitForAuthorizationRefresh(active.lease.prepared), - ]).pipe(exitUnlessInterrupted); - const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; - if (Exit.isSuccess(connectedExit)) { - return { - _tag: "Interrupted", - established: true, - stable: connectedForMs >= BACKOFF_RESET_AFTER_MS, - resetRetry: connectedExit.value, - } satisfies AttemptOutcome; + for (;;) { + const connectedEvent = yield* Effect.raceAllFirst([ + waitForActiveCompletion(active), + exitUnlessInterrupted(prepareReplacement(active, activeGeneration + 1)).pipe( + Effect.map((exit): ConnectedLeaseEvent => ({ _tag: "ReplacementCompleted", exit })), + ), + ]); + const stable = (yield* Clock.currentTimeMillis) - connectedAt >= BACKOFF_RESET_AFTER_MS; + if (connectedEvent._tag === "ActiveCompleted") { + if (Exit.isSuccess(connectedEvent.exit)) { + return { + _tag: "Interrupted", + established: true, + generation: activeGeneration, + stable, + resetRetry: connectedEvent.exit.value, + } satisfies AttemptOutcome; + } + return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable); + } + if (Exit.isFailure(connectedEvent.exit)) { + return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable); + } + if (Option.isNone(connectedEvent.exit.value)) { + return { + _tag: "Interrupted", + established: true, + generation: activeGeneration, + stable, + resetRetry: true, + } satisfies AttemptOutcome; + } + + const candidate = connectedEvent.exit.value.value; + const replacementIntent = yield* Ref.get(intent); + if (!replacementIntent.desired || replacementIntent.network === "offline") { + yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore); + return { + _tag: "Interrupted", + established: true, + generation: activeGeneration, + stable, + resetRetry: false, + } satisfies AttemptOutcome; + } + + const previous = active; + yield* Effect.uninterruptible( + Effect.gen(function* () { + active = candidate; + activeGeneration += 1; + yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); + yield* SubscriptionRef.set(session, Option.some(active.lease.session)); + yield* setState({ + desired: true, + network: replacementIntent.network, + phase: "connected", + stage: null, + attempt: 1, + generation: activeGeneration, + lastFailure: null, + retryAt: null, + }); + yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore); + }), + ); } - return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); }, Effect.ensuring(clearLease)); const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { @@ -681,15 +891,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } const attempt = failureCount + 1; - const nextGeneration = generation + 1; const outcome: AttemptOutcome = yield* Effect.scoped( - runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), + runAttempt(attempt, generation, latestFailure, pendingRetry), ); // Consumed on every iteration so a stale marker can never leak into a // later, unrelated failure. const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); + generation = outcome.generation; if (outcome.established) { - generation = nextGeneration; if (outcome.stable) { resetRetryLadder(); latestFailure = null; From f559fe0ba6fb5950bd14a2404f10b9c94b33f696 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:25:37 +0100 Subject: [PATCH 30/36] fix(web): show context meter in compact composer (#9430) --- apps/web/src/components/chat/ChatComposer.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e2d4bda1b1d6..8993fbcab89d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1057,7 +1057,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; showSendWhileRunning?: boolean; - showSecondaryStatus: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -1067,7 +1066,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( }) { return ( <> - {props.showSecondaryStatus && props.activeContextWindow ? ( + {props.activeContextWindow ? ( Date: Fri, 4 Sep 2026 02:16:25 -0400 Subject: [PATCH 31/36] fix(pull-requests): refresh data after thread turns (#9496) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../OrchestrationEngineHarness.integration.ts | 6 +++ apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/CheckpointReactor.test.ts | 15 ++++++ .../orchestration/Layers/CheckpointReactor.ts | 48 +++++++++++++++++-- .../pullRequest/PullRequestService.test.ts | 19 +++++--- .../src/pullRequest/PullRequestService.ts | 17 ++++++- apps/server/src/ws.ts | 6 +++ .../pullRequest/PullRequestDetailPanel.tsx | 22 ++++++--- apps/web/src/routes/_chat.pull-requests.tsx | 19 ++++++++ apps/web/src/state/pullRequests.ts | 25 +++++++++- packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/pullRequests.test.ts | 20 ++++++++ .../client-runtime/src/state/pullRequests.ts | 19 ++++++++ packages/client-runtime/src/state/runtime.ts | 18 ++++++- packages/contracts/src/rpc.ts | 14 +++++- 15 files changed, 229 insertions(+), 21 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b4e04fd44f60..ce34855a3194 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -86,6 +86,7 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; +import * as PullRequestService from "../src/pullRequest/PullRequestService.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); @@ -350,6 +351,11 @@ export const makeOrchestrationIntegrationHarness = ( ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge( + Layer.mock(PullRequestService.PullRequestService)({ + refreshAfterTurn: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 50adf83bfc53..de6661f45886 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = { // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope, // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 87a239b13d92..391602548774 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -62,6 +62,7 @@ import { ProviderValidationError } from "../../provider/Errors.ts"; import { ServerConfig } from "../../config.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; +import { PullRequestService } from "../../pullRequest/PullRequestService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -325,6 +326,8 @@ describe("CheckpointReactor", () => { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-reactor-test-", }); + const pullRequestRefreshes: number[] = []; + const refreshAfterTurn = Effect.sync(() => void pullRequestRefreshes.push(1)); const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: (cwd: string) => @@ -355,6 +358,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( @@ -466,6 +470,7 @@ describe("CheckpointReactor", () => { provider, cwd, drain, + pullRequestRefreshes, }; } @@ -766,6 +771,14 @@ describe("CheckpointReactor", () => { NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-turn-started-aux"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-aux"), + }); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-aux"), @@ -782,6 +795,7 @@ describe("CheckpointReactor", () => { const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.checkpoints).toHaveLength(0); expect(pullRequestRefreshCalls).toEqual([]); + expect(harness.pullRequestRefreshes).toEqual([]); harness.provider.emit({ type: "turn.completed", @@ -801,6 +815,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); await harness.drain(); expect(pullRequestRefreshCalls).toEqual([harness.cwd]); + expect(harness.pullRequestRefreshes).toEqual([1]); }); it("captures pre-turn and completion checkpoints for claude runtime events", async () => { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0fc6295d4495..aa0e233a5dc3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -38,6 +38,7 @@ import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; +import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -88,6 +89,9 @@ const make = Effect.gen(function* () { const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const pullRequests = yield* PullRequestService.PullRequestService; + const startedTurns = new Map(); + const pending = new Set(); const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -854,6 +858,7 @@ const make = Effect.gen(function* () { const processDomainEvent = Effect.fn("processDomainEvent")(function* (event: OrchestrationEvent) { if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") { + if (event.type === "thread.turn-start-requested") pending.add(event.payload.threadId); yield* ensurePreTurnBaselineFromDomainTurnStart(event); return; } @@ -897,14 +902,46 @@ const make = Effect.gen(function* () { const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( event: ProviderRuntimeEvent, ) { + if (event.type === "session.exited") { + startedTurns.delete(event.threadId); + pending.delete(event.threadId); + return; + } + if (event.type === "turn.started") { + const turnId = toTurnId(event.turnId); + const activeTurnId = (yield* providerService.listSessions()).find((session) => + sameId(session.threadId, event.threadId), + )?.activeTurnId; + const mayReplace = pending.has(event.threadId) && sameId(activeTurnId, turnId); + if (turnId !== null && (!startedTurns.has(event.threadId) || mayReplace)) { + startedTurns.set(event.threadId, turnId); + pending.delete(event.threadId); + } yield* ensurePreTurnBaselineFromTurnStart(event); return; } - if (event.type === "turn.completed") { + if (event.type === "turn.completed" || event.type === "turn.aborted") { const turnId = toTurnId(event.turnId); - yield* refreshLocalGitStatusFromTurnCompletion(event); + const thread = yield* resolveThreadDetail(event.threadId); + const startedTurnId = startedTurns.get(event.threadId); + const isTrackedTurn = sameId(startedTurnId, turnId); + if (isTrackedTurn) startedTurns.delete(event.threadId); + if (event.type === "turn.completed") { + yield* refreshLocalGitStatusFromTurnCompletion(event); + } + if ( + turnId !== null && + thread !== undefined && + (isTrackedTurn || + sameId(thread.session?.activeTurnId, turnId) || + (startedTurnId === undefined && !thread.session?.activeTurnId)) + ) { + pending.delete(event.threadId); + yield* pullRequests.refreshAfterTurn; + } + if (event.type === "turn.aborted") return; yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => Effect.flatMap(nowIso, (createdAt) => @@ -963,7 +1000,12 @@ const make = Effect.gen(function* () { yield* forkParked( Stream.runForEach(providerService.streamEvents, (event) => { - if (event.type !== "turn.started" && event.type !== "turn.completed") { + if ( + event.type !== "turn.started" && + event.type !== "turn.completed" && + event.type !== "turn.aborted" && + event.type !== "session.exited" + ) { return Effect.void; } return worker.enqueue({ source: "runtime", event }); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0dc7a928c264..1f2ff59a94d3 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2646,10 +2646,11 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => }), ); -it.effect("an explicit invalidation makes the next listing ask the host again", () => +it.effect("explicit and turn invalidations make the next listing ask the host again", () => Effect.gen(function* () { let hostCalls = 0; let viewerCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ @@ -2673,11 +2674,14 @@ it.effect("an explicit invalidation makes the next listing ask the host again", assert.strictEqual(viewerCalls, 2); // Forgetting one change request leaves the listings shared. - yield* service.invalidate({ - reference: { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, - }); + yield* service.invalidate({ reference }); yield* service.list({ state: "open" }); assert.strictEqual(hostCalls, 2); + yield* service.refreshAfterTurn; + const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); + yield* service.list({ state: "open" }); + assert.isAbove(refresh, 0); + assert.strictEqual(hostCalls, 3); }), ); @@ -3900,7 +3904,7 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () => }), ); -it.effect("forgets the cached detail after a rewrite, like the other mutations", () => +it.effect("forgets the cached detail after a rewrite or terminal turn", () => Effect.gen(function* () { let coreCalls = 0; const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; @@ -3935,8 +3939,11 @@ it.effect("forgets the cached detail after a rewrite, like the other mutations", yield* service.detail(reference); yield* service.update({ ...reference, title: "Renamed" }); yield* service.detail(reference); - assert.strictEqual(coreCalls, 2); + + yield* service.refreshAfterTurn; + yield* service.detail(reference); + assert.strictEqual(coreCalls, 3); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index b27fff3534a7..88a8ffe32df3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -148,6 +149,8 @@ export class PullRequestService extends Context.Service< never, Scope.Scope >; + readonly subscribeRefreshes: Stream.Stream; + readonly refreshAfterTurn: Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -531,6 +534,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string export const make = Effect.gen(function* () { const mergedPullRequests = yield* PubSub.sliding(64); + const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry; const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -2123,10 +2127,12 @@ export const make = Effect.gen(function* () { // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. let epochCounter = 0; let listingsEpoch = 0; + let turnRefreshEpoch = 0; const refEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; - const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; + const refEpoch = (ref: PullRequestRef) => + Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); const refCacheKey = (ref: PullRequestRef) => JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); const bumpRefEpoch = (ref: PullRequestRef) => { @@ -2396,6 +2402,11 @@ export const make = Effect.gen(function* () { }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); }; + const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { + turnRefreshEpoch = listingsEpoch = ++epochCounter; + return SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch); + }); + // A mutation's own client re-reads right after it, and every other client's next read must // see the action too — so a write forgets the change request it touched and the listings its // state change reorders, for everyone, without any client asking. @@ -2435,6 +2446,10 @@ export const make = Effect.gen(function* () { subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), + subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe( + Stream.filter((revision) => revision > 0), + ), + refreshAfterTurn, detail, activity, threadComments, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0a9b5b64389d..35f41d58e319 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2096,6 +2096,12 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => + observeRpcStream( + WS_METHODS.pullRequestsSubscribeRefreshes, + pullRequests.subscribeRefreshes, + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsReviewerCandidates]: (input) => observeRpcEffect( WS_METHODS.pullRequestsReviewerCandidates, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index adce43dd3344..9bfc49848803 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -64,7 +64,11 @@ import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; +import { + pullRequestEnvironment, + usePullRequestTurnRefresh, + useSharedPullRequestSummary, +} from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -577,6 +581,7 @@ export function PullRequestDetailPanel({ const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); + const turnRefresh = usePullRequestTurnRefresh(environmentId); const [cachedDetail, setCachedDetail] = useState(() => readPullRequestDetailSnapshot( typeof window === "undefined" ? undefined : window.localStorage, @@ -675,6 +680,8 @@ export function PullRequestDetailPanel({ detailQuery.refresh(); activityQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh]); + const [refreshToken, setRefreshToken] = useState(0); + const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( null, ); @@ -698,15 +705,18 @@ export function PullRequestDetailPanel({ // revision effect above reads it only after this same pull request reports a change. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull // request every time it is opened. - useLiveRefresh(detailQuery.refresh, { - key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`, - }); + useLiveRefresh( + () => { + detailQuery.refresh(); + setRefreshToken((token) => token + 1); + }, + { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}` }, + ); // The button, on the other hand, goes around the server's cache rather than through it: it is // the answer for a reader who can see that what they are looking at is behind. The // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); - const [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { await invalidate({ environmentId, input: { reference } }); refreshDetail(); @@ -2352,7 +2362,7 @@ export function PullRequestDetailPanel({ fixFindingLabel={handoffLabels.fixFinding} onFixFinding={startFixFinding} onRefresh={refreshDetail} - refreshToken={refreshToken} + refreshToken={codeRefreshToken} />
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 6db4f99b1502..bcc22daa4a9d 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -135,6 +135,7 @@ import { pullRequestEnvironment, usePullRequestList, usePullRequestListStats, + usePullRequestTurnRefreshes, type EnvironmentQueryTarget, } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; @@ -632,6 +633,12 @@ function PullRequestsRouteView() { .join("|"), [environmentQueries], ); + const turnRefreshes = usePullRequestTurnRefreshes( + environmentQueries.map(({ environmentId }) => environmentId), + ); + const turnRefreshToken = turnRefreshes + .map(([environmentId, revision]) => `${environmentId}:${revision}`) + .join("|"); // Page size is view state, not a URL concern: a shared link should open the first page. const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`; const filterKey = `${scopeKey}:${sentQuery}`; @@ -1091,6 +1098,18 @@ function PullRequestsRouteView() { }); }; + const appliedTurnRefreshToken = useRef(""); + const refreshAfterTurn = useEffectEvent(() => { + if (sentCursors !== null) refreshList(); + }); + useEffect(() => { + if (turnRefreshToken.length === 0 || appliedTurnRefreshToken.current === turnRefreshToken) { + return; + } + appliedTurnRefreshToken.current = turnRefreshToken; + refreshAfterTurn(); + }, [turnRefreshToken]); + // The list goes stale the same way the detail does: somebody opens a pull request, a check // finishes, a branch is merged. So it reads again on the way back to the window, and once a // minute while somebody is reading it. Those reads go through the server's cache and stop diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index bde8b4c2d9cf..99b2ef37c99a 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -24,8 +24,10 @@ import { import { formatEnvironmentQueryError } from "./query"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); -export const linkedPullRequestDetailAtom = - createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); +export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily( + connectionAtomRuntime, + pullRequestEnvironment.refreshes, +); const observedPullRequestSummaryAtom = Atom.family((key: string) => Atom.make(null).pipe( @@ -148,6 +150,25 @@ const usePullRequestStatsQuery = createMergedEnvironmentQuery( pullRequestEnvironment.listStats, ); +const usePullRequestTurnRefreshQuery = createMergedEnvironmentQuery( + "web-pull-requests:turn-refreshes", + ({ environmentId }: EnvironmentQueryTarget>>) => + pullRequestEnvironment.refreshes({ environmentId, input: {} }), +); + +export function usePullRequestTurnRefreshes( + environmentIds: ReadonlyArray, +): ReadonlyArray { + return usePullRequestTurnRefreshQuery( + environmentIds.map((environmentId) => ({ environmentId, input: {} })), + ).values; +} + +export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null { + const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} })); + return Option.getOrNull(AsyncResult.value(result)); +} + export interface MergedPullRequestListView { readonly data: MergedPullRequestList | null; readonly error: string | null; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 38eb3735ab8f..0d68d2b2d531 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -52,6 +52,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeResourceTelemetry + | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 618d5c39418b..6a22b22f1bda 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1,8 +1,10 @@ import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; @@ -42,8 +44,10 @@ function session(client: WsRpcProtocolClient): RpcSession { it.effect("refreshes pull request activity after a comment is updated", () => Effect.scoped( Effect.gen(function* () { + const refreshEvents = yield* PubSub.unbounded(); let commentBody = "old comment"; const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), [WS_METHODS.pullRequestsActivity]: () => Effect.succeed({ author: null, @@ -138,6 +142,22 @@ it.effect("refreshes pull request activity after a comment is updated", () => (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).comments[0] ?.body, ).toBe("updated"); + const refreshed = Latch.makeUnsafe(); + const stop = registry.subscribe(activity, (result) => { + if (AsyncResult.isSuccess(result) && result.value.comments[0]?.body === "after turn") { + refreshed.openUnsafe(); + } + }); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + + commentBody = "after turn"; + yield* PubSub.publish(refreshEvents, 1); + yield* refreshed.await; + + expect( + (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).comments[0] + ?.body, + ).toBe("after turn"); }), ), ); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 28c85ffe5c1e..7ebaba0fed03 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -15,6 +15,7 @@ import { createAtomCommandScheduler, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, createEnvironmentQueryAtomFamily, } from "./runtime.ts"; import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; @@ -34,9 +35,19 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; +function createPullRequestRefreshAtomFamily( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:pull-requests:turn-refreshes", + tag: WS_METHODS.pullRequestsSubscribeRefreshes, + }); +} + /** Refresh only the live fields a linked thread renders. */ export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, + refreshes = createPullRequestRefreshAtomFamily(runtime), ) { return createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:linked-summary", @@ -44,6 +55,7 @@ export function createLinkedPullRequestSummaryAtomFamily( staleTimeMs: 60_000, refreshIntervalMs: 60_000, idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }); } @@ -69,6 +81,7 @@ export function pullRequestDetailToVcsStatus( export function createPullRequestEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { + const refreshes = createPullRequestRefreshAtomFamily(runtime); const commandScheduler = createAtomCommandScheduler(); const serialPerEnvironment = { mode: "serial", @@ -78,12 +91,16 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:activity", tag: WS_METHODS.pullRequestsActivity, staleTimeMs: 15_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }); return { + refreshes, list: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list", tag: WS_METHODS.pullRequestsList, staleTimeMs: 30_000, + refreshTrigger: ({ environmentId, input }) => + input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined, }), /** * The line counts for rows the listing has already handed over. Its own query because the @@ -95,11 +112,13 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:list-stats", tag: WS_METHODS.pullRequestsListStats, staleTimeMs: 60_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), detail: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:detail", tag: WS_METHODS.pullRequestsDetail, staleTimeMs: 15_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), activity, threadComments: createEnvironmentRpcCommand(runtime, { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 4b0fc330839e..56489a4ba668 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -51,6 +51,10 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt readonly staleTimeMs?: number; readonly idleTtlMs?: number; readonly refreshIntervalMs?: number; + readonly refreshTrigger?: (target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; + }) => Atom.Atom | undefined; } interface EnvironmentSubscriptionAtomOptions { @@ -565,10 +569,15 @@ export function createEnvironmentQueryAtomFamily( }), Atom.setIdleTTL(idleTtlMs), ); - return ( + const intervalQuery = options.refreshIntervalMs === undefined ? queryAtom - : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)) + : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)); + const refreshTrigger = options.refreshTrigger?.(target); + return ( + refreshTrigger === undefined + ? intervalQuery + : intervalQuery.pipe(Atom.makeRefreshOnSignal(refreshTrigger)) ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`)); }); return (target) => family(environmentRpcKey(target)); @@ -615,6 +624,10 @@ export function createEnvironmentRpcQueryAtomFamily; + }) => Atom.Atom | undefined; }, ) { return createEnvironmentQueryAtomFamily(runtime, { @@ -624,6 +637,7 @@ export function createEnvironmentRpcQueryAtomFamily) => request(options.tag, input), }); } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c0ef8cd56d6d..f7f2c2b6faa7 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderAuthCancelInput, ProviderAuthCompleteInput, @@ -343,6 +343,7 @@ export const WS_METHODS = { pullRequestsSetThreadResolution: "pullRequests.setThreadResolution", pullRequestsSetReaction: "pullRequests.setReaction", pullRequestsInvalidate: "pullRequests.invalidate", + pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", pullRequestsLabelCandidates: "pullRequests.labelCandidates", @@ -714,6 +715,16 @@ export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInval error: PullRequestRpcError, }); +export const WsPullRequestsSubscribeRefreshesRpc = Rpc.make( + WS_METHODS.pullRequestsSubscribeRefreshes, + { + payload: Schema.Struct({}), + success: NonNegativeInt, + error: EnvironmentAuthorizationError, + stream: true, + }, +); + /** * Read on its own rather than as part of the detail: the people who may be asked are only wanted * once somebody opens the menu, and reading them with every change request would spend a request @@ -1215,6 +1226,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsSetThreadResolutionRpc, WsPullRequestsSetReactionRpc, WsPullRequestsInvalidateRpc, + WsPullRequestsSubscribeRefreshesRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, WsPullRequestsLabelCandidatesRpc, From caab2fdbac041ac2e851ad4fa3ac4a40a1d4a8f6 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:17:18 +0300 Subject: [PATCH 32/36] fix(web): render draft PRs in gray (#9537) --- .../features/threads/thread-list-items.tsx | 16 +++++++---- .../src/state/thread-pr-presentation.ts | 7 +++-- apps/mobile/src/state/use-thread-pr.test.ts | 9 ++++++ apps/server/src/git/GitManager.test.ts | 15 ++++++---- apps/server/src/git/GitManager.ts | 10 +++---- .../src/pullRequest/GitHubPullRequestCli.ts | 2 ++ .../src/pullRequest/PullRequestProvider.ts | 2 ++ .../src/pullRequest/PullRequestService.ts | 2 ++ .../AzureDevOpsSourceControlProvider.ts | 2 ++ .../BitbucketSourceControlProvider.ts | 1 + .../src/sourceControl/GitHubCli.test.ts | 4 ++- apps/server/src/sourceControl/GitHubCli.ts | 5 ++-- .../GitHubSourceControlProvider.test.ts | 2 +- .../GitHubSourceControlProvider.ts | 3 +- apps/server/src/sourceControl/GitLabCli.ts | 1 + .../GitLabSourceControlProvider.ts | 1 + .../sourceControl/azureDevOpsPullRequests.ts | 3 ++ .../sourceControl/bitbucketPullRequests.ts | 3 ++ .../src/sourceControl/gitHubPullRequests.ts | 3 ++ .../src/sourceControl/gitLabMergeRequests.ts | 4 +++ apps/web/src/components/Sidebar.tsx | 2 +- .../components/ThreadStatusIndicators.test.ts | 28 +++++++++++++++++++ .../src/components/ThreadStatusIndicators.tsx | 23 +++++++++++---- .../pullRequest/PullRequestDetailPanel.tsx | 10 +++++-- .../pullRequest/pullRequestPresentation.tsx | 6 ++-- .../client-runtime/src/state/pullRequests.ts | 1 + packages/contracts/src/git.ts | 2 ++ packages/contracts/src/pullRequest.ts | 2 ++ packages/contracts/src/sourceControl.ts | 2 ++ scripts/build-desktop-artifact.ts | 17 ++++++++++- 30 files changed, 152 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index dc5beea14d24..fcba4626be2d 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -23,7 +23,7 @@ import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; +import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; @@ -42,9 +42,15 @@ export type ThreadListVariant = "compact" | "sidebar"; export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; -function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") { +function pullRequestTintColor( + pr: Pick, + colorScheme: "light" | "dark", +) { const dark = colorScheme === "dark"; - switch (state) { + if (pr.state === "open" && pr.isDraft === true) { + return dark ? "#a1a1aa" : "#71717a"; + } + switch (pr.state) { case "open": return dark ? "#34d399" : "#059669"; case "merged": @@ -548,9 +554,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ; export interface ThreadPrPresentation { readonly number: number; readonly state: ThreadPr["state"]; + readonly isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; readonly url: string; @@ -27,13 +28,15 @@ export function presentThreadPr( provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): ThreadPrPresentation { const presentation = resolveChangeRequestPresentation(provider); + const isDraft = pr.state === "open" && pr.isDraft === true; return { number: pr.number, state: pr.state, + isDraft, updatedAt: pr.updatedAt ?? null, url: pr.url, label: String(pr.number), - accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`, - textClassName: PR_STATE_TEXT_CLASS[pr.state], + accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, + textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 1313f202fa13..ddda3b1acd96 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -33,4 +33,13 @@ describe("presentThreadPr", () => { accessibilityLabel: "#3774 merge request merged", }); }); + + it("uses gray for draft pull requests", () => { + expect( + presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), + ).toMatchObject({ + accessibilityLabel: "#3774 pull request draft", + textClassName: "text-adaptive-zinc-500-400", + }); + }); }); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 3e4be02a3c14..643e1e7a0d5b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -54,6 +54,7 @@ interface FakeGhScenario { baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; + isDraft?: boolean; isCrossRepository?: boolean; headRepositoryNameWithOwner?: string | null; headRepositoryOwnerLogin?: string | null; @@ -116,6 +117,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ ? "closed" : "merged" : undefined; + const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined; const isCrossRepository = typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; const headRepositoryNameWithOwner = @@ -138,6 +140,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ baseRefName, headRefName, ...(state ? { state } : {}), + ...(isDraft === true ? { isDraft: true } : {}), ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), @@ -508,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -552,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -699,7 +702,7 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { - it.effect("status includes PR metadata when branch already has an open PR", () => + it.effect("status includes draft PR metadata when branch already has a draft PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -719,6 +722,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseRefName: "main", headRefName: "feature/status-open-pr", + isDraft: true, }, ]), ], @@ -737,6 +741,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "feature/status-open-pr", state: "open", + isDraft: true, updatedAt: null, }); }), @@ -1686,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1752,7 +1757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cad328d16b08..2d8af0c9e8bb 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -170,6 +170,7 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; + isDraft?: boolean; updatedAt: Option.Option; } @@ -404,6 +405,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -558,6 +560,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + isDraft?: boolean; updatedAt: string | null; } { return { @@ -567,6 +570,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.isDraft === true ? { isDraft: true } : {}), updatedAt: Option.match(pr.updatedAt, { onNone: () => null, onSome: (updatedAt) => DateTime.formatIso(updatedAt), @@ -1518,11 +1522,7 @@ export const make = Effect.gen(function* () { ); if (firstPullRequest) { return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, + ...firstPullRequest, state: "open", updatedAt: Option.none(), } satisfies PullRequestInfo; diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 3dc41896037b..5d5c2062c08c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -468,6 +468,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly headBranch: string; readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1650,6 +1651,7 @@ export const make = Effect.gen(function* () { headBranch: summary.headRefName, baseBranch: summary.baseRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 1459d7cec921..22028ced5ddf 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -96,6 +96,8 @@ export interface ProviderChangeRequestSummary { readonly headBranch: string; readonly baseBranch: string; readonly state: PullRequestState; + /** Present when the host says an open pull request is still a draft. */ + readonly isDraft?: boolean; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 88a8ffe32df3..6a37ed935848 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1254,6 +1254,7 @@ export const make = Effect.gen(function* () { title: changeRequest.title, url: changeRequest.url, state: changeRequest.state, + ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, updatedAt: changeRequest.updatedAt, @@ -2278,6 +2279,7 @@ export const make = Effect.gen(function* () { title: detail.title, url: detail.url, state: detail.state, + ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, updatedAt: detail.updatedAt, diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 2f147452f9ec..8a840c524eba 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -71,6 +72,7 @@ function toChangeRequest(summary: { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 59fab76e5277..ffa3fb1301c9 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -17,6 +17,7 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index ea79dc87e7fc..3f08e92e2c1e 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -65,6 +65,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "OPEN", + isDraft: true, mergedAt: null, updatedAt: "2026-08-24T12:34:56Z", isCrossRepository: true, @@ -92,6 +93,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", @@ -105,7 +107,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index a9b65b30e706..49b2ea31a08c 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -205,6 +205,7 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -366,7 +367,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -398,7 +399,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 1381271e6bbc..7faa2fe351ef 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 738b10498d56..1a20b587256a 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -30,6 +30,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt === undefined ? Option.none() @@ -153,7 +154,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360247..ab8dfbb5f334 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -246,6 +246,7 @@ export interface GitLabMergeRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f7..2ec1f9b9a228 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -26,6 +26,7 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8c3c5c4de56b..8ac682399e1d 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -14,6 +14,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; } @@ -35,6 +36,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, status: Schema.String, + isDraft: Schema.optional(Schema.Boolean), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -168,6 +170,7 @@ function normalizeAzureDevOpsPullRequestRecord( baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), state: normalizeAzureDevOpsPullRequestState(raw.status), + ...(raw.isDraft === true ? { isDraft: true } : {}), updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts index 6d67477bca70..3b07334a8040 100644 --- a/apps/server/src/sourceControl/bitbucketPullRequests.ts +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -10,6 +10,7 @@ export interface NormalizedBitbucketPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -38,6 +39,7 @@ export const BitbucketPullRequestSchema = Schema.Struct({ id: PositiveInt, title: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), links: Schema.Struct({ html: Schema.Struct({ @@ -98,6 +100,7 @@ export function normalizeBitbucketPullRequestRecord( baseRefName: raw.destination.branch.name, headRefName: raw.source.branch.name, state: normalizeBitbucketPullRequestState(raw.state), + ...(raw.draft === true ? { isDraft: true } : {}), updatedAt: raw.updated_on ?? Option.none(), ...(isCrossRepository ? { isCrossRepository: true } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index ded3c0a90b08..9e4f282e1c8a 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -14,6 +14,7 @@ export interface NormalizedGitHubPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -27,6 +28,7 @@ const GitHubPullRequestSchema = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -93,6 +95,7 @@ function normalizeGitHubPullRequestRecord( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), + ...(raw.isDraft === true ? { isDraft: true } : {}), updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index afd1eceaeedc..3b032e245bbc 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -14,6 +14,7 @@ export interface NormalizedGitLabMergeRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -41,6 +42,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ source_branch: TrimmedNonEmptyString, target_branch: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -108,6 +111,7 @@ function normalizeGitLabMergeRequestRecord( baseRefName: raw.target_branch, headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), + ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf14c9614734..8b636dc6ed46 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -954,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 31b94db36d4c..439756b2466e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -10,6 +10,7 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsEqual, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -559,6 +560,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }); expect(displayed?.state).toBe("merged"); }); + + it("refreshes a cached snapshot when a pull request becomes ready", () => { + const readyPr = { ...mergedPr, state: "open" as const }; + const draftPr = { ...readyPr, isDraft: true }; + + expect( + threadChangeRequestSnapshotsEqual( + snapshotFor(featureBranch, draftPr), + snapshotFor(featureBranch, readyPr), + ), + ).toBe(false); + }); }); describe("threadChangeRequestSnapshotsAtom", () => { @@ -600,6 +613,17 @@ describe("prStatusIndicator", () => { "text-red-600", ); }); + + it("uses gray and draft wording for draft pull requests", () => { + const draftPr = status().pr; + if (!draftPr) throw new Error("Expected pull request fixture"); + + expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({ + label: "PR draft", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltipLead: "PR #42 - Draft", + }); + }); }); describe("settledPrHoverColorClass", () => { @@ -610,4 +634,8 @@ describe("settledPrHoverColorClass", () => { ] as const)("restores the %s pull request color on row hover", (state, colorClass) => { expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`); }); + + it("keeps draft pull requests gray on row hover", () => { + expect(settledPrHoverColorClass("open", true)).toContain("group-hover/v2-row:text-zinc-500"); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 1ad0c3139fd8..4c830f4a4b8b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -84,9 +84,15 @@ export function useLinkedThreadPullRequest( ); } -export function settledPrHoverColorClass(state: NonNullable["state"]): string { +export function settledPrHoverColorClass( + state: NonNullable["state"], + isDraft = false, +): string { switch (state) { case "open": + if (isDraft) { + return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80"; + } return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; case "merged": return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; @@ -99,12 +105,13 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { - function formatPrState(state: NonNullable["state"]): string { - return state.charAt(0).toUpperCase() + state.slice(1); + function formatPrState(pr: NonNullable): string { + if (pr.state === "open" && pr.isDraft === true) return "Draft"; + return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { - return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`; } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); @@ -113,9 +120,12 @@ export function prStatusIndicator( const tooltip = `${tooltipLead}: ${pr.title}`; if (pr.state === "open") { + const isDraft = pr.isDraft === true; return { - label: `${presentation.shortName} open`, - colorClass: "text-emerald-600 dark:text-emerald-300/90", + label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`, + colorClass: isDraft + ? "text-zinc-500 dark:text-zinc-400/80" + : "text-emerald-600 dark:text-emerald-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, @@ -230,6 +240,7 @@ export function threadChangeRequestSnapshotsEqual( left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && + left.pr.isDraft === right.pr.isDraft && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 9bfc49848803..70533f06488f 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -625,7 +625,13 @@ export function PullRequestDetailPanel({ () => resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail ? resolvedCoreDetail - : { ...resolvedCoreDetail, ...sharedSummary }, + : { + ...resolvedCoreDetail, + ...sharedSummary, + // A summary may come from an older server that does not report draft state. Keep the + // detail's required value instead of making the complete detail shape partial. + isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, + }, [resolvedCoreDetail, sharedSummary], ); const activity = activityQuery.data; @@ -1297,7 +1303,7 @@ export function PullRequestDetailPanel({ !conflicting && allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. The conflict action is separate from this state: an open pull request remains green. + // it. Conflicts take the action slot while they need a person, but do not change the PR state. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index d9fd35e0c8b4..f4eb7958a1d3 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -33,9 +33,9 @@ interface StatePresentation { } /** - * How a pull request's state reads on this page. Open, closed and merged use the same ink as - * the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two - * different things in two places; draft and conflicts are states that badge never shows. + * How a pull request's state reads on this page. Open, closed, merged, and draft use the same + * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two + * different things in two places. * * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface * once it is real work. diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 7ebaba0fed03..6144d8507a9e 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -69,6 +69,7 @@ export function pullRequestDetailToVcsStatus( baseRef: detail.baseBranch, headRef: detail.headBranch, state: detail.state, + ...(detail.isDraft === true ? { isDraft: true } : {}), updatedAt: detail.updatedAt, }; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d39be34bf6e9..4b63b877923f 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -198,6 +198,8 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + /** Optional for compatibility with older servers and providers. */ + isDraft: Schema.optional(Schema.Boolean), /** * Last provider-side activity (ISO). For a merged/closed change request * this bounds when it reached that state, so clients can tell a PR that diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 04c9d53cf777..f766578bb1a3 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -634,6 +634,8 @@ export const PullRequestSummary = Schema.Struct({ title: TrimmedNonEmptyString, url: TrimmedNonEmptyString, state: PullRequestState, + /** Present when the host says the open pull request is still a draft. */ + isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..be3d70aefadd 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: ChangeRequestState, + /** Present when the provider can tell that an open change request is still a draft. */ + isDraft: Schema.optional(Schema.Boolean), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f32038763188..3e40d94dc69a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2863,7 +2863,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function readonly arch: typeof BuildArch.Type; }) { const fs = yield* FileSystem.FileSystem; - yield* Effect.tryPromise({ + const archiveStream = yield* Effect.tryPromise({ try: () => createPackageWithOptions(input.sourceDir, input.asarPath, { dot: true, @@ -2872,6 +2872,21 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function }), catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), }); + yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const stream = archiveStream as NodeJS.WritableStream & { + readonly writableFinished?: boolean; + }; + if (stream.writableFinished === true) { + resolve(); + return; + } + stream.once("finish", resolve); + stream.once("error", reject); + }), + catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), + }); const unpackedDirPath = `${input.asarPath}.unpacked`; if (!(yield* fs.exists(unpackedDirPath))) { return yield* new WindowsServerSidecarPackError({ From f1e90e388b86fe4b007a55c0e685a1fa878115e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 23:56:23 -0700 Subject: [PATCH 33/36] refactor(web): move usage provider controls to settings (#9599) --- .../settings/components/SettingsSection.tsx | 6 +- .../src/features/usage/UsageLimitsSection.tsx | 2 +- .../AddUsageLimitSourceDialog.tsx | 2 +- .../settings/ProviderSettingsPanel.tsx | 12 +- .../settings/UsageProviderSettings.tsx | 130 ++++++++++ .../settings/settingsSearch.test.ts | 10 + .../src/components/settings/settingsSearch.ts | 9 + apps/web/src/components/usage/UsageLimits.tsx | 233 +----------------- docs/user/usage.md | 6 +- 9 files changed, 176 insertions(+), 234 deletions(-) rename apps/web/src/components/{usage => settings}/AddUsageLimitSourceDialog.tsx (98%) create mode 100644 apps/web/src/components/settings/UsageProviderSettings.tsx diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx index 9c87561a8ee9..6bea4ffe24a4 100644 --- a/apps/mobile/src/features/settings/components/SettingsSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -4,14 +4,16 @@ import { View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; export function SettingsSection(props: { - readonly title: string; + readonly title?: string; readonly children: ReactNode; /** Force the grouped card background; Android otherwise lists options flat. */ readonly card?: boolean; }) { return ( - {props.title} + {props.title ? ( + {props.title} + ) : null} {sources.map((source) => ( - + {source.error ? ( {source.error} ) : source.accounts.length === 0 ? ( diff --git a/apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx similarity index 98% rename from apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx rename to apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index 9b1329d6bd7e..80727a8e970f 100644 --- a/apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -35,7 +35,7 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId { } /** - * Adds a CLIProxyAPI hub as a usage-limit source on one environment. The + * Adds a CLIProxyAPI hub from provider settings on one environment. The * management key is sent once and kept in that server's secret store; * settings only ever carry a redaction marker for it afterwards. */ diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 88ea31a8ffe2..b25c127570c6 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -68,6 +68,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { providerSettingsTabClassName } from "./providerSettingsTabs"; @@ -241,7 +242,8 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { )?.environmentId; useEffect(() => { if ( - searchTargetId === searchableSetting("provider-health-check-interval").id && + (searchTargetId === searchableSetting("provider-health-check-interval").id || + searchTargetId === searchableSetting("usage-providers").id) && !selectedEnvironmentCanRenderSettings && searchableEnvironmentId !== undefined ) { @@ -997,6 +999,14 @@ export function EnvironmentProviderSettings({
+ + + setAdding(true)}> + + Add hub + + ) : null + } + > + {entries.length === 0 ? ( + + ) : ( + entries.map(([id, source]) => { + const label = source.label?.trim() || source.url; + return ( + + CLI Proxy{source.enabled ? "" : " · Disabled"} + {label !== source.url ? ` · ${source.url}` : ""} + + } + control={ + !readOnly ? ( + updateSettings({ usageLimitSources: { [id]: null } })} + /> + ) : null + } + /> + ); + }) + )} + + {adding && !readOnly ? ( + + ) : null} + + ); +} + +/** Removing a hub deletes its stored management key, so it requires confirmation. */ +function RemoveUsageProviderButton({ + label, + onConfirm, +}: { + readonly label: string; + readonly onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Remove {label}? + + The hub's management key is deleted from this server. Its accounts leave the Limits + view; the hub itself is untouched. Add it again with the URL and key to bring them + back. + + + + }>Cancel + + + + + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 6176f51121e4..bf206584f1e9 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -101,6 +101,16 @@ describe("searchSettings", () => { ]); }); + it.each(["usage providers", "CLIProxyAPI", "CLI proxy hub", "management key"])( + "finds usage-provider management by %s", + (query) => { + expect(searchSettings(query)[0]).toMatchObject({ + id: "usage-providers", + to: "/settings/providers", + }); + }, + ); + it("returns no results for an empty query", () => { expect(searchSettings(" ", ITEMS)).toEqual([]); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ea1a221036d8..22e2204d8a27 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -324,6 +324,15 @@ export const SETTINGS_SEARCH_ITEMS = [ "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, + { + id: "usage-providers", + title: "Usage providers", + to: "/settings/providers", + searchTerms: [ + "usage sources CLIProxyAPI CLI proxy hub quota subscription limits management key add remove", + ], + providerSettingsOnly: true, + }, { id: "provider-health-check-interval", title: "Health check interval", diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 8e626d0a5267..b1a57582bc9f 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -1,5 +1,5 @@ import { - EnvironmentId, + type EnvironmentId, type ProviderConsumeResetCreditOutcome, ProviderInstanceId, ServerProvider, @@ -21,28 +21,16 @@ import { paceOf, providerLimitsLabel, } from "@t3tools/shared/usageLimits"; -import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; +import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useState } from "react"; -import { isElectron } from "../../env"; -import { usePrimarySessionState } from "../../environments/primary"; -import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; -import { - type EnvironmentPresentation, - useEnvironments, - usePrimaryEnvironmentId, -} from "../../state/environments"; -import { useEnvironmentSessionState } from "../../state/session"; +import { usePrimarySettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { getDriverOption } from "../settings/providerDriverMeta"; -import { - resolvePrimaryOperateAccess, - resolveRemoteOperateAccess, -} from "../settings/ProviderSettingsPanel.logic"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { AlertDialog, @@ -54,9 +42,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { PROVIDER_PRESENTATION } from "./usageProviders"; const PACE: Record = { @@ -407,79 +393,17 @@ function SourceAccountLimits({ ); } -/** - * Accounts a configured source (a CLIProxyAPI hub) pools, grouped under the - * source's name. Unlike provider rows these are read-only: nothing on this - * environment can run a turn against them. - */ const SOURCE_KIND_LABEL: Record = { cliproxy: "CLI Proxy", }; type LimitsSource = ReturnType[number]; -/** - * Removing a hub also deletes its management key from the server, so it - * asks first and says so. A bare icon that acted on click was too easy to - * hit while reaching for the row beside it. - */ -function RemoveSourceButton({ - source, - onConfirm, -}: { - readonly source: UsageLimitSourceSnapshot; - readonly onConfirm: () => void; -}) { - const [open, setOpen] = useState(false); - return ( - <> - - - - - Remove {source.label}? - - The hub's management key is deleted from this server. Its accounts leave the Limits - view; the hub itself is untouched. Add it again with the URL and key to bring them - back. - - - - }>Cancel - - - - - - ); -} - -function SourceLimits({ - source, - now, - onRemove, -}: { - readonly source: LimitsSource; - readonly now: number; - readonly onRemove: (() => void) | null; -}) { +/** Read-only accounts pooled by a configured usage source. */ +function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { const kind = SOURCE_KIND_LABEL[source.kind]; return (
-
-

{source.label}

- {onRemove ? : null} -
{source.error ? ( {source.error} ) : source.accounts.length === 0 ? ( @@ -497,49 +421,6 @@ function SourceLimits({ ); } -/** - * Whether this client's credential may write settings on an environment, - * resolved the way Settings → Providers does: the desktop app owns its - * primary outright; a browser session checks the scopes it was granted; - * a remote environment reports scopes over its own session endpoint. - */ -function useCanOperateEnvironment(environment: EnvironmentPresentation | null): boolean { - const isPrimary = environment?.entry.target._tag === "PrimaryConnectionTarget"; - const primarySession = usePrimarySessionState(); - const remoteSession = useEnvironmentSessionState( - environment?.environmentId ?? EnvironmentId.make("none"), - ); - if (environment === null || environment.connection.phase !== "connected") return false; - if (isPrimary && isElectron) return true; - const access = isPrimary - ? resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: false, - session: primarySession.data, - isPending: primarySession.isPending, - hasError: primarySession.error !== null, - }) - : resolveRemoteOperateAccess({ - session: remoteSession.data, - isPending: remoteSession.isPending, - hasError: remoteSession.hasError, - }); - return access === "granted"; -} - -/** One source with a remove control bound to the environment it lives in. */ -function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const updateSettings = useUpdateEnvironmentSettings(source.environmentId); - const { environments } = useEnvironments(); - const environment = - environments.find((entry) => entry.environmentId === source.environmentId) ?? null; - const canOperate = useCanOperateEnvironment(environment); - // The patch names only this entry, so two edits in flight cannot clobber - // each other's map. - const remove = () => updateSettings({ usageLimitSources: { [source.id]: null } }); - return ; -} - /** * Subscription quota windows from every connected environment's providers. * Countdowns anchor to render time rather than ticking: a live clock would @@ -549,108 +430,18 @@ export function UsageLimitsSection() { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const groups = collectLimitsGroups(presentations); const sources = collectLimitSources(presentations); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { environments } = useEnvironments(); - const [adding, setAdding] = useState(false); // Anchored once per mount on purpose: countdowns must not tick (see below). const [now] = useState(() => Date.now()); - // Sources live in one environment's settings. Writing them needs only the - // operate scope, like any provider control, so a T3 Connect client can add - // a hub to whichever environment it is connected to: the primary when there - // is one, else the first connected environment, with a picker for more. - const connected = environments.filter( - (environment) => environment.connection.phase === "connected", - ); - const [pickedEnvironmentId, setPickedEnvironmentId] = useState(null); - const targetEnvironment = - (pickedEnvironmentId !== null - ? connected.find((environment) => environment.environmentId === pickedEnvironmentId) - : undefined) ?? - (primaryEnvironmentId !== null - ? connected.find((environment) => environment.environmentId === primaryEnvironmentId) - : undefined) ?? - connected[0] ?? - null; - const canOperateTarget = useCanOperateEnvironment(targetEnvironment); - return (
- {/* Sources first: they are the thing a user configures here, so the - control to add one sits at the top rather than after every row. */} -
-
-

Usage sources

-

- Quota from a CLIProxyAPI hub shows beside the providers signed in on this machine. -

-
- {/* The picker stays whenever several environments are connected, so - a read-only default target does not hide the way to an operable - one; only the button follows the picked target's access. */} - {targetEnvironment ? ( -
- {connected.length > 1 ? ( - - ) : null} - {canOperateTarget ? ( - - ) : ( - - - } - > - - - - - - Your session cannot change settings on {targetEnvironment.label}. - - - )} -
- ) : null} -
{groups.length === 0 && sources.length === 0 ? (

No provider on a connected environment reports subscription limits.

) : null} {sources.map((source) => ( - + ))} {groups.map((group) => (
@@ -669,18 +460,6 @@ export function UsageLimitsSection() { ))}
))} - {targetEnvironment && canOperateTarget ? ( - // Keyed on the target: if it disconnects or the primary changes while - // the dialog is open, a fresh dialog mounts empty rather than carrying - // a typed key over to a different environment. - - ) : null}
); } diff --git a/docs/user/usage.md b/docs/user/usage.md index 2fcb5c54210b..fceedc3c2560 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,8 +18,10 @@ provider health-check interval and update live while a turn runs. API-key accoun subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client. -If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts -the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for +If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers** +and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on +the Limits view. Remove hubs from the same settings section. Each limits row shows its provider +and instance name, or a small _CLI Proxy_ label for hub accounts. When a connected provider reports limits for the same provider and email, its row replaces the hub copy, keeping details such as banked reset credits. The hub copy remains visible if the connected provider cannot report limits. Enter the hub's URL and management key; the key From 00f8b7c28056188e3c5630160806a0afe51c9010 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 00:24:44 -0700 Subject: [PATCH 34/36] fix: show idle subagent batches without completion marks (#9616) --- apps/mobile/src/lib/threadActivity.test.ts | 26 ++++--- apps/mobile/src/lib/threadActivity.ts | 6 +- .../Layers/AntigravityAdapter.test.ts | 5 +- .../src/provider/Layers/AntigravityAdapter.ts | 2 +- apps/web/src/components/AgentsPanel.tsx | 6 +- .../src/components/chat/MessagesTimeline.tsx | 47 ++++-------- .../components/chat/agentSpawnSummary.test.ts | 73 +++++++++++++++++++ .../src/components/chat/agentSpawnSummary.ts | 64 ++++++++++++++++ docs/user/providers-antigravity.md | 2 + .../src/state/subagentRuntime.test.ts | 15 +++- .../src/state/subagentRuntime.ts | 6 +- 11 files changed, 200 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/components/chat/agentSpawnSummary.test.ts create mode 100644 apps/web/src/components/chat/agentSpawnSummary.ts diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 868aaa8db84e..cca0bf6890f5 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2256,9 +2256,13 @@ describe("quiet timeline: nested agents", () => { }, ); - it.each(["cancelled", "failed", "interrupted"] as const)( - "replaces Antigravity progress with %s without a timeline bypass flag", + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( + "replaces Antigravity batch progress with %s", (status) => { + const detail = + status === "idle" + ? "Turn ended. Individual agent status is unavailable." + : "Antigravity process stopped."; const thread = makeThread({ id: ThreadId.make("antigravity-agents"), projectId: ProjectId.make("project-1"), @@ -2268,14 +2272,14 @@ describe("quiet timeline: nested agents", () => { makeActivity({ id: EventId.make(`progress-${index}`), kind: "task.progress", - summary: "Antigravity subagent", + summary: "Antigravity subagent batch", createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, payload: { taskId, - taskType: "subagent", + taskType: "subagent_batch", agentKind: "agent", - title: "Antigravity subagent", - detail: "Antigravity subagent", + title: "Antigravity subagent batch", + detail: "Antigravity subagent batch", status: "running", }, }), @@ -2287,11 +2291,11 @@ describe("quiet timeline: nested agents", () => { createdAt: "2026-04-01T00:00:03.000Z", payload: { taskId: "trajectory:4", - taskType: "subagent", + taskType: "subagent_batch", agentKind: "agent", - title: "Antigravity subagent", + title: "Antigravity subagent batch", status, - error: "Antigravity process stopped.", + ...(status === "idle" ? { detail, timelineBypass: true } : { error: detail }), }, }), ], @@ -2302,8 +2306,8 @@ describe("quiet timeline: nested agents", () => { expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ lifecycleStatus: status === "failed" ? "failed" : "stopped", - detail: "Antigravity process stopped.", - workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent" }, + detail, + workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" }, }); expect(rows[1]).toMatchObject({ lifecycleStatus: "inProgress", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 564ab5e58fff..6233bdf355d3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -449,6 +449,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const taskDetailAsLabel = isTaskActivity && !taskSummary && + !title && typeof payload?.detail === "string" && payload.detail.length > 0 ? payload.detail @@ -499,7 +500,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo toolName: data?.toolName, data, }); - if (detail && !repeatsCommand) entry.detail = detail; + if (detail && detail !== title && !repeatsCommand) entry.detail = detail; } if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) { entry.detail = payload.error; @@ -1110,6 +1111,9 @@ function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { const status = payload?.status; + // The parent turn ended, so batch tracking is inactive. The detail explains + // that child status is unavailable; do not retain the earlier running marker. + if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped"; if (status === "pending" || status === "running" || status === "waiting") return "inProgress"; if (status === "cancelled" || status === "interrupted") return "stopped"; if ( diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 92a5d9010814..dd8784bccacc 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -796,6 +796,7 @@ it.layer(layer)("AntigravityAdapter", (it) => { expect(launched.payload).toMatchObject({ taskId: started.toolCall.toolCallId, title: "Antigravity subagent batch", + taskType: "subagent_batch", description: "Launch subagents", status: "running", }); @@ -857,7 +858,7 @@ it.layer(layer)("AntigravityAdapter", (it) => { const completed = yield* h.waitForEvent((event) => event.type === "task.completed"); expect(completed.payload).toEqual({ taskId: "replayed:4", - taskType: "subagent", + taskType: "subagent_batch", toolUseId: "replayed:4", title: "Antigravity subagent batch", status: "failed", @@ -1085,7 +1086,7 @@ it.layer(layer)("AntigravityAdapter", (it) => { expect(settled.payload).toMatchObject({ taskId: "trajectory:4", title: "Antigravity subagent batch", - taskType: "subagent", + taskType: "subagent_batch", status: stop === "disconnect" ? "failed" diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index b6c3c785986f..8e135b612ee4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -176,7 +176,7 @@ interface OpenSubagent { function subagentLinkage(toolCallId: string) { return { taskId: RuntimeTaskId.make(toolCallId), - taskType: "subagent", + taskType: "subagent_batch", toolUseId: toolCallId, title: "Antigravity subagent batch", }; diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 62044a8659d7..459506efc78c 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -140,6 +140,8 @@ function agentActivityText(agent: RuntimeSubagent): string | null { /** Flat, non-interactive agent status line. No unfold. */ function AgentRow({ agent }: { agent: RuntimeSubagent }) { const visuals = STATUS_VISUALS[agent.status]; + const statusLabel = + agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label; const activity = agentActivityText(agent); const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); const role = @@ -180,12 +182,12 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground", )} > - {activity ?? visuals.label} + {activity ?? statusLabel} {metadata.join(" · ")} - {visuals.label} + {statusLabel}
); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index cdca3328eb69..45c91d7f3617 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -167,6 +167,7 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { @@ -2993,22 +2994,12 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), ); - const running = agents.filter( - (agent) => agent.status === "running" || agent.status === "pending", - ).length; - const waiting = agents.filter((agent) => agent.status === "waiting").length; - const failed = agents.filter((agent) => agent.status === "failed").length; - // The coordinator's own status is authoritative for workflows: dynamic - // spawns mean the member list can be momentarily all-settled while the - // run is still mid-flight (the "completed" lie from live testing). A - // workflow is live until the coordinator itself reaches a terminal state. - const coordinatorStatus = workflowGroup?.workflow.status; - const coordinatorSettled = - coordinatorStatus === "completed" || - coordinatorStatus === "failed" || - coordinatorStatus === "cancelled" || - coordinatorStatus === "interrupted"; - const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0; + const summary = deriveAgentSpawnSummary({ + agents, + agentCount, + coordinatorStatus: workflowGroup?.workflow.status, + }); + const { live, lead } = summary; // Same rule as the panel footer: providers may aggregate member usage into // the coordinator, so count the coordinator only when no members exist. const totalTokens = agents.reduce( @@ -3020,22 +3011,14 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time const workflowName = workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; - // One steady in-flight presentation (monitoring-pill rule): waiting and - // stalled agents read as working; only settled states differentiate. - const working = running + waiting; - const dotClass = live ? "bg-info" : failed > 0 ? "bg-destructive" : "bg-success"; - const lead = live - ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}` - : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`; - const status = live - ? livePhase - ? `${livePhase.title} · ${livePhase.activeCount} working` - : working > 0 - ? `${working} working` - : "working" - : failed > 0 - ? `${failed} failed` - : "✓ completed"; + const dotClass = { + working: "bg-info", + failed: "bg-destructive", + completed: "bg-success", + inactive: "bg-muted-foreground/50", + }[summary.tone]; + const status = + live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; return (
- {showFailedIndicator && hasSpecialToolIcon ? ( - + {showFailedIndicator && + !showDestructiveRowStyle && + !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( + ) : null}