"));
+
+ const unchanged = render(scenarios(), scenarios());
+ assert.match(unchanged, /✅ Thread transfer remains within every enforced ceiling/);
+ assert.match(open(unchanged), /All 4 scenario measurements match `main`/);
+ assert.doesNotMatch(open(unchanged), /long turn finished, cold open/);
+
+ const grown = render(scenarios({ observed: 400_000 }), scenarios());
+ assert.match(open(grown), /1 of 4 scenario measurements changed or failed/);
+ assert.match(
+ open(grown),
+ /\| Codex \| `long turn finished, cold open` \| `HTTP decoded` \| 380\.9 KiB \| 390\.6 KiB \| \+9\.8 KiB \(\+2\.6%\) \| 410\.2 KiB \| ✅ \|/,
+ );
+
+ const over = render(scenarios({ observed: 430_000, ceiling: 425_000 }), scenarios());
+ assert.match(over, /❌ One or more thread transfer ceilings were exceeded/);
+ assert.match(
+ over,
+ /Codex `long turn finished, cold open` `HTTP decoded`: 410\.2 KiB → 415\.0 KiB/,
+ );
+
+ // A row `main` does not have is not reported as matching it.
+ const added = render(scenarios({ phase: "GH-26" }), scenarios());
+ assert.match(open(added), /3 scenario measurements match `main`\. 1 have no `main` baseline\./);
+ assert.match(added, /\| Codex \| `GH-26` \|/);
+
+ // A main run from before the scenario file has nothing to compare; a PR run from before it has no section.
+ assert.match(open(render(scenarios(), undefined)), /No `main` baseline exists yet/);
+ assert.doesNotMatch(render(undefined, scenarios()), /Other scenarios/);
});
test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => {
diff --git a/README.md b/README.md
index 4abbd5bd932cf..24dc0f8f7f666 100644
--- a/README.md
+++ b/README.md
@@ -1,153 +1,108 @@
-# T3 Code
+
+
+
+
+
-T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
+T3 Code
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and fx. If they're set up on your computer, T3 Code can control them.
+The open-source control plane for coding agents.
-## "Wait, what are you selling me?"
-
-Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar.
+
+ Download ·
+ iOS ·
+ Android ·
+ Web ·
+ Docs ·
+ Discord
+
-We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want.
+
+
+
+
+
-## Installation
+T3 Code runs the coding agents on your machine and gives them one fast interface. Start a thread at your desk, then approve the next step from your phone.
-> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, and fx. Install and authenticate at least one provider before use:
->
-> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
-> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
-> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
-> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
-> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
-> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required.
-> - fx: install [fx](https://fx.sh) and run `fx login` on the machine running the T3 Code server ([setup guide](./docs/user/providers-fx.md))
+It works with Claude Code, Codex, Cursor, OpenCode, Grok Build, Antigravity, Kiro, Pi, Muse Code, fx, and any [ACP](./docs/user/providers-acp.md) agent. If an agent is set up on your computer, T3 Code can control it.
-### Command line
+## What you get
-```bash
-curl -fsSL https://t3.codes/install.sh | sh
-```
+- **Your subscriptions.** T3 Code does not resell tokens. It drives the agents you are already signed in to.
+- **Every agent in one place.** Change models in the middle of a thread. Let Claude hand a task to Codex.
+- **Desktop, web, and phone.** One server, and every client shows the same threads. There are apps for iOS and Android.
+- **Remote ready.** Run agents on any machine. Connect over your local network, Tailscale, or [T3 Connect](./docs/user/remote-access.md).
+- **Git built in.** Each thread can work in its own worktree. Review the diff, then commit, push, and open a pull request with one button.
+- **Fast.** Performance is a feature here. We measure it, and we treat a dropped frame as a bug.
+- **Open.** MIT licensed. If you don't like something, fork it.
-On Windows, in PowerShell:
-
-```powershell
-irm https://t3.codes/install.ps1 | iex
-```
-
-Then run `t3` to start the server and open the local web app. `t3 service install` keeps it running in the background, `t3 update` moves to a newer release, and `t3 --help` has the full reference.
-
-To try it once without installing, run `npx t3@latest`. It offers to install the `t3` command for you.
+## Install
### Desktop app
-Install the latest version of the desktop app from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or from your favorite package registry:
+[Download T3 Code](https://t3.codes/download) for macOS, Windows, or Linux. You can also use a package manager:
-#### Windows (`winget`)
+| Platform | Command |
+| -------------- | -------------------------------------------------------------------- |
+| macOS | `brew install --cask t3-code` |
+| Windows | `winget install T3Tools.T3Code` or `scoop install extras/t3code` |
+| Arch Linux | `yay -S t3code-bin` |
+| Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` |
+| Debian, Ubuntu | `.deb` from [Releases](https://github.com/pingdotgg/t3code/releases) |
-```bash
-winget install T3Tools.T3Code
-```
-
-#### Windows (`scoop`)
-
-```bash
-scoop install extras/t3code
-```
-
-#### macOS (Homebrew)
-
-```bash
-brew install --cask t3-code
-```
-
-#### Debian, Ubuntu (`.deb`)
+### Command line
-Download the `.deb` from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), then:
+This installs the `t3` command. Run `t3` to start the server and open the web app.
```bash
-sudo apt install ./T3-Code-*.deb
+curl -fsSL https://t3.codes/install.sh | sh
```
-#### Arch Linux (AUR)
-
-Stable:
+On Windows, in PowerShell:
-```bash
-yay -S t3code-bin
+```powershell
+irm https://t3.codes/install.ps1 | iex
```
-Nightly:
+To try it once without an install, run `npx t3@latest`.
-```bash
-yay -S t3code-nightly-bin
-```
+### Phone
-The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur).
+Get the app from the [App Store](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824) or [Google Play](https://play.google.com/store/apps/details?id=com.t3tools.t3code). Then follow [remote access](./docs/user/remote-access.md) to connect it to your computer.
-#### Nix (nixpkgs)
+### Sign in to an agent
-```bash
-nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code
-```
+T3 Code needs one agent that is installed and signed in. For example, install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`, or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`.
-The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code)
-and includes the desktop app (`t3code-desktop`) and the `t3` command.
-See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates.
+[Provider setup](./docs/user/install.md#providers) has the steps for each agent. [Install T3 Code](./docs/user/install.md) covers nightly builds, AppImage, WSL, and NixOS.
-## Some notes
+## "Wait, what are you selling me?"
-We are very very early in this project. Expect bugs.
+Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar.
-We are (mostly) not accepting contributions yet. Small fixes may be considered. Big features will not be.
+We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the tool that you want.
## Documentation
-Full docs live in [docs/](./docs). There's no docs site yet.
-
-- [FAQ](./docs/user/faq.md)
- [Install and first run](./docs/user/install.md)
-- [Permission modes](./docs/user/permission-modes.md)
-- [Keyboard shortcuts](./docs/user/keybindings.md)
-- [Project settings](./docs/user/project-settings.md)
-- [Appearance preferences](./docs/user/appearance.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
-- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md)
-- [Keeping app and server in sync](./docs/user/updating.md)
-- [Source control integrations](./docs/user/source-control.md)
-- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
-- [Run T3 Code as a background service](./docs/user/background-service.md)
-
-Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md).
-
-## If you REALLY want to contribute still.... read this first
-
-### Install `vp`
-
-T3 Code uses Vite+ so you'll need to install the global `vp` command-line tool.
-
-#### macOS / Linux
-
-```bash
-curl -fsSL https://vite.plus | bash
-```
-
-#### Windows
-
-```bash
-irm https://vite.plus/ps1 | iex
-```
+- [Permission modes](./docs/user/permission-modes.md)
+- [Source control](./docs/user/source-control.md)
+- [Connect outside agents over MCP](./docs/user/outside-agents.md)
+- [FAQ](./docs/user/faq.md)
-Checkout their getting started guide for more information: https://viteplus.dev/guide/
+[All docs](./docs/README.md) has the full list.
-### Install dependencies
+## Contributing
-```bash
-vp i
-```
+We have limited review capacity. Small, focused fixes are the most likely to be merged. Features need an approved discussion first.
-Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR.
+- Read [CONTRIBUTING.md](./CONTRIBUTING.md) before you report a bug or open a pull request.
+- Put feature requests in [Ideas](https://github.com/pingdotgg/t3code/discussions/categories/ideas).
+- Build from source with the [development runbook](./docs/operations/development.md#first-checkout). The [architecture overview](./docs/internals/overview.md) explains how it works.
+- Get help in [Discord](https://discord.gg/jn4EGJjrvv).
-Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas).
+## License
-Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
+[MIT](./LICENSE)
diff --git a/apps/desktop/src/app/DesktopExitChildren.test.ts b/apps/desktop/src/app/DesktopExitChildren.test.ts
new file mode 100644
index 0000000000000..490c6740f0e00
--- /dev/null
+++ b/apps/desktop/src/app/DesktopExitChildren.test.ts
@@ -0,0 +1,42 @@
+// @effect-diagnostics nodeBuiltinImport:off - Spawns a real process tree for /proc.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import { assert, describe, it } from "@effect/vitest";
+
+import { stopChildProcessesBeforeExit } from "./DesktopExitChildren.ts";
+
+const isRunning = (pid: number) => {
+ try {
+ const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8");
+ return !"ZX".includes(stat.charAt(stat.lastIndexOf(")") + 2));
+ } catch {
+ return false;
+ }
+};
+
+describe.runIf(NodeFS.existsSync("/proc/self/task"))("stopChildProcessesBeforeExit", () => {
+ it("terminates every child of the given process before returning", async () => {
+ const parent = NodeChildProcess.spawn(
+ "sh",
+ ["-c", "sleep 60 & echo $!; sleep 60 & echo $!; wait"],
+ { stdio: ["ignore", "pipe", "ignore"] },
+ );
+ try {
+ const childPids = await new Promise>((resolve) => {
+ let output = "";
+ parent.stdout.on("data", (chunk: Buffer) => {
+ output += chunk.toString();
+ const pids = output.split("\n").filter(Boolean).map(Number);
+ if (pids.length === 2) resolve(pids);
+ });
+ });
+ assert.isTrue(childPids.every(isRunning));
+
+ assert.deepStrictEqual(stopChildProcessesBeforeExit(parent.pid!), []);
+
+ assert.isFalse(childPids.some(isRunning));
+ } finally {
+ parent.kill("SIGKILL");
+ }
+ });
+});
diff --git a/apps/desktop/src/app/DesktopExitChildren.ts b/apps/desktop/src/app/DesktopExitChildren.ts
new file mode 100644
index 0000000000000..7cc64c43833e7
--- /dev/null
+++ b/apps/desktop/src/app/DesktopExitChildren.ts
@@ -0,0 +1,70 @@
+// @effect-diagnostics nodeBuiltinImport:off - Runs synchronously in the process "exit" event, where nothing async completes.
+import * as NodeFS from "node:fs";
+
+// Matches the backend's quit budget: SIGTERM, 5 s grace, SIGKILL, 1 s wait.
+const TERMINATE_GRACE_MS = 5_000;
+const KILL_WAIT_MS = 1_000;
+
+const readProc = (path: string): string | undefined => {
+ try {
+ return NodeFS.readFileSync(path, "utf8");
+ } catch {
+ return undefined;
+ }
+};
+
+// Chromium launches helpers from its own threads, so each thread's children file is read.
+const childPids = (pid: number): ReadonlyArray => {
+ let tasks: ReadonlyArray;
+ try {
+ tasks = NodeFS.readdirSync(`/proc/${pid}/task`);
+ } catch {
+ return [];
+ }
+ return tasks.flatMap((task) =>
+ (readProc(`/proc/${pid}/task/${task}/children`) ?? "")
+ .split(" ")
+ .filter((value) => value.trim() !== "")
+ .map(Number),
+ );
+};
+
+// A zombie has already released its memory mappings.
+const isRunning = (pid: number): boolean => {
+ const stat = readProc(`/proc/${pid}/stat`);
+ if (stat === undefined) return false;
+ const state = stat.charAt(stat.lastIndexOf(")") + 2);
+ return state !== "Z" && state !== "X";
+};
+
+const signal = (pids: ReadonlyArray, name: NodeJS.Signals) => {
+ for (const pid of pids) {
+ try {
+ process.kill(pid, name);
+ } catch {}
+ }
+};
+
+const waitUntilStopped = (pids: ReadonlyArray, maxMs: number): ReadonlyArray => {
+ const tick = new Int32Array(new SharedArrayBuffer(4));
+ let running = pids.filter(isRunning);
+ for (let waited = 0; waited < maxMs && running.length > 0; waited += 10) {
+ Atomics.wait(tick, 0, 0, 10);
+ running = running.filter(isRunning);
+ }
+ return running;
+};
+
+/**
+ * Stops the main process's children while it is still alive. On an AppImage the image unmounts
+ * as soon as main exits, so children left running then die with SIGBUS. Linux only (/proc).
+ * Returns the pids that needed SIGKILL.
+ */
+export const stopChildProcessesBeforeExit = (pid: number): ReadonlyArray => {
+ const children = childPids(pid);
+ signal(children, "SIGTERM");
+ const survivors = waitUntilStopped(children, TERMINATE_GRACE_MS);
+ signal(survivors, "SIGKILL");
+ waitUntilStopped(survivors, KILL_WAIT_MS);
+ return survivors;
+};
diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts
index 0b5039bd623df..56fb25fcfa907 100644
--- a/apps/desktop/src/app/DesktopLifecycle.ts
+++ b/apps/desktop/src/app/DesktopLifecycle.ts
@@ -12,6 +12,7 @@ import type * as Electron from "electron";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
+import { stopChildProcessesBeforeExit } from "./DesktopExitChildren.ts";
import { makeComponentLogger } from "./DesktopObservability.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
@@ -69,15 +70,18 @@ function addScopedListener>(
target: unknown,
eventName: string,
listener: (...args: Args) => void,
+ options: { readonly prepend?: boolean } = {},
): Effect.Effect {
const eventTarget = target as {
on: (eventName: string, listener: (...args: Array) => void) => unknown;
+ prependListener: (eventName: string, listener: (...args: Array) => void) => unknown;
removeListener: (eventName: string, listener: (...args: Array) => void) => unknown;
};
const untypedListener = listener as unknown as (...args: Array) => void;
return Effect.acquireRelease(
Effect.sync(() => {
- eventTarget.on(eventName, untypedListener);
+ if (options.prepend) eventTarget.prependListener(eventName, untypedListener);
+ else eventTarget.on(eventName, untypedListener);
}),
() =>
Effect.sync(() => {
@@ -300,6 +304,34 @@ export const make = DesktopLifecycle.of({
);
});
+ // Electron can exit without before-quit, e.g. when the X11/Wayland connection drops.
+ // Children (the backend too) get SIGTERM while main still holds an AppImage mount open.
+ // Prepended so the record is buffered before the trace sink's own exit flush runs.
+ yield* addScopedListener(
+ process,
+ "exit",
+ (exitCode: number) => {
+ Effect.runSyncWith(context)(
+ Effect.gen(function* () {
+ const shutdown = yield* DesktopShutdown.DesktopShutdown;
+ // "allowed" without a finished shutdown is the updater's quit; its relaunched app is a child.
+ if (quit.phase === "allowed" || (yield* shutdown.isComplete)) return;
+ const attributes = { exitCode, quitPhase: quit.phase };
+ yield* Effect.gen(function* () {
+ yield* logLifecycleError(
+ "desktop main process exiting before shutdown completed",
+ attributes,
+ );
+ if (environment.platform !== "linux") return;
+ const killed = stopChildProcessesBeforeExit(process.pid);
+ yield* Effect.annotateCurrentSpan({ killedChildPids: killed });
+ }).pipe(Effect.withSpan("desktop.lifecycle.exit", { attributes }));
+ }),
+ );
+ },
+ { prepend: true },
+ );
+
if (environment.platform !== "win32") {
yield* addScopedListener(process, "SIGINT", () => {
quitFromSignal("SIGINT", runEffect, quit);
diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts
index ab8ea0b8e9d6e..ceb29b149e3d6 100644
--- a/apps/desktop/src/app/DesktopObservability.test.ts
+++ b/apps/desktop/src/app/DesktopObservability.test.ts
@@ -281,6 +281,27 @@ describe("DesktopObservability", () => {
),
);
+ it.effect("startup prunes only canonical rotation suffixes", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const directory = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-rotating-prune-test-",
+ });
+ const filePath = `${directory}/server-child.log`;
+ const retained = ["1", "3", "04", "4.0", "4e0", "0x4", "+4", "notes"];
+ for (const suffix of [...retained, "4", "50"]) {
+ yield* fileSystem.writeFileString(`${filePath}.${suffix}`, suffix);
+ }
+
+ yield* DesktopObservability.makeRotatingLogFileWriter({ filePath, maxFiles: 3 });
+
+ assert.deepEqual(
+ (yield* fileSystem.readDirectory(directory)).sort(),
+ retained.map((suffix) => `server-child.log.${suffix}`).sort(),
+ );
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
it.effect("stops every export when the OpenTelemetry SDK is disabled", () => {
const requests: Array = [];
return Effect.gen(function* () {
diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts
index 3cd3936a59bfc..16192fff46de8 100644
--- a/apps/desktop/src/app/DesktopObservability.ts
+++ b/apps/desktop/src/app/DesktopObservability.ts
@@ -263,8 +263,10 @@ export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(
const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
for (const entry of entries) {
if (!entry.startsWith(`${baseName}.`)) continue;
- const suffix = Number(entry.slice(baseName.length + 1));
- if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
+ const suffixText = entry.slice(baseName.length + 1);
+ const suffix = Number(suffixText);
+ if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= maxFiles)
+ continue;
yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
}
});
@@ -654,6 +656,8 @@ const layerTelemetry = Layer.unwrap(
maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
});
+ // Unscoped: Electron can exit without closing the app scope, e.g. when the display drops.
+ process.once("exit", sink.flushSync);
const delegate =
endpoints.traces === undefined
? undefined
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 35156d645dc0a..f241e14587f34 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -214,7 +214,6 @@ const layerDesktopApplication = Layer.mergeAll(
DesktopApplicationMenu.layer,
DesktopLinuxUrlHandler.layer,
DesktopCliCommand.layer,
- layerDesktopSsh,
).pipe(
Layer.provideMerge(layerDesktopSnapShot),
Layer.provideMerge(
@@ -224,6 +223,8 @@ const layerDesktopApplication = Layer.mergeAll(
Layer.provide(DesktopManagedPolicy.layer),
),
),
+ // Updates closes the same tunnels the SSH IPC opens before quitAndInstall.
+ Layer.provideMerge(layerDesktopSsh),
Layer.provideMerge(layerDesktopWslBackend),
Layer.provideMerge(layerDesktopLocalEnvironmentAuth),
);
diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
index 9528cd5d2e846..6309c4ea993f1 100644
--- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
+++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
@@ -33,6 +33,7 @@ const layerRejectedBeforeSession = Layer.succeed(
isPartition: () => false,
getSession: () => Effect.die("getSession must not be reached"),
clearCookies: () => Effect.die("clearCookies must not be reached"),
+ cookiesCleared: () => Effect.die("cookiesCleared must not be reached"),
clearCache: () => Effect.die("clearCache must not be reached"),
}),
);
diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts
index e68e52961fa60..9a9be6213c39a 100644
--- a/apps/desktop/src/preview/BrowserSession.test.ts
+++ b/apps/desktop/src/preview/BrowserSession.test.ts
@@ -1,8 +1,11 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as NodeEvents from "node:events";
import { beforeEach, vi } from "vite-plus/test";
const { fromPartition, sessions } = vi.hoisted(() => ({
@@ -12,6 +15,8 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
{
readonly clearCache: ReturnType;
readonly clearStorageData: ReturnType;
+ readonly closeAllConnections: ReturnType;
+ readonly storagePath: string | null;
readonly getUserAgent: ReturnType string>>;
readonly setPermissionRequestHandler: ReturnType;
readonly setPermissionCheckHandler: ReturnType;
@@ -21,44 +26,125 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
>(),
}));
+const storage = vi.hoisted(() => ({ root: "" }));
+
const { fromWebContents } = vi.hoisted(() => ({
fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
}));
+const { appListeners } = vi.hoisted(() => ({
+ appListeners: new Map void>(),
+}));
+
vi.mock("electron", () => ({
+ app: {
+ on: (eventName: string, listener: (...args: unknown[]) => void) => {
+ appListeners.set(eventName, listener);
+ },
+ removeListener: (eventName: string) => {
+ appListeners.delete(eventName);
+ },
+ },
BrowserWindow: { fromWebContents },
session: {
fromPartition,
},
}));
+import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as BrowserSession from "./BrowserSession.ts";
-const { showMessageBox } = vi.hoisted(() => ({
+const { showMessageBox, showErrorBox } = vi.hoisted(() => ({
showMessageBox: vi.fn<
- (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number
+ (
+ options: Electron.MessageBoxOptions,
+ owner?: Electron.BrowserWindow,
+ ) => number | Promise
>(() => 1),
+ showErrorBox: vi.fn<(title: string, content: string) => void>(),
}));
const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.die("unused"),
pickFiles: () => Effect.die("unused"),
showMessageBox: (options, owner) =>
- Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })),
- showErrorBox: () => Effect.void,
+ Effect.promise(async () => ({
+ response: await showMessageBox(options, owner),
+ checkboxChecked: false,
+ })),
+ showErrorBox: (title, content) => Effect.sync(() => showErrorBox(title, content)),
});
const layer = BrowserSession.layer.pipe(
- Layer.provide(Layer.merge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer), layerDialog)),
+ Layer.provide(
+ Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerDialog, ElectronApp.layer),
+ ),
);
+const selfSignedCertificate = {
+ fingerprint: "sha256/self-signed",
+ subjectName: "localhost",
+ issuerName: "localhost",
+ validStart: 1_735_776_000,
+ validExpiry: 1_807_056_000,
+} as Electron.Certificate;
+
+/** Raises Electron's `certificate-error` for a preview guest in `browserSession`. */
+const certificateError = (
+ browserSession: unknown,
+ input: {
+ readonly url: string;
+ readonly certificate?: Electron.Certificate;
+ readonly isMainFrame?: boolean;
+ },
+) => {
+ const listener = appListeners.get("certificate-error");
+ assert.isDefined(listener, "preview sessions should handle certificate errors");
+ const guest = Object.assign(new NodeEvents.EventEmitter(), {
+ session: browserSession,
+ hostWebContents: null,
+ isDestroyed: () => false,
+ });
+ const event = { preventDefault: vi.fn() };
+ const decision = Deferred.makeUnsafe();
+ const callback = vi.fn((trusted: boolean) => {
+ Deferred.doneUnsafe(decision, Effect.succeed(trusted));
+ });
+ listener(
+ event,
+ guest,
+ input.url,
+ "net::ERR_CERT_AUTHORITY_INVALID",
+ input.certificate ?? selfSignedCertificate,
+ callback,
+ input.isMainFrame ?? true,
+ );
+ return { guest, event, callback, trusted: Deferred.await(decision) };
+};
+
+/** Answers the certificate prompt with its "Trust…" button. */
+const trustCertificateButton = (options: Electron.MessageBoxOptions): number => {
+ const index = (options.buttons ?? []).findIndex((label) => label.startsWith("Trust"));
+ assert.notStrictEqual(index, -1, "the certificate prompt should offer to trust");
+ return index;
+};
+
describe("BrowserSession", () => {
beforeEach(() => {
sessions.clear();
+ appListeners.clear();
+ storage.root = "";
+ showErrorBox.mockReset();
fromPartition.mockReset();
fromPartition.mockImplementation((partition: string) => {
const browserSession = {
clearCache: vi.fn(() => Promise.resolve()),
clearStorageData: vi.fn(() => Promise.resolve()),
+ closeAllConnections: vi.fn(() => Promise.resolve()),
+ // Like Electron, only a `persist:` partition has storage on disk.
+ storagePath:
+ storage.root !== "" && partition.startsWith("persist:")
+ ? `${storage.root}/Partitions/${partition}`
+ : null,
getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
@@ -129,6 +215,8 @@ describe("BrowserSession", () => {
const browserSession = {
clearCache: vi.fn(() => Promise.resolve()),
clearStorageData: vi.fn(() => Promise.resolve()),
+ closeAllConnections: vi.fn(() => Promise.resolve()),
+ storagePath: null,
getUserAgent: vi.fn(() => userAgent),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
@@ -333,4 +421,236 @@ describe("BrowserSession", () => {
assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0);
}).pipe(Effect.provide(layer)),
);
+
+ it.effect("asks before trusting an untrusted certificate and remembers the approval", () =>
+ Effect.gen(function* () {
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const partition = yield* browserSessions.getPartition("scope-a");
+ yield* browserSessions.getSession("scope-a");
+ const browserSession = sessions.get(partition);
+
+ showMessageBox.mockReset();
+ showMessageBox.mockImplementation(trustCertificateButton);
+ const first = certificateError(browserSession, { url: "https://localhost:18443/" });
+ assert.strictEqual(first.event.preventDefault.mock.calls.length, 1);
+ assert.isTrue(yield* first.trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+
+ // The approval covers this origin, port, and certificate in this profile.
+ const again = certificateError(browserSession, { url: "https://localhost:18443/other" });
+ assert.isTrue(yield* again.trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+ // Its sockets reach the same TLS endpoint, and cannot ask on their own.
+ const socket = certificateError(browserSession, {
+ url: "wss://localhost:18443/hmr",
+ isMainFrame: false,
+ });
+ assert.isTrue(yield* socket.trusted);
+ const otherSocket = certificateError(browserSession, {
+ url: "wss://localhost:18444/hmr",
+ isMainFrame: false,
+ });
+ assert.isFalse(yield* otherSocket.trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+
+ showMessageBox.mockReset();
+ showMessageBox.mockReturnValue(1);
+ const otherPort = certificateError(browserSession, { url: "https://localhost:18444/" });
+ assert.isFalse(yield* otherPort.trusted);
+ const renewed = certificateError(browserSession, {
+ url: "https://localhost:18443/",
+ certificate: { ...selfSignedCertificate, fingerprint: "sha256/renewed" },
+ });
+ assert.isFalse(yield* renewed.trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 2);
+
+ // A Session that is not a preview profile keeps Electron's denial.
+ const appSession = certificateError({}, { url: "https://localhost:18443/" });
+ assert.strictEqual(appSession.event.preventDefault.mock.calls.length, 0);
+ assert.strictEqual(appSession.callback.mock.calls.length, 0);
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("revokes certificate approvals when cookies are cleared, not the cache", () =>
+ Effect.gen(function* () {
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const partition = yield* browserSessions.getPartition("scope-a");
+ yield* browserSessions.getSession("scope-a");
+ const browserSession = sessions.get(partition);
+ const url = "https://localhost:18443/";
+
+ showMessageBox.mockReset();
+ showMessageBox.mockImplementation(trustCertificateButton);
+ assert.isTrue(yield* certificateError(browserSession, { url }).trusted);
+
+ yield* browserSessions.clearCache([partition]);
+ assert.isTrue(yield* certificateError(browserSession, { url }).trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+
+ // A failed storage clear still forgets the approvals the user asked to clear.
+ browserSession!.clearStorageData.mockRejectedValueOnce(new Error("disk busy"));
+ const failure = yield* browserSessions.clearCookies([partition]).pipe(Effect.flip);
+ assert.strictEqual(failure._tag, "BrowserSessionStorageClearError");
+ showMessageBox.mockReset();
+ showMessageBox.mockReturnValue(1);
+ assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("remembers approvals across restarts, but not incognito ones", () =>
+ Effect.gen(function* () {
+ storage.root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
+ const url = "https://localhost:18443/";
+ const approve = Effect.gen(function* () {
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const persistent = yield* browserSessions.getPartition("scope-a");
+ const incognito = yield* browserSessions.getPartition("scope-a", false);
+ yield* browserSessions.getSession("scope-a");
+ yield* browserSessions.getSession("scope-a", false);
+ showMessageBox.mockReset();
+ showMessageBox.mockImplementation(trustCertificateButton);
+ assert.isTrue(yield* certificateError(sessions.get(persistent), { url }).trusted);
+ assert.isTrue(yield* certificateError(sessions.get(incognito), { url }).trusted);
+ return { persistent, incognito };
+ });
+ const { persistent, incognito } = yield* approve.pipe(Effect.provide(layer));
+
+ // A restarted app gets fresh Session objects and reads approvals from disk.
+ sessions.clear();
+ appListeners.clear();
+ yield* Effect.gen(function* () {
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ yield* browserSessions.getSession("scope-a");
+ yield* browserSessions.getSession("scope-a", false);
+ showMessageBox.mockReset();
+ showMessageBox.mockReturnValue(1);
+ assert.isTrue(yield* certificateError(sessions.get(persistent), { url }).trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 0);
+ assert.isFalse(yield* certificateError(sessions.get(incognito), { url }).trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+ }).pipe(Effect.provide(layer));
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("an answer to a prompt opened before cookies were cleared does not trust", () =>
+ Effect.gen(function* () {
+ storage.root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const partition = yield* browserSessions.getPartition("scope-a");
+ yield* browserSessions.getSession("scope-a");
+ const browserSession = sessions.get(partition);
+ const url = "https://localhost:18443/";
+
+ const asked = yield* Deferred.make();
+ let answer = () => {};
+ const answered = new Promise((resolve) => {
+ answer = resolve;
+ });
+ showMessageBox.mockReset();
+ showMessageBox.mockImplementation((options) => {
+ Deferred.doneUnsafe(asked, Effect.void);
+ return answered.then(() => trustCertificateButton(options));
+ });
+ const pending = certificateError(browserSession, { url });
+ yield* Deferred.await(asked);
+ yield* browserSessions.clearCookies([partition]);
+ answer();
+ assert.isFalse(yield* pending.trusted);
+
+ showMessageBox.mockReset();
+ showMessageBox.mockReturnValue(1);
+ assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+ assert.isFalse(
+ yield* (yield* FileSystem.FileSystem).exists(
+ `${browserSession!.storagePath}/t3code-certificate-approvals.json`,
+ ),
+ );
+ }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))),
+ );
+
+ it.effect("a tab that closes or navigates away dismisses its certificate prompt", () =>
+ Effect.gen(function* () {
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const partition = yield* browserSessions.getPartition("scope-a");
+ yield* browserSessions.getSession("scope-a");
+ const browserSession = sessions.get(partition);
+ const url = "https://localhost:18443/";
+
+ for (const leave of [
+ (guest: NodeEvents.EventEmitter) =>
+ guest.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }),
+ (guest: NodeEvents.EventEmitter) => guest.emit("destroyed"),
+ ]) {
+ showMessageBox.mockReset();
+ // Answering "Trust" as the tab leaves must not approve the certificate.
+ showMessageBox.mockImplementation(
+ (options) =>
+ new Promise((resolve) =>
+ options.signal?.addEventListener("abort", () =>
+ resolve(trustCertificateButton(options)),
+ ),
+ ),
+ );
+ const pending = certificateError(browserSession, { url });
+ pending.guest.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true });
+ leave(pending.guest);
+ assert.isFalse(yield* pending.trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+ }
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("does not trust a certificate whose approval cannot be saved", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ // A file where the profile directory should be makes every write fail.
+ const root = yield* fileSystem.makeTempDirectoryScoped();
+ yield* fileSystem.writeFileString(`${root}/Partitions`, "");
+ storage.root = root;
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const partition = yield* browserSessions.getPartition("scope-a");
+ yield* browserSessions.getSession("scope-a");
+ const browserSession = sessions.get(partition);
+ const url = "https://localhost:18443/";
+
+ showMessageBox.mockReset();
+ showMessageBox.mockImplementation(trustCertificateButton);
+ assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
+ assert.strictEqual(showErrorBox.mock.calls.length, 1);
+ // Nothing was trusted for this run either, so the next visit asks again.
+ showMessageBox.mockReset();
+ showMessageBox.mockReturnValue(1);
+ assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
+ assert.strictEqual(showMessageBox.mock.calls.length, 1);
+ }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))),
+ );
+
+ it.effect("clearing cookies deletes a saved approval even when none are loaded", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ storage.root = yield* fileSystem.makeTempDirectoryScoped();
+ const browserSessions = yield* BrowserSession.BrowserSession;
+ const partition = yield* browserSessions.getPartition("scope-a");
+ yield* browserSessions.getSession("scope-a");
+ const browserSession = sessions.get(partition);
+ // What a clear whose removal failed leaves behind: memory forgot, the file did not.
+ const file = `${browserSession!.storagePath}/t3code-certificate-approvals.json`;
+ yield* fileSystem.makeDirectory(browserSession!.storagePath!, { recursive: true });
+ yield* fileSystem.writeFileString(
+ file,
+ JSON.stringify([
+ {
+ origin: "https://localhost:18443",
+ fingerprint: selfSignedCertificate.fingerprint,
+ error: "net::ERR_CERT_AUTHORITY_INVALID",
+ },
+ ]),
+ );
+
+ yield* browserSessions.clearCookies([partition]);
+ assert.isFalse(yield* fileSystem.exists(file));
+ }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))),
+ );
});
diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts
index b2523ba62b973..1400f2c9cbe09 100644
--- a/apps/desktop/src/preview/BrowserSession.ts
+++ b/apps/desktop/src/preview/BrowserSession.ts
@@ -1,14 +1,21 @@
-import type { Session, WebContents } from "electron";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import type { Certificate, Session, WebContents } from "electron";
import { BrowserWindow, session } from "electron";
import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
import * as Digest from "effect/crypto/Digest";
import * as Effect from "effect/Effect";
import * as Hex from "effect/encoding/Hex";
+import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
+import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
import * as SynchronizedRef from "effect/SynchronizedRef";
+import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
@@ -73,6 +80,64 @@ const permissionOrigin = (rawUrl: string | undefined): string | null => {
}
};
+/**
+ * Certificate errors a user may accept for a preview page, with the reason the
+ * prompt shows. Every other error keeps Electron's denial.
+ */
+const OVERRIDABLE_CERTIFICATE_ERRORS: ReadonlyMap = new Map([
+ ["net::ERR_CERT_AUTHORITY_INVALID", "No trusted authority issued this certificate."],
+ ["net::ERR_CERT_COMMON_NAME_INVALID", "This certificate is not for this address."],
+ ["net::ERR_CERT_DATE_INVALID", "This certificate has expired or is not valid yet."],
+]);
+
+/**
+ * A `wss:` socket reaches the same TLS endpoint as the page, so it shares the
+ * `https:` origin. It must: a socket is never a main frame, so it could not be
+ * approved on its own, and a page's dev-server socket would stay blocked.
+ */
+const certificateOrigin = (rawUrl: string): string | null => {
+ try {
+ const url = new URL(rawUrl);
+ return url.protocol === "https:" || url.protocol === "wss:" ? `https://${url.host}` : null;
+ } catch {
+ return null;
+ }
+};
+
+const CertificateApproval = Schema.Struct({
+ origin: Schema.String,
+ fingerprint: Schema.String,
+ error: Schema.String,
+});
+type CertificateApproval = typeof CertificateApproval.Type;
+const CertificateApprovalsJson = Schema.fromJsonString(Schema.Array(CertificateApproval));
+const decodeCertificateApprovals = Schema.decodeEffect(CertificateApprovalsJson);
+const encodeCertificateApprovals = Schema.encodeEffect(CertificateApprovalsJson);
+
+const certificateApprovalKey = ({ origin, fingerprint, error }: CertificateApproval): string =>
+ JSON.stringify([origin, fingerprint, error]);
+
+/** Kept beside the profile's cookies, so it lives and dies with them. */
+const CERTIFICATE_APPROVALS_FILE = "t3code-certificate-approvals.json";
+
+/** Server-chosen certificate names must not add lines or flood the prompt. */
+const promptCertificateName = (name: string): string => {
+ const flat = name.replace(/\s+/g, " ").trim();
+ return flat.length > 120 ? `${flat.slice(0, 120)}…` : flat;
+};
+
+const promptCertificateDate = (seconds: number): string =>
+ DateTime.formatIsoDateUtc(DateTime.makeUnsafe(seconds * 1000));
+
+/** The certificates a user accepted in one preview Session. */
+interface CertificateTrust {
+ /** `null` for an incognito Session, whose approvals end with the process. */
+ readonly file: string | null;
+ readonly approvals: Map;
+ /** Bumped by each revocation, so an answer to an older prompt cannot restore trust. */
+ revocations: number;
+}
+
export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()(
"BrowserSessionPartitionDerivationError",
{
@@ -154,6 +219,10 @@ export class BrowserSession extends Context.Service<
readonly clearCookies: (
partitions?: ReadonlyArray,
) => Effect.Effect;
+ /** For cookies cleared outside `clearCookies`, such as over a tab's CDP session. */
+ readonly cookiesCleared: (
+ browserSession: Session,
+ ) => Effect.Effect;
readonly clearCache: (
partitions?: ReadonlyArray,
) => Effect.Effect;
@@ -190,7 +259,11 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined
/** @public */
export const make = Effect.gen(function* BrowserSessionMake() {
const crypto = yield* Digest.Digest;
+ const electronApp = yield* ElectronApp.ElectronApp;
const electronDialog = yield* ElectronDialog.ElectronDialog;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const platform = yield* HostProcess.Platform;
const runFork = Effect.runForkWith(yield* Effect.context());
// One prompt at a time, so a page cannot stack dialogs by looping a deep link.
let permissionPromptOpen = false;
@@ -257,6 +330,229 @@ export const make = Effect.gen(function* BrowserSessionMake() {
(answer) => callback(answer === true),
);
};
+
+ const certificateTrust = new WeakMap();
+ const writeCertificateApprovals = yield* Semaphore.make(1);
+ const loadCertificateTrust = Effect.fn("BrowserSession.loadCertificateTrust")(function* (
+ browserSession: Session,
+ ) {
+ const file =
+ browserSession.storagePath == null
+ ? null
+ : path.join(browserSession.storagePath, CERTIFICATE_APPROVALS_FILE);
+ const approvals: ReadonlyArray =
+ file === null
+ ? []
+ : yield* fileSystem.readFileString(file).pipe(
+ Effect.flatMap(decodeCertificateApprovals),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "NotFound" ? Effect.succeed([]) : Effect.fail(error),
+ }),
+ Effect.catch((cause) =>
+ Effect.logWarning("Ignored saved certificate approvals.", { file, cause }).pipe(
+ Effect.as([]),
+ ),
+ ),
+ );
+ const trust: CertificateTrust = {
+ file,
+ approvals: new Map(approvals.map((approval) => [certificateApprovalKey(approval), approval])),
+ revocations: 0,
+ };
+ return trust;
+ });
+ const writeApprovalsFile = Effect.fn("BrowserSession.writeApprovalsFile")(function* (
+ file: string,
+ approvals: ReadonlyMap,
+ ) {
+ if (approvals.size === 0) return yield* fileSystem.remove(file, { force: true });
+ yield* fileSystem.makeDirectory(path.dirname(file), { recursive: true });
+ yield* fileSystem.writeFileString(
+ file,
+ yield* encodeCertificateApprovals([...approvals.values()]),
+ );
+ });
+ /**
+ * Saved before it is trusted, so a failed write cannot leave an approval that
+ * silently ends at restart. Writes share one lock, so a slower write cannot
+ * restore stale data.
+ */
+ const rememberCertificate = Effect.fn("BrowserSession.rememberCertificate")(function* (
+ trust: CertificateTrust,
+ key: string,
+ approval: CertificateApproval,
+ revocations: number,
+ ) {
+ if (trust.revocations !== revocations) return false;
+ if (trust.file !== null) {
+ yield* writeApprovalsFile(trust.file, new Map(trust.approvals).set(key, approval));
+ // A revocation during the write queues its own rewrite behind this one.
+ if (trust.revocations !== revocations) return false;
+ }
+ trust.approvals.set(key, approval);
+ return true;
+ }, writeCertificateApprovals.withPermits(1));
+ /**
+ * Rewrites the file from memory. Clearing cookies runs this even when memory
+ * is already empty, so retrying after a failed removal still deletes the file.
+ */
+ const saveCertificateApprovals = Effect.fn("BrowserSession.saveCertificateApprovals")(function* (
+ trust: CertificateTrust,
+ ) {
+ if (trust.file !== null) yield* writeApprovalsFile(trust.file, trust.approvals);
+ }, writeCertificateApprovals.withPermits(1));
+
+ // One prompt at a time; tabs that hit the same certificate wait on it.
+ let certificatePrompt: {
+ readonly trust: CertificateTrust;
+ readonly key: string;
+ readonly callbacks: Array<(trusted: boolean) => void>;
+ } | null = null;
+ const confirmCertificate = (
+ contents: WebContents,
+ trust: CertificateTrust,
+ approval: CertificateApproval,
+ certificate: Certificate,
+ reason: string,
+ callback: (trusted: boolean) => void,
+ ): void => {
+ const key = certificateApprovalKey(approval);
+ if (certificatePrompt !== null) {
+ if (certificatePrompt.trust === trust && certificatePrompt.key === key) {
+ certificatePrompt.callbacks.push(callback);
+ } else {
+ callback(false);
+ }
+ return;
+ }
+ const prompt = { trust, key, callbacks: [callback] };
+ certificatePrompt = prompt;
+ // A prompt for a tab that closed or moved on would approve a page nobody is waiting for.
+ const dismiss = new AbortController();
+ const abandon = () => dismiss.abort();
+ const navigated = (
+ event: Electron.Event,
+ ) => {
+ if (event.isMainFrame && !event.isSameDocument) abandon();
+ };
+ contents.once("destroyed", abandon);
+ contents.on("did-start-navigation", navigated);
+ const settle = (trusted: boolean) =>
+ Effect.sync(() => {
+ certificatePrompt = null;
+ if (!contents.isDestroyed()) {
+ contents.off("destroyed", abandon);
+ contents.off("did-start-navigation", navigated);
+ }
+ for (const waiter of prompt.callbacks) waiter(trusted);
+ });
+ const host = new URL(approval.origin).host;
+ const revocations = trust.revocations;
+ runFork(
+ electronDialog
+ .showMessageBox(
+ {
+ type: "warning",
+ buttons: ["Trust and Remember", "Cancel"],
+ defaultId: 1,
+ cancelId: 1,
+ signal: dismiss.signal,
+ message: `${host} has an untrusted certificate`,
+ detail: [
+ reason,
+ "",
+ `Issued to: ${promptCertificateName(certificate.subjectName)}`,
+ `Issued by: ${promptCertificateName(certificate.issuerName)}`,
+ `Valid: ${promptCertificateDate(certificate.validStart)} – ${promptCertificateDate(certificate.validExpiry)}`,
+ `Fingerprint: ${approval.fingerprint}`,
+ "",
+ "Trust it only if you know this certificate belongs to the site. Clearing this browser profile's cookies forgets it.",
+ ].join("\n"),
+ },
+ // In the reporter's Windows test, a box owned by the host window
+ // blocked all mouse input, its own buttons included, until it closed.
+ platform === "win32" ? undefined : previewHostWindow(contents),
+ )
+ .pipe(
+ Effect.flatMap(({ response }) =>
+ response === 0 && !dismiss.signal.aborted
+ ? rememberCertificate(trust, key, approval, revocations).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("Failed to save a certificate approval.", { cause }).pipe(
+ Effect.andThen(
+ electronDialog.showErrorBox(
+ `Could not trust the certificate for ${host}`,
+ "T3 Code could not save this approval, so the page stays blocked. Check that the disk is not full and its data folder is writable, then try again.",
+ ),
+ ),
+ Effect.as(false),
+ ),
+ ),
+ )
+ : Effect.succeed(false),
+ ),
+ Effect.orElseSucceed(() => false),
+ Effect.flatMap(settle),
+ Effect.onInterrupt(() => settle(false)),
+ ),
+ );
+ };
+ yield* electronApp.on(
+ "certificate-error",
+ (
+ event: Electron.Event,
+ contents: WebContents,
+ url: string,
+ error: string,
+ certificate: Certificate,
+ callback: (trusted: boolean) => void,
+ isMainFrame: boolean,
+ ) => {
+ if (contents.isDestroyed()) return;
+ // Sessions this service did not create keep Electron's denial.
+ const trust = certificateTrust.get(contents.session);
+ const origin = certificateOrigin(url);
+ const reason = OVERRIDABLE_CERTIFICATE_ERRORS.get(error);
+ if (trust === undefined || origin === null || reason === undefined) return;
+ const approval = { origin, fingerprint: certificate.fingerprint, error };
+ event.preventDefault();
+ if (trust.approvals.has(certificateApprovalKey(approval))) {
+ callback(true);
+ return;
+ }
+ // Only a page navigation asks; a subresource the user has not accepted stays blocked.
+ if (!isMainFrame) {
+ callback(false);
+ return;
+ }
+ confirmCertificate(contents, trust, approval, certificate, reason, callback);
+ },
+ );
+
+ /** Clearing a profile's cookies also forgets the certificates its user trusted. */
+ const revokeCertificates = Effect.fn("BrowserSession.revokeCertificates")(function* (
+ partition: string,
+ browserSession: Session,
+ ) {
+ const trust = certificateTrust.get(browserSession);
+ if (trust === undefined) return;
+ const hadApprovals = trust.approvals.size > 0;
+ trust.revocations += 1;
+ trust.approvals.clear();
+ const saved = yield* saveCertificateApprovals(trust).pipe(Effect.result);
+ // An open connection already passed its certificate check.
+ if (hadApprovals) {
+ yield* Effect.tryPromise({
+ try: () => browserSession.closeAllConnections(),
+ catch: (cause) => new BrowserSessionStorageClearError({ partition, cause }),
+ });
+ }
+ if (Result.isFailure(saved)) {
+ return yield* new BrowserSessionStorageClearError({ partition, cause: saved.failure });
+ }
+ });
+
const sessionsRef = yield* SynchronizedRef.make>(new Map());
const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
@@ -361,9 +657,7 @@ export const make = Effect.gen(function* BrowserSessionMake() {
});
},
);
- const next = new Map(sessions);
- next.set(partition, browserSession);
- return [browserSession, next] as const;
+ return browserSession;
},
catch: (cause) =>
new BrowserSessionCreationError({
@@ -371,7 +665,18 @@ export const make = Effect.gen(function* BrowserSessionMake() {
partition,
cause,
}),
- });
+ }).pipe(
+ Effect.flatMap((browserSession) =>
+ loadCertificateTrust(browserSession).pipe(
+ Effect.map((trust) => {
+ certificateTrust.set(browserSession, trust);
+ const next = new Map(sessions);
+ next.set(partition, browserSession);
+ return [browserSession, next] as const;
+ }),
+ ),
+ ),
+ );
});
});
@@ -386,20 +691,37 @@ export const make = Effect.gen(function* BrowserSessionMake() {
yield* Effect.forEach(
selectSessions(sessions, partitions),
([partition, browserSession]) =>
- Effect.tryPromise({
- try: () =>
- browserSession.clearStorageData({
- storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"],
- }),
- catch: (cause) =>
- new BrowserSessionStorageClearError({
- partition,
- cause,
- }),
+ Effect.gen(function* () {
+ const cleared = yield* Effect.tryPromise({
+ try: () =>
+ browserSession.clearStorageData({
+ storages: [
+ "cookies",
+ "localstorage",
+ "indexdb",
+ "serviceworkers",
+ "cachestorage",
+ ],
+ }),
+ catch: (cause) =>
+ new BrowserSessionStorageClearError({
+ partition,
+ cause,
+ }),
+ }).pipe(Effect.result);
+ // Approvals go even when the storage clear fails, since the user asked to forget them.
+ yield* revokeCertificates(partition, browserSession);
+ if (Result.isFailure(cleared)) return yield* cleared.failure;
}),
{ concurrency: "unbounded", discard: true },
);
}),
+ cookiesCleared: Effect.fn("BrowserSession.cookiesCleared")(function* (browserSession) {
+ const sessions = yield* SynchronizedRef.get(sessionsRef);
+ for (const [partition, candidate] of sessions) {
+ if (candidate === browserSession) yield* revokeCertificates(partition, browserSession);
+ }
+ }),
clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) {
const sessions = yield* SynchronizedRef.get(sessionsRef);
yield* Effect.forEach(
diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts
index 2cda717a891b7..a53fc0e3e6ec2 100644
--- a/apps/desktop/src/preview/DesktopBrowserHost.ts
+++ b/apps/desktop/src/preview/DesktopBrowserHost.ts
@@ -40,6 +40,8 @@ export interface DesktopBrowserTabKey {
export interface DesktopBrowserTabDebugger {
readonly webContents: Electron.WebContents;
readonly debugger: Electron.Debugger;
+ /** Runs after the server clears the tab's cookies, so state kept beside them goes too. */
+ readonly cookiesCleared?: () => Promise;
}
const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`;
@@ -315,9 +317,12 @@ export const make = Effect.gen(function* () {
const relayFor = (tab: AttachedTab) => {
if (tab.relay) return tab.relay;
- const { webContents, debugger: debuggee } = tab.debuggee;
- const send = (method: string, params: Record, sessionId?: string) =>
- sendPageCommand(webContents, debuggee, method, params, sessionId);
+ const { webContents, debugger: debuggee, cookiesCleared } = tab.debuggee;
+ const send = async (method: string, params: Record, sessionId?: string) => {
+ const result = await sendPageCommand(webContents, debuggee, method, params, sessionId);
+ if (method === "Network.clearBrowserCookies") await cookiesCleared?.();
+ return result;
+ };
const detachedOwners = new Set();
/** Viewers and recordings stream; the agent's own sessions never do. */
const streamingOwners = new Set();
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index a178edeb9753f..6db04b389ab6d 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -205,6 +205,7 @@ const layerBrowserSession = Layer.succeed(
isPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
getSession: () => Effect.succeed(previewSession as unknown as Electron.Session),
clearCookies: () => Effect.void,
+ cookiesCleared: () => Effect.void,
clearCache: () => Effect.void,
}),
);
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 7f39a92bdb769..189885b9b2178 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -563,9 +563,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const random = yield* SecureRandom.SecureRandom;
const parentScope = yield* Scope.Scope;
const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
+ const browserSession = yield* BrowserSession.BrowserSession;
const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
const context = yield* Effect.context();
const runFork = Effect.runForkWith(context);
+ const runPromise = Effect.runPromiseWith(context);
const resolvedArtifactDirectory = path.resolve(artifactDirectory);
let forwardedShortcuts: ReadonlyArray = [];
@@ -2472,7 +2474,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return;
}
if (afterAttach.serverTab) {
- browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger });
+ browserHost.attach(afterAttach.serverTab, {
+ webContents: wc,
+ debugger: control.debugger,
+ cookiesCleared: () => runPromise(browserSession.cookiesCleared(wc.session)),
+ });
// CDP cannot recover a failure that happened before it attached.
const { navStatus } = afterAttach;
if (navStatus.kind === "LoadFailed") {
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
index 0c7869194323c..c00e2ea7c65a2 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
@@ -637,6 +637,52 @@ describe("DesktopShellEnvironment", () => {
}),
);
+ it.effect("recovers the login-shell PATH when the first probe outlasts the timeout", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" };
+ const output = envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" });
+ let shellProbes = 0;
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () => "",
+ processSpawner: ChildProcessSpawner.make((command) => {
+ if (command._tag !== "StandardCommand" || command.command !== "/bin/zsh") {
+ return Effect.succeed(makeProcess(""));
+ }
+ shellProbes += 1;
+ if (shellProbes > 1) return Effect.succeed(makeProcess(output));
+ // A cold start: the first login shell answers, but only after 7s.
+ const slow = Effect.sleep("7 seconds");
+ const stdout = Stream.fromEffect(slow.pipe(Effect.as(textEncoder.encode(output))));
+ return Effect.succeed(
+ ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(125),
+ stdout,
+ stderr: Stream.empty,
+ all: stdout,
+ exitCode: slow.pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
+ isRunning: Effect.succeed(true),
+ kill: () => Effect.void,
+ stdin: Sink.drain,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ unref: Effect.succeed(Effect.void),
+ }),
+ );
+ }),
+ use: (service) =>
+ Effect.gen(function* () {
+ yield* service.installIntoProcess;
+ yield* TestClock.adjust("10 minutes");
+ return true;
+ }),
+ }).pipe(Effect.provide(Logger.layer([])));
+
+ assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin");
+ }),
+ );
+
it.effect("loads PowerShell profile environment on Windows", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts
index 9d9e390c95496..f0983ca622490 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts
@@ -117,6 +117,8 @@ const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
const FALLBACK_LC_CTYPE = "en_US.UTF-8";
const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
+// A profile can be slow only while the machine boots; one longer wait covers it.
+const SLOW_LOGIN_SHELL_TIMEOUT = Duration.seconds(20);
const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
@@ -261,7 +263,11 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
readonly env: NodeJS.ProcessEnv;
readonly timeout: Duration.Duration;
readonly shell?: boolean;
-}): Effect.fn.Return {
+}): Effect.fn.Return<
+ string,
+ DesktopShellEnvironmentCommandTimeoutError,
+ ChildProcessSpawner.ChildProcessSpawner
+> {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fiber = yield* Effect.scoped(
Effect.gen(function* () {
@@ -317,21 +323,34 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
return output.value;
}
- const error = new DesktopShellEnvironmentCommandTimeoutError({
+ return yield* new DesktopShellEnvironmentCommandTimeoutError({
probe: input.probe,
executable: executableName(input.command),
argumentCount: input.args.length,
timeoutMs: Duration.toMillis(input.timeout),
});
- yield* logShellEnvironmentCommandError(error);
- return "";
});
+const orEmptyOnTimeout = (
+ effect: Effect.Effect,
+): Effect.Effect =>
+ effect.pipe(
+ Effect.catchTags({
+ DesktopShellEnvironmentCommandTimeoutError: (error) =>
+ logShellEnvironmentCommandError(error).pipe(Effect.as("")),
+ }),
+ );
+
const readLoginShellEnvironment = (
shell: string,
names: ReadonlyArray,
env: NodeJS.ProcessEnv,
-): Effect.Effect =>
+ timeout: Duration.Duration,
+): Effect.Effect<
+ EnvironmentPatch,
+ DesktopShellEnvironmentCommandTimeoutError,
+ ChildProcessSpawner.ChildProcessSpawner
+> =>
names.length === 0
? Effect.succeed({})
: runCommandOutput({
@@ -339,7 +358,7 @@ const readLoginShellEnvironment = (
command: shell,
args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)],
env,
- timeout: LOGIN_SHELL_TIMEOUT,
+ timeout,
}).pipe(Effect.map((output) => extractEnvironment(output, names)));
const readLaunchctlPath = (env: NodeJS.ProcessEnv) =>
@@ -349,7 +368,7 @@ const readLaunchctlPath = (env: NodeJS.ProcessEnv) =>
command: "/bin/launchctl",
args: ["getenv", "PATH"],
timeout: LAUNCHCTL_TIMEOUT,
- }).pipe(Effect.map(trimNonEmpty));
+ }).pipe(orEmptyOnTimeout, Effect.map(trimNonEmpty));
const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")(
function* (
@@ -374,7 +393,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn
args,
env,
timeout: LOGIN_SHELL_TIMEOUT,
- });
+ }).pipe(orEmptyOnTimeout);
const environment = extractEnvironment(output, names);
if (Object.keys(environment).length > 0) {
return environment;
@@ -429,10 +448,27 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
const userShells = [config.env.SHELL?.trim(), readUserLoginShell()];
let answeredBy: string | undefined;
+ let waitedForSlowShell = false;
for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) {
+ const read = (timeout: Duration.Duration) =>
+ readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env, timeout);
Object.assign(
shellEnvironment,
- yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env),
+ yield* read(LOGIN_SHELL_TIMEOUT).pipe(
+ Effect.catchTags({
+ DesktopShellEnvironmentCommandTimeoutError: (error) => {
+ const retry = !waitedForSlowShell;
+ waitedForSlowShell = true;
+ return logShellEnvironmentCommandError(error).pipe(
+ Effect.andThen(retry ? read(SLOW_LOGIN_SHELL_TIMEOUT) : Effect.succeed({})),
+ );
+ },
+ }),
+ Effect.catchTags({
+ DesktopShellEnvironmentCommandTimeoutError: (error) =>
+ logShellEnvironmentCommandError(error).pipe(Effect.as({})),
+ }),
+ ),
);
if (shellEnvironment.PATH) {
answeredBy = shell;
diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts
index 35c007e35dc0e..1a85c9a4cd891 100644
--- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts
+++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts
@@ -30,6 +30,7 @@ class FakeBus extends NodeEvents.EventEmitter {
requestPath = "";
boundId = "";
foreignHandle = false;
+ owner = ":1.2";
send(message: Message) {
this.sends.push(message);
@@ -39,7 +40,7 @@ class FakeBus extends NodeEvents.EventEmitter {
member: string,
path: string,
body: unknown[],
- sender = ":1.2",
+ sender = this.owner,
signature = "",
) {
this.emit(
@@ -71,7 +72,7 @@ class FakeBus extends NodeEvents.EventEmitter {
]),
});
}
- activate(id = this.boundId, session = this.session, sender = ":1.2") {
+ activate(id = this.boundId, session = this.session, sender = this.owner) {
this.signal(portal, "Activated", root, [session, id, 1, {}], sender, "osta{sv}");
}
async call(message: Message) {
@@ -87,7 +88,7 @@ class FakeBus extends NodeEvents.EventEmitter {
if (this.registryError) throw this.registryError;
return reply();
}
- if (message.member === "GetNameOwner") return reply([":1.2"]);
+ if (message.member === "GetNameOwner") return reply([this.owner]);
if (message.member === "Get") return reply([new Variant("u", this.version)]);
if (message.member === "AddMatch" || message.member === "ConfigureShortcuts") return reply();
const options = message.body.at(-1) as Record>;
@@ -338,7 +339,30 @@ it("handles a disappearing portal without keeping a false registered state", asy
);
expect(client.state.shortcutRegistered).toBe(false);
expect(client.state.shortcutMessage).toContain("restarted");
- expect(bus.disconnect).toHaveBeenCalledOnce();
+ expect(client.hasSession).toBe(false);
+ expect(bus.disconnect).not.toHaveBeenCalled();
+ await vi.waitFor(() => expect(client.state.shortcutRegistered).toBe(true));
+ expect(bus.calls.filter((call) => call.member === "CreateSession")).toHaveLength(2);
+});
+
+it("binds a new session when the portal restarts", async () => {
+ const { bus, client, capture } = start(new FakeBus(), chord, true);
+ await client.ready;
+ const ownerChanged = (from: string, to: string) =>
+ bus.signal(
+ "org.freedesktop.DBus",
+ "NameOwnerChanged",
+ "/org/freedesktop/DBus",
+ ["org.freedesktop.portal.Desktop", from, to],
+ "org.freedesktop.DBus",
+ );
+ ownerChanged(":1.2", "");
+ bus.owner = ":1.3";
+ ownerChanged("", ":1.3");
+ await vi.waitFor(() => expect(client.state.shortcutActionRegistered).toBe(true));
+ expect(bus.calls.filter((call) => call.member === "CreateSession")).toHaveLength(2);
+ bus.activate();
+ expect(capture).toHaveBeenCalledOnce();
});
it("accepts old portals without Registry but does not ignore permission denial", async () => {
diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts
index ec42af46a3881..8d44ec86d4fb7 100644
--- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts
+++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts
@@ -67,6 +67,7 @@ export class PortalCaptureShortcut {
};
readonly ready: Promise;
private closed = false;
+ private connection = 0;
private owner = "";
private namespace = "";
private session = "";
@@ -74,8 +75,10 @@ export class PortalCaptureShortcut {
private version = 0;
private pending: { path: string; resolve: (body: unknown) => void } | undefined;
private responses = new Map();
- private readonly stopped: Promise;
+ private stopped!: Promise;
private rejectStopped!: (reason: Error) => void;
+ private readonly appId: string;
+ private readonly trigger: string;
private readonly onCapture: () => void;
private readonly onStateChanged: () => void;
private readonly bus: MessageBus;
@@ -89,6 +92,8 @@ export class PortalCaptureShortcut {
bus: MessageBus = sessionBus(),
managedByHyprland = false,
) {
+ this.appId = appId;
+ this.trigger = portalShortcutTrigger(shortcut);
this.onCapture = onCapture;
this.onStateChanged = onStateChanged;
this.bus = bus;
@@ -99,13 +104,41 @@ export class PortalCaptureShortcut {
shortcutPending: true,
shortcutMessage: "Connecting to Hyprland shortcuts…",
};
+ this.resetStopped();
+ bus.on("error", this.failed);
+ bus.on("message", this.message);
+ this.ready = this.connect(true);
+ }
+
+ private resetStopped() {
this.stopped = new Promise((_, reject) => {
this.rejectStopped = reject;
});
void this.stopped.catch(() => undefined);
- bus.on("error", this.failed);
- bus.on("message", this.message);
- this.ready = this.initialize(appId, shortcut).catch(this.failed);
+ }
+
+ private connect(first = false) {
+ const connection = ++this.connection;
+ return this.initialize(first).catch((error: unknown) => {
+ if (connection === this.connection) this.failed(error);
+ });
+ }
+
+ private portalGone() {
+ this.rejectStopped(new Error("The desktop shortcut service restarted."));
+ this.resetStopped();
+ this.owner = "";
+ this.session = "";
+ this.pending = undefined;
+ this.responses.clear();
+ this.update({
+ shortcutRegistered: false,
+ shortcutActionRegistered: false,
+ shortcutPending: true,
+ shortcutMessage: "The desktop shortcut service restarted. Reconnecting…",
+ });
+ // Register is sent to the well-known name, so it D-Bus-activates a portal that is not running.
+ void this.connect();
}
close = () => {
@@ -209,10 +242,9 @@ export class PortalCaptureShortcut {
message.sender === "org.freedesktop.DBus" &&
message.interface === DBUS &&
message.member === "NameOwnerChanged" &&
- message.body[0] === PORTAL &&
- message.body[1] === this.owner
+ message.body[0] === PORTAL
) {
- this.failed(new Error("The desktop shortcut service restarted. Retry the shortcut request."));
+ if (this.owner && message.body[1] === this.owner) this.portalGone();
return;
}
if (!this.owner || message.sender !== this.owner) return;
@@ -270,7 +302,8 @@ export class PortalCaptureShortcut {
const response = new Promise((done) => {
resolve = done;
});
- this.pending = { path: expectedPath, resolve };
+ const pending = { path: expectedPath, resolve };
+ this.pending = pending;
this.responses.clear();
let completed = false;
try {
@@ -284,7 +317,7 @@ export class PortalCaptureShortcut {
});
const handle = string(reply.body[0]);
if (!handle.startsWith(this.namespace)) throw new Error("Invalid shortcut request handle.");
- this.pending.path = handle;
+ pending.path = handle;
if (this.responses.has(handle)) resolve(this.responses.get(handle));
const [status, results] = decodeResponse(
await this.wait(response, member === "BindShortcuts" ? 120_000 : 5_000),
@@ -306,9 +339,11 @@ export class PortalCaptureShortcut {
}
return results;
} finally {
- if (!completed && !this.closed && this.pending) this.closeObject(this.pending.path, REQUEST);
- this.pending = undefined;
- this.responses.clear();
+ if (this.pending === pending) {
+ if (!completed && !this.closed) this.closeObject(pending.path, REQUEST);
+ this.pending = undefined;
+ this.responses.clear();
+ }
}
}
@@ -338,8 +373,8 @@ export class PortalCaptureShortcut {
});
}
- private async initialize(appId: string, shortcut: SnapShotKeyChord) {
- const trigger = portalShortcutTrigger(shortcut);
+ private async initialize(first: boolean) {
+ const trigger = this.trigger;
if (!process.env.FLATPAK_ID && !process.env.SNAP) {
await this.call({
destination: PORTAL,
@@ -347,7 +382,7 @@ export class PortalCaptureShortcut {
interface: "org.freedesktop.host.portal.Registry",
member: "Register",
signature: "sa{sv}",
- body: [appId, {}],
+ body: [this.appId, {}],
}).catch((error: unknown) => {
if (
!(
@@ -381,7 +416,11 @@ export class PortalCaptureShortcut {
this.version = decodeVersion(version.body[0]).value;
for (const rule of [
`type='signal',sender='${this.owner}',path_namespace='${PATH}'`,
- `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`,
+ ...(first
+ ? [
+ `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`,
+ ]
+ : []),
])
await this.call({
destination: DBUS,
diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts
index 7b480b36fbd12..d47984e2bcb59 100644
--- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts
+++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts
@@ -64,6 +64,7 @@ export class DesktopSshEnvironment extends Context.Service<
readonly disconnectEnvironment: (
target: DesktopSshEnvironmentTarget,
) => Effect.Effect;
+ readonly closeAllTunnels: Effect.Effect;
}
>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}
@@ -156,6 +157,7 @@ export const make = Effect.gen(function* () {
Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt),
Effect.provide(runtimeContext),
),
+ closeAllTunnels: manager.closeAllTunnels,
});
});
diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
index 68002f826d421..15c481f488dbe 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -655,7 +655,11 @@ describe("DesktopUpdates", () => {
const result = yield* updates.install;
assert.isTrue(result.accepted);
assert.isFalse(yield* Ref.get(desktopState.quitting));
- assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
+ assert.deepEqual(harness.installSteps, [
+ "closeSshTunnels",
+ "quitAndInstall",
+ "startBackend",
+ ]);
assert.equal(harness.updateRestartMarkers.size, 0);
assert.equal(harness.installRecords.size, 0);
}),
@@ -685,7 +689,7 @@ describe("DesktopUpdates", () => {
const result = yield* updates.install;
assert.equal(result.state.errorContext, "install");
- assert.deepEqual(harness.installSteps, ["quitAndInstall"]);
+ assert.deepEqual(harness.installSteps, ["closeSshTunnels", "quitAndInstall"]);
assert.isFalse((yield* (yield* pool.primary).snapshot).desiredRunning);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
@@ -779,7 +783,11 @@ describe("DesktopUpdates", () => {
assert.equal(harness.quitAndInstalls(), 1);
harness.emit("error", new Error("duplicate native installer error"));
yield* flushCallbacks;
- assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
+ assert.deepEqual(harness.installSteps, [
+ "closeSshTunnels",
+ "quitAndInstall",
+ "startBackend",
+ ]);
yield* Deferred.succeed(releaseRecovery, undefined);
const failedResult = yield* Fiber.join(failedInstall);
diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts
index 0f52ec249c2c4..077cad6161479 100644
--- a/apps/desktop/src/updates/DesktopUpdates.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.ts
@@ -35,6 +35,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as IpcChannels from "../ipc/channels.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
+import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts";
import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts";
import {
normalizeDesktopUpdateReleaseNotes,
@@ -369,6 +370,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean
export const make = Effect.gen(function* () {
const config = yield* DesktopConfig.DesktopConfig;
const pool = yield* DesktopBackendPool.DesktopBackendPool;
+ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
const desktopState = yield* DesktopState.DesktopState;
const electronUpdater = yield* ElectronUpdater.ElectronUpdater;
const electronWindow = yield* ElectronWindow.ElectronWindow;
@@ -816,17 +818,23 @@ export const make = Effect.gen(function* () {
yield* writeUpdateRestartMarker;
const { downloadedVersion } = yield* Ref.get(updateStateRef);
if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion);
- // Stop pooled backends before quitAndInstall can outrun their scope finalizers.
+ // Stop pooled backends and SSH tunnels before quitAndInstall can outrun their scope finalizers.
const instances = yield* pool.list;
// stop clears desiredRunning, so retain the recovery set beforehand.
const runningInstances = yield* Effect.filter(instances, (instance) =>
instance.snapshot.pipe(Effect.map((snapshot) => snapshot.desiredRunning)),
);
yield* Ref.set(installRunningBackendsRef, runningInstances);
- yield* Effect.forEach(
- instances,
- (instance) =>
- instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }),
+ yield* Effect.all(
+ [
+ Effect.forEach(
+ instances,
+ (instance) =>
+ instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }),
+ { concurrency: "unbounded" },
+ ),
+ sshEnvironment.closeAllTunnels,
+ ],
{ concurrency: "unbounded" },
);
yield* electronUpdater.quitAndInstall({
diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts
index 6f05b1e9e94ab..a8fcc70ea355c 100644
--- a/apps/desktop/src/updates/releaseNotes.test.ts
+++ b/apps/desktop/src/updates/releaseNotes.test.ts
@@ -241,14 +241,20 @@ describe("normalizeDesktopUpdateReleaseNotes", () => {
});
});
- it("does not throw on out-of-range numeric entities and keeps the literal", () => {
+ it("keeps unsupported entities literal, including out-of-range and Object.prototype names", () => {
const result = normalizeDesktopUpdateReleaseNotes(
- "- Broken entity ",
+ "- Broken entity \n- Mentions &constructor; and &toString;",
"1.0.0",
"latest",
);
expect(result).toEqual({
- releaseNotes: [{ version: "1.0.0", items: ["Broken entity "], totalItems: 1 }],
+ releaseNotes: [
+ {
+ version: "1.0.0",
+ items: ["Mentions &constructor; and &toString;", "Broken entity "],
+ totalItems: 2,
+ },
+ ],
omittedReleaseCount: 0,
});
});
diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts
index 715c1767e6a04..30bfb9ace64b8 100644
--- a/apps/desktop/src/updates/releaseNotes.ts
+++ b/apps/desktop/src/updates/releaseNotes.ts
@@ -20,14 +20,14 @@ const MAX_RELEASE_NOTE_GROUPS = 6;
const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8;
const MAX_RELEASE_NOTE_ITEM_LENGTH = 220;
-const HTML_ENTITY_REPLACEMENTS: Readonly> = {
- amp: "&",
- apos: "'",
- gt: ">",
- lt: "<",
- nbsp: " ",
- quot: '"',
-};
+const HTML_ENTITY_REPLACEMENTS: ReadonlyMap = new Map([
+ ["amp", "&"],
+ ["apos", "'"],
+ ["gt", ">"],
+ ["lt", "<"],
+ ["nbsp", " "],
+ ["quot", '"'],
+]);
function decodeCodePoint(codePoint: number, entity: string): string {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
@@ -37,7 +37,7 @@ function decodeCodePoint(codePoint: number, entity: string): string {
}
function decodeHtmlEntity(entity: string): string {
- const named = HTML_ENTITY_REPLACEMENTS[entity];
+ const named = HTML_ENTITY_REPLACEMENTS.get(entity);
if (named) return named;
if (entity.startsWith("#x")) {
return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity);
diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts
index 54698130ab582..06f9e86a97d5c 100644
--- a/apps/desktop/src/updates/updatesTestHarness.ts
+++ b/apps/desktop/src/updates/updatesTestHarness.ts
@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
+import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts";
import * as DesktopUpdates from "./DesktopUpdates.ts";
@@ -136,6 +137,12 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
syncAllAppearance: () => Effect.void,
} satisfies ElectronWindow.ElectronWindow["Service"]);
+ const layerSshEnvironment = Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {
+ closeAllTunnels: Effect.sync(() => {
+ installSteps.push("closeSshTunnels");
+ }),
+ });
+
let backendDesiredRunning = options.backendDesiredRunning ?? true;
const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = {
id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
@@ -278,6 +285,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
Layer.provideMerge(layerUpdater),
Layer.provideMerge(layerWindow),
Layer.provideMerge(layerBackend),
+ Layer.provideMerge(layerSshEnvironment),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(layerSettings),
Layer.provideMerge(
diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
index d8b8c491f5224..2dbbad891ae1f 100644
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
@@ -16,6 +16,7 @@ import android.text.Layout
import android.text.Spanned
import android.text.TextUtils
import android.text.TextWatcher
+import android.text.method.Touch
import android.text.style.ReplacementSpan
import android.util.TypedValue
import android.view.DragEvent
@@ -636,6 +637,9 @@ internal class SelectionAwareEditText(
var textPasteThresholdBytes = 0
var maxInputChars = Int.MAX_VALUE
var clipboardFragment = ""
+ private var touchDownScrollX = 0
+ private var touchDownScrollY = 0
+ private var touchDownCaret = -1
// Keeps autofill services such as SMS verification codes from writing into the draft.
override fun getAutofillType(): Int = View.AUTOFILL_TYPE_NONE
@@ -807,6 +811,31 @@ internal class SelectionAwareEditText(
}
}
+ // Releasing a drag that scrolled the text makes ArrowKeyMovementMethod pull the caret into view.
+ // Finish Touch's drag and cancel the release instead, so the caret stays where it was.
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ touchDownScrollX = scrollX
+ touchDownScrollY = scrollY
+ touchDownCaret = if (selectionStart == selectionEnd) selectionStart else -1
+ }
+ val releasesScroll =
+ event.actionMasked == MotionEvent.ACTION_UP &&
+ event.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER &&
+ touchDownCaret >= 0 &&
+ selectionStart == touchDownCaret &&
+ selectionEnd == touchDownCaret &&
+ (scrollX != touchDownScrollX || scrollY != touchDownScrollY)
+ if (!releasesScroll) return super.onTouchEvent(event)
+ Touch.onTouchEvent(this, text, event)
+ val cancel = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL }
+ return try {
+ super.onTouchEvent(cancel)
+ } finally {
+ cancel.recycle()
+ }
+ }
+
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
selectionListener?.invoke(selStart, selEnd)
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 6287a05956519..d2577b07c004f 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -4,7 +4,7 @@ import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { requireNativeModule } from "expo";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { Platform, StatusBar } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
@@ -21,7 +21,7 @@ import {
import { pipe } from "effect/Function";
import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
-import { RegistryContext } from "@effect/atom-react";
+import { RegistryContext, useAtomValue } from "@effect/atom-react";
import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages";
import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
import * as Dialog from "./components/ui/Dialog";
@@ -50,7 +50,7 @@ import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./
import { appAtomRegistry } from "./state/atom-registry";
import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts";
import { environmentThreadShells, threadEnvironment } from "./state/threads";
-import { environmentShell } from "./state/shell";
+import { environmentShell, shellCachesReadAtom } from "./state/shell";
import { OverlayPortalHost } from "./components/ui/OverlayPortal";
import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking";
import { hasCloudPublicConfig } from "./features/cloud/publicConfig";
@@ -204,13 +204,28 @@ disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.ho
for (const dispose of appServiceDisposers) dispose();
});
+// Cached shells are local reads that end in a fraction of this time; the limit only keeps a
+// failed read from holding the splash screen.
+const SPLASH_SHELL_CACHE_WAIT_MS = 1_500;
+
function SplashScreenCoordinator() {
const { isReady } = useAppearancePreferences();
+ const shellCachesRead = useAtomValue(shellCachesReadAtom);
+ const [waitEnded, setWaitEnded] = useState(false);
+
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the limit on how long the native splash screen waits for cached shells
+ useEffect(() => {
+ const timer = setTimeout(() => setWaitEnded(true), SPLASH_SHELL_CACHE_WAIT_MS);
+ return () => clearTimeout(timer);
+ }, []);
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once appearance preferences have loaded
+ // The thread list paints every environment's cached rows at once when the splash hides
+ // after they are read.
+ const canHide = isReady && (shellCachesRead || waitEnded);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once preferences and cached shells have loaded
useEffect(() => {
- if (isReady) void SplashScreen.hide();
- }, [isReady]);
+ if (canHide) void SplashScreen.hide();
+ }, [canHide]);
return null;
}
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index d93bf076a78e0..b2ad65cf05374 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -493,11 +493,11 @@ const RootStackConfig = createWorkspaceStackNavigator({
Home: createNativeStackScreen({
screen: WorkspaceRootScreen,
linking: "",
- listeners: {
+ listeners: ({ navigation }) => ({
transitionStart: ({ data }) => recordHomeTransitionStart(data.closing),
- transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing),
+ transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing, navigation.isFocused()),
focus: recordHomeFocus,
- },
+ }),
options: {
...pageRoute({ duoMinimize: "never", root: true }),
contentStyle: { backgroundColor: "transparent" },
diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts
index 2f88de1432a08..fa6f4402890fc 100644
--- a/apps/mobile/src/connection/runtime.ts
+++ b/apps/mobile/src/connection/runtime.ts
@@ -40,6 +40,8 @@ const layerProvidedClientConnection = layerSnapshotLoader.pipe(
usageLimitSources: true,
usageLimitsCommand: true,
providerCatalogsOnDemand: true,
+ providerCatalogDigests: true,
+ providerStatusDeltas: true,
}),
),
Layer.provideMerge(
diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
index d83852f260466..b312eeb645346 100644
--- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
+++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
@@ -35,16 +35,33 @@ type BrowserPreviewRouteScreenProps = StaticScreenProps<{
export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) {
const navigation = useNavigation();
const onClose = () => navigation.goBack();
+ const { environmentId, threadId } = route.params;
+ if (!environmentId?.trim() || !threadId?.trim()) {
+ return ;
+ }
return (
);
}
+function BrowserUnavailableScreen({ onClose }: { readonly onClose: () => void }) {
+ const insets = useSafeAreaInsets();
+ const contentTopInset = useContentTopInset();
+ return (
+
+
+
+ );
+}
+
function BrowserPreviewScreen({
environmentId,
threadId,
diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx
index a2db3d14d45fd..78b4eca22a47d 100644
--- a/apps/mobile/src/features/home/HomeRouteScreen.tsx
+++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx
@@ -15,7 +15,7 @@ import {
} from "@t3tools/client-runtime/state/project-grouping";
import { useProjects } from "../../state/entities";
-import { environmentThreadShells } from "../../state/threads";
+import { steadyNavigationThreadShellsAtom } from "../../state/threads";
import { useLaunchingThreads, usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
@@ -47,7 +47,7 @@ export function HomeRouteScreen() {
// Stop rebuilding the streaming thread list while Thread covers Home.
const visible = useHomeRouteVisible();
const threads = useLaunchingThreads(
- useAtomValueWhileVisible(environmentThreadShells.navigationThreadShellsAtom, visible),
+ useAtomValueWhileVisible(steadyNavigationThreadShellsAtom, visible),
);
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx
index b720bbae033ac..dae399420944a 100644
--- a/apps/mobile/src/features/home/home-route-visibility.test.tsx
+++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx
@@ -4,7 +4,10 @@ import { act, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";
-import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
+import {
+ NativePrimaryColumnContext,
+ NativeWorkspaceTabActivityContext,
+} from "../../native/v5-workspace-context";
// The Thread in the detail column is the focused route on iPad split view.
vi.mock("@react-navigation/native", () => ({
@@ -12,6 +15,7 @@ vi.mock("@react-navigation/native", () => ({
}));
import {
+ recordHomeFocus,
recordHomeTransitionEnd,
recordHomeTransitionStart,
useAtomValueWhileVisible,
@@ -54,6 +58,18 @@ function setup() {
),
);
},
+ /** A phone has no primary column: Home is the root page of its tab's slot. */
+ showHomeInTabSlot(active: boolean) {
+ act(() =>
+ root.render(
+
+ active }}>
+
+
+ ,
+ ),
+ );
+ },
[Symbol.dispose]() {
act(() => root.unmount());
registry.dispose();
@@ -71,11 +87,51 @@ it("keeps the sidebar thread list live after switching tabs beside a focused Thr
// Chats → PRs → Chats: Chats' column will-appear, then the PRs column's did-disappear.
act(() => {
- recordHomeTransitionEnd(true);
+ recordHomeTransitionEnd(true, false);
recordHomeTransitionStart(false);
- recordHomeTransitionEnd(true);
+ recordHomeTransitionEnd(true, false);
});
act(() => home.registry.set(shellsAtom, 2));
expect(home.latest, "new thread shells reach the sidebar list").toBe(2);
});
+
+// Each phone tab slot shows its own copy of the Home route. PRs keeps Home focused; Settings
+// is a route of its own.
+it.each([
+ { tab: "PRs", homeFocusedUnderTab: true },
+ { tab: "Settings", homeFocusedUnderTab: false },
+])(
+ "keeps the phone thread list live after a round trip through $tab",
+ ({ homeFocusedUnderTab }) => {
+ using home = setup();
+ recordHomeFocus();
+ home.showHomeInTabSlot(true);
+ act(() => home.registry.set(shellsAtom, 1));
+ expect(home.latest).toBe(1);
+
+ // To the other tab: its slot will-appear, then the Chats slot's did-disappear.
+ act(() => {
+ recordHomeTransitionStart(false);
+ recordHomeTransitionEnd(true, homeFocusedUnderTab);
+ });
+ home.showHomeInTabSlot(false);
+ act(() => home.registry.set(shellsAtom, 2));
+ expect(home.latest, "the list under another tab holds its rows").toBe(1);
+
+ // Back to Chats: its slot will-appear, then the other slot's did-disappear comes last.
+ act(() => {
+ recordHomeFocus();
+ recordHomeTransitionStart(false);
+ recordHomeTransitionEnd(true, true);
+ });
+ home.showHomeInTabSlot(true);
+ act(() => home.registry.set(shellsAtom, 3));
+ expect(home.latest, "a server rename reaches the list").toBe(3);
+
+ // A pushed Thread takes the focus and still stops the list.
+ act(() => recordHomeTransitionEnd(true, false));
+ act(() => home.registry.set(shellsAtom, 4));
+ expect(home.latest, "the list under a Thread holds its rows").toBe(3);
+ },
+);
diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts
index 532053f8f8d07..7a8837b582a11 100644
--- a/apps/mobile/src/features/home/home-route-visibility.ts
+++ b/apps/mobile/src/features/home/home-route-visibility.ts
@@ -3,7 +3,10 @@ import { useNavigation } from "@react-navigation/native";
import { useContext, useRef, useSyncExternalStore } from "react";
import type { Atom } from "effect/reactivity";
-import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
+import {
+ NativePrimaryColumnContext,
+ NativeWorkspaceTabActivityContext,
+} from "../../native/v5-workspace-context";
/**
* Home stays mounted under a pushed Thread, and native-stack never freezes the
@@ -34,8 +37,12 @@ export function recordHomeTransitionStart(closing: boolean): void {
if (!closing) setHomeVisible(true);
}
-export function recordHomeTransitionEnd(closing: boolean): void {
- if (closing) setHomeVisible(false);
+/**
+ * Each iOS tab slot shows its own copy of the Home route, and the slot a tab switch leaves
+ * disappears last. So a disappear hides Home only when another route has the focus.
+ */
+export function recordHomeTransitionEnd(closing: boolean, homeFocused: boolean): void {
+ if (closing && !homeFocused) setHomeVisible(false);
}
/** Popping a sheet or a deep-linked route above Home has no appear event. */
@@ -46,10 +53,13 @@ export function recordHomeFocus(): void {
export function useHomeRouteVisible(): boolean {
const navigation = useNavigation();
const primaryColumn = useContext(NativePrimaryColumnContext);
+ const tabActive = useContext(NativeWorkspaceTabActivityContext)?.active ?? true;
const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible);
// Every tab slot's primary column reports to the one Home route, so a tab
// switch can end on the leaving slot's disappear. Trust this column instead.
if (primaryColumn) return primaryColumn.presented;
+ // The store is true for the Home route of every tab; this list shows in one of them.
+ if (!tabActive) return false;
// Before Home's first navigation event: a cold deep link mounts Home under
// its Thread without ever showing it.
return visible ?? navigation.isFocused();
diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts
index 18cd5363d3c0a..b0f0ce78d0a89 100644
--- a/apps/mobile/src/features/home/useThreadListActions.test.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.test.ts
@@ -16,6 +16,7 @@ const state = vi.hoisted(() => ({
connectedEnvironmentIds: [] as EnvironmentId[],
pendingOrder: null as object | null,
dropBusy: false,
+ holdingRows: false,
failedAction: null as string | null,
archivedRefreshes: [] as string[],
scopes: new Map>(),
@@ -78,26 +79,28 @@ vi.mock("../../state/atom-registry", () => ({
? state.connectedEnvironmentIds
: atom === "thread-drop-busy"
? state.dropBusy
- : atom === "thread-shells"
- ? state.shells
- : atom === "queued-thread-keys"
- ? new Set()
- : new Map(
- [...state.scopes.keys()].map((environmentId) => [
- environmentId,
- {
- environment: {
- capabilities: {
- threadSettlement: true,
- threadSnooze: true,
- threadPinning: true,
- threadPinReorder: true,
- threadTitleRegeneration: true,
+ : atom === "steady-shells-holding"
+ ? state.holdingRows
+ : atom === "thread-shells"
+ ? state.shells
+ : atom === "queued-thread-keys"
+ ? new Set()
+ : new Map(
+ [...state.scopes.keys()].map((environmentId) => [
+ environmentId,
+ {
+ environment: {
+ capabilities: {
+ threadSettlement: true,
+ threadSnooze: true,
+ threadPinning: true,
+ threadPinReorder: true,
+ threadTitleRegeneration: true,
+ },
},
},
- },
- ]),
- ),
+ ]),
+ ),
},
}));
vi.mock("../../state/use-atom-command", () => ({
@@ -121,10 +124,9 @@ vi.mock("../../state/thread-order", () => ({
},
}));
vi.mock("../../state/threads", () => ({
- environmentThreadShells: {
- threadShellsAtom: "thread-shells",
- navigationThreadShellsAtom: "thread-shells",
- },
+ environmentThreadShells: { threadShellsAtom: "thread-shells" },
+ steadyNavigationThreadShellsAtom: "thread-shells",
+ steadyShellsHoldingAtom: "steady-shells-holding",
threadEnvironment: Object.fromEntries(
[
"archive",
@@ -193,6 +195,7 @@ beforeEach(() => {
state.archivedRefreshes = [];
state.pendingOrder = null;
state.dropBusy = false;
+ state.holdingRows = false;
state.scopes = new Map([
[primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])],
[otherEnvironmentId, new Set()],
@@ -274,6 +277,12 @@ describe("pinned thread operation permissions", () => {
}),
];
+ // Rows that wait to show can hold order keys the server has replaced.
+ state.holdingRows = true;
+ expect(await useThreadListActions().moveThread(moved, "up")).toBe(false);
+ expect(state.requests).toEqual([]);
+ state.holdingRows = false;
+
expect(await useThreadListActions().moveThread(moved, "up")).toBe(true);
expect(state.requests).toEqual([
expect.objectContaining({ action: "reorderPin", environmentId: primaryEnvironmentId }),
@@ -494,6 +503,25 @@ describe("thread action undo", () => {
},
);
+ it.each(["settleThread", "unpinThread"] as const)(
+ "%s plans its undo from the live row when the list holds an older one",
+ async (handler) => {
+ const held = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", pinOrderKey: "m" });
+ const live = makeThread({
+ pinnedAt: "2026-09-02T00:00:00.000Z",
+ pinOrderKey: "t",
+ snoozedUntil: "2099-01-01T09:00:00.000Z",
+ });
+ state.shells = [live];
+ await useThreadListActions()[handler](held);
+ await readThreadUndoNotice()!.undo();
+ expect(state.requests.slice(-2)).toMatchObject([
+ { action: "pin", input: { threadId: live.id, orderKey: "t" } },
+ { action: "snooze", input: { threadId: live.id, snoozedUntil: live.snoozedUntil } },
+ ]);
+ },
+ );
+
// regression: #21401 restore archive and thread action undo
it("does not restore snooze if undoing an unpin fails", async () => {
const thread = makeThread({
@@ -570,5 +598,10 @@ describe("thread archive guard", () => {
expect(state.requests).toEqual([]);
expect(state.archivedRefreshes).toEqual([]);
expect(state.alerts.at(-1)?.title).toBe("Could not archive thread");
+
+ // A list can hold a row from before the run started.
+ state.shells = [thread];
+ expect(await useThreadListActions().archiveThread(makeThread())).toBe(false);
+ expect(state.requests).toEqual([]);
});
});
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index 9d44d971c1f03..aaba12dd8f419 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -17,7 +17,12 @@ import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { connectedEnvironmentIdsAtom } from "../../state/presentation";
import { readEnvironmentScope } from "../../state/session";
-import { environmentThreadShells, threadEnvironment } from "../../state/threads";
+import {
+ environmentThreadShells,
+ steadyNavigationThreadShellsAtom,
+ steadyShellsHoldingAtom,
+ threadEnvironment,
+} from "../../state/threads";
import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
import { useAtomCommand } from "../../state/use-atom-command";
import {
@@ -83,6 +88,19 @@ function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: s
return false;
}
+/**
+ * The live row of a thread that a list passes to an action. A list holds its rows while an
+ * environment synchronizes (`steadyNavigationThreadShellsAtom`), so its row can be older than
+ * the client's data: an undo plan or a guard read from it would use replaced state.
+ */
+function liveThread(thread: EnvironmentThreadShell): EnvironmentThreadShell {
+ return (
+ appAtomRegistry
+ .get(environmentThreadShells.threadShellsAtom)
+ .find((row) => row.id === thread.id && row.environmentId === thread.environmentId) ?? thread
+ );
+}
+
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
) {
@@ -572,7 +590,10 @@ export function useThreadListActions(): {
const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
if (!checkThreadOperationPermission(thread, "Could not move thread")) return false;
if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;
- const shells = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom);
+ // The rows on screen can be older than the server's while they wait to show. A move
+ // planned from them could act on a pin or an order key that the server has replaced.
+ if (appAtomRegistry.get(steadyShellsHoldingAtom)) return false;
+ const shells = appAtomRegistry.get(steadyNavigationThreadShellsAtom);
const current = shells.find(
(row) => row.id === thread.id && row.environmentId === thread.environmentId,
);
@@ -708,19 +729,20 @@ export function useThreadListActions(): {
const confirmDeleteThread = (thread: EnvironmentThreadShell) =>
confirmThreadDeletion(executeAction, thread);
+ // `moveThread` looks up its own row, and refuses while a list holds older rows.
return {
- archiveThread,
- confirmDeleteThread,
- settleThread,
- snoozeThread,
- unsnoozeThread,
- unsettleThread,
- pinThread,
- unpinThread,
- setThreadAutoSettle,
+ archiveThread: (thread) => archiveThread(liveThread(thread)),
+ confirmDeleteThread: (thread) => confirmDeleteThread(liveThread(thread)),
+ settleThread: (thread) => settleThread(liveThread(thread)),
+ snoozeThread: (thread, snoozedUntil) => snoozeThread(liveThread(thread), snoozedUntil),
+ unsnoozeThread: (thread) => unsnoozeThread(liveThread(thread)),
+ unsettleThread: (thread) => unsettleThread(liveThread(thread)),
+ pinThread: (thread) => pinThread(liveThread(thread)),
+ unpinThread: (thread) => unpinThread(liveThread(thread)),
+ setThreadAutoSettle: (thread, enabled) => setThreadAutoSettle(liveThread(thread), enabled),
moveThread,
- renameThread,
- regenerateThreadTitle,
+ renameThread: (thread) => renameThread(liveThread(thread)),
+ regenerateThreadTitle: (thread) => regenerateThreadTitle(liveThread(thread)),
};
}
diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
index f53541ceeaaef..13f0bbde955ee 100644
--- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
+++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
@@ -20,6 +20,7 @@ import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachine
import { GlassSurface } from "../../components/ui/GlassSurface";
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
import { ProviderIcon } from "../../components/ProviderIcon";
+import { cn } from "../../lib/cn";
import type { ModelOption } from "../../lib/modelOptions";
import { pullRequestStatus } from "../pull-requests/pullRequestStatus";
export type ComposerCommandItem =
@@ -162,7 +163,7 @@ function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean):
case "pull-request":
return "No matching pull requests.";
case "path":
- return "No matching files or folders.";
+ return "No matching mentions.";
case "skill":
return "No skills found.";
case "slash-command":
@@ -207,7 +208,13 @@ const CommandRow = memo(function CommandRow(props: {
type="monochrome"
/>
) : null}
-
+
{props.isSlashSkill && props.item.type === "skill" ? (
<>
skill:
diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
index de3041c06b57e..747b9a9fc1867 100644
--- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
+++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
@@ -56,6 +56,41 @@ function descendsFrom(
/** Resolve apps from stored tool calls so links cannot supply apps the call never produced. */
export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
+ const navigation = useNavigation();
+ const { environmentId, threadId, itemId } = route.params;
+ if (!environmentId?.trim() || !threadId?.trim() || !itemId?.trim()) {
+ return (
+
+ navigation.goBack()}
+ closeAccessibilityLabel="Exit full screen"
+ />
+ This app cannot be shown.
+
+ );
+ }
+ return (
+
+ );
+}
+
+function McpAppFullscreen({
+ params,
+ environmentId,
+ threadId,
+ itemId,
+}: {
+ readonly params: McpAppFullscreenScreenProps["route"]["params"];
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly itemId: TurnItemId;
+}) {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
// Wait for focus before goBack; an exit under another screen must not pop that screen.
@@ -74,10 +109,6 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
});
};
const [size, setSize] = useState({ width: 0, height: 0 });
- const params = route.params;
- const environmentId = EnvironmentId.make(params.environmentId);
- const threadId = ThreadId.make(params.threadId);
- const itemId = TurnItemId.make(params.itemId);
const conversationThreadId = useConversationThreadId(
environmentId,
threadId,
diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
index 2ebbad63577e6..1b4614a81f472 100644
--- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
@@ -18,7 +18,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
import { deriveThreadArrangementSizing } from "../../lib/layout";
import { environmentServerConfigsAtom } from "../../state/server";
import { threadListEnvironmentsAtom } from "../../state/presentation";
-import { environmentThreadShells } from "../../state/threads";
+import { steadyNavigationThreadShellsAtom, steadyShellsHoldingAtom } from "../../state/threads";
import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf";
import {
@@ -170,12 +170,13 @@ function ThreadArrangementSheet(props: { onClose: () => void }) {
baseFontSize: appearance.baseFontSize,
fontScale,
});
- const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom);
+ const threads = useAtomValue(steadyNavigationThreadShellsAtom);
const configs = useAtomValue(environmentServerConfigsAtom);
const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom);
const pendingOrder = useAtomValue(pendingThreadOrderAtom);
const dropBusy = useAtomValue(threadDropBusyAtom);
+ const holdingRows = useAtomValue(steadyShellsHoldingAtom);
const { moveThread } = useThreadListActions();
const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
const now = useThreadListNow(threads);
@@ -489,6 +490,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) {
disabled={
dropBusy ||
pendingOrder !== null ||
+ holdingRows ||
(!listEnvironments.pinReorderEnvironmentIds.has(
thread.environmentId,
) &&
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 00d56cd47ffcb..dbbcaa1fd9cff 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -233,9 +233,10 @@ import { ThreadMcpApp } from "./McpAppWebView";
import { assetEnvironment, useAssetUrl, useRefreshAssetUrl } from "../../state/assets";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
import { readEnvironmentScope, usePreparedConnection } from "../../state/session";
-import { projectEnvironment } from "../../state/projects";
+import { environmentProjects, projectEnvironment } from "../../state/projects";
import {
needsWorkspaceBasenameLookup,
+ pickProjectLinkMatch,
pickWorkspaceLinkMatch,
WORKSPACE_BASENAME_LOOKUP_LIMIT,
workspaceLinkLookupQuery,
@@ -260,6 +261,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { useV2ItemSupport } from "../../state/v2-item-support";
import {
fileRoutePathSegments,
+ isAbsolutePath,
resolveFileLinkPath,
resolveWorkspaceFilePath,
resolveWorkspaceRelativeFilePath,
@@ -1714,6 +1716,7 @@ function renderFeedEntry(
rowSizing={props.workRowSizing}
iconSubtleColor={iconSubtleColor}
label={entry.label}
+ providerStartingLabel={entry.providerStartingLabel}
/>
);
}
@@ -2837,6 +2840,85 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// A v1 link names no environment, so it falls back to this message's.
return scopeThreadRef(props.environmentId, link.threadId);
};
+ // The file a chat link names: in this workspace, else in the one other project that has it.
+ const findFileLinkTarget = async (
+ linkPath: string,
+ path: string,
+ signal: AbortSignal,
+ ): Promise => {
+ const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path);
+ if (
+ !props.workspaceRoot ||
+ !relativePath ||
+ /[\\/]$/.test(linkPath) ||
+ workspaceLinkLookupQuery(linkPath, props.workspaceRoot) === null ||
+ !readEnvironmentScope(props.environmentId, AuthFilesystemReadScope)
+ ) {
+ return relativePath ?? path;
+ }
+ const workspaceRoot = props.workspaceRoot;
+ const originalPath = resolveWorkspaceFilePath(workspaceRoot, relativePath);
+ const queryOptions = { signal, reportFailure: false, reportDefect: false };
+ const searchRoot = (root: string) =>
+ executeAtomQuery(
+ appAtomRegistry,
+ projectEnvironment.searchEntries({
+ environmentId: props.environmentId,
+ input: {
+ cwd: root,
+ query: workspaceLinkLookupQuery(relativePath, root) ?? relativePath,
+ kind: "file",
+ limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
+ },
+ }),
+ queryOptions,
+ ).then((result) => ({
+ root,
+ entries: result._tag === "Success" ? result.value.entries : [],
+ }));
+ const lookup = async () => {
+ const own = await searchRoot(workspaceRoot);
+ let match = pickWorkspaceLinkMatch(relativePath, workspaceRoot, own.entries);
+ const otherProject = match === null;
+ if (otherProject) {
+ const threadProjectId = appAtomRegistry.get(
+ environmentThreadShells.threadShellAtom(
+ scopeThreadRef(props.environmentId, props.threadId),
+ ),
+ )?.projectId;
+ const otherRoots = appAtomRegistry
+ .get(environmentProjects.projectsAtom)
+ .flatMap((project) =>
+ project.environmentId === props.environmentId &&
+ project.id !== threadProjectId &&
+ project.workspaceRoot !== workspaceRoot
+ ? [project.workspaceRoot]
+ : [],
+ );
+ match =
+ otherRoots.length === 0
+ ? null
+ : pickProjectLinkMatch(relativePath, await Promise.all(otherRoots.map(searchRoot)));
+ }
+ if (!match || match === relativePath || signal.aborted) return relativePath;
+ const metadata = await executeAtomQuery(
+ appAtomRegistry,
+ projectEnvironment.fileMetadata({
+ environmentId: props.environmentId,
+ input: { path: originalPath },
+ }),
+ queryOptions,
+ );
+ // Another project's file never replaces a thread-local file the search missed.
+ return metadata._tag === "Success" &&
+ (needsWorkspaceBasenameLookup(relativePath) && !otherProject
+ ? metadata.value?.kind !== "directory"
+ : metadata.value === null)
+ ? match
+ : relativePath;
+ };
+ return lookup().catch(() => relativePath);
+ };
const onMarkdownLinkPress = (href: string, imageEmbed = false) => {
markdownLinkRequest.current?.abort();
const request = new AbortController();
@@ -2904,8 +2986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
if (presentation.kind === "file") {
const path = resolveFileLinkPath(props.workspaceRoot, presentation.path);
if (path === null) return;
- const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path);
- const openAt = (target: string) => {
+ void findFileLinkTarget(presentation.path, path, request.signal).then((target) => {
if (request.signal.aborted) return;
void Haptics.selectionAsync();
navigation.navigate("ThreadFile", {
@@ -2914,62 +2995,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
path: fileRoutePathSegments(target),
...(presentation.line ? { line: String(presentation.line) } : {}),
});
- };
- if (
- props.workspaceRoot &&
- relativePath &&
- !/[\\/]$/.test(presentation.path) &&
- workspaceLinkLookupQuery(presentation.path, props.workspaceRoot) !== null &&
- readEnvironmentScope(props.environmentId, AuthFilesystemReadScope)
- ) {
- const workspaceRoot = props.workspaceRoot;
- const originalPath = resolveWorkspaceFilePath(props.workspaceRoot, relativePath);
- void executeAtomQuery(
- appAtomRegistry,
- projectEnvironment.searchEntries({
- environmentId: props.environmentId,
- input: {
- cwd: workspaceRoot,
- query: workspaceLinkLookupQuery(relativePath, workspaceRoot) ?? relativePath,
- kind: "file",
- limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
- },
- }),
- { signal: request.signal, reportFailure: false, reportDefect: false },
- ).then(
- (result) => {
- const match =
- result._tag === "Success"
- ? pickWorkspaceLinkMatch(relativePath, workspaceRoot, result.value.entries)
- : null;
- if (!match || match === relativePath) {
- openAt(relativePath);
- return;
- }
- if (request.signal.aborted) return;
- void executeAtomQuery(
- appAtomRegistry,
- projectEnvironment.fileMetadata({
- environmentId: props.environmentId,
- input: { path: originalPath },
- }),
- { signal: request.signal, reportFailure: false, reportDefect: false },
- ).then(
- (metadata) =>
- openAt(
- metadata._tag === "Success" &&
- (needsWorkspaceBasenameLookup(relativePath)
- ? metadata.value?.kind !== "directory"
- : metadata.value === null)
- ? match
- : relativePath,
- ),
- () => openAt(relativePath),
- );
- },
- () => openAt(relativePath),
- );
- } else openAt(relativePath ?? path);
+ });
return;
}
@@ -3051,8 +3077,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
switch (actionId) {
case "copy-full-path":
case "copy-relative-path": {
- const clipboardPath = fileChipClipboardPath(target, actionId);
- if (clipboardPath !== undefined) copyTextWithHaptic(clipboardPath);
+ const presentation = resolveMarkdownLinkPresentation(href);
+ const path =
+ presentation.kind === "file"
+ ? resolveFileLinkPath(props.workspaceRoot, presentation.path)
+ : null;
+ if (presentation.kind !== "file" || path === null) return;
+ markdownLinkRequest.current?.abort();
+ const request = new AbortController();
+ markdownLinkRequest.current = request;
+ void findFileLinkTarget(presentation.path, path, request.signal).then((found) => {
+ if (request.signal.aborted) return;
+ const fullPath =
+ props.workspaceRoot && !isAbsolutePath(found)
+ ? resolveWorkspaceFilePath(props.workspaceRoot, found)
+ : found;
+ const clipboardPath = fileChipClipboardPath(
+ {
+ ...target,
+ ...(target.fullPath ? { fullPath } : {}),
+ ...(target.relativePath
+ ? {
+ relativePath:
+ resolveWorkspaceRelativeFilePath(props.workspaceRoot, fullPath) ??
+ target.relativePath,
+ }
+ : {}),
+ },
+ actionId,
+ );
+ if (clipboardPath !== undefined) copyTextWithHaptic(clipboardPath);
+ });
return;
}
case "open-file":
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 1f0a84a71154b..e1c51b7f524b5 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -27,7 +27,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/ui/AppText";
import { Button } from "../../components/ui/Button";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
-import { useProjects, useNavigationThreadShells } from "../../state/entities";
+import { useProjects } from "../../state/entities";
+import { steadyNavigationThreadShellsAtom } from "../../state/threads";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { useThreadListNow } from "./use-thread-list-now";
@@ -121,7 +122,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
const insets = useSafeAreaInsets();
const { fabClearance, fabSize } = useAndroidControlSizing();
const projects = useProjects();
- const threads = useNavigationThreadShells();
+ const threads = useAtomValue(steadyNavigationThreadShellsAtom);
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
const openSwipeableRef = useRef(null);
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index fc72af2f06b0f..e1a2b71fde5be 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -1416,8 +1416,10 @@ export function ThreadThinkingRow(props: {
readonly rowSizing: ReturnType;
readonly iconSubtleColor: ColorValue;
readonly label?: string;
+ /** Replaces "Thinking" while the provider has not started a turn. */
+ readonly providerStartingLabel?: string | undefined;
}) {
- const label = props.label ?? "Thinking";
+ const label = props.providerStartingLabel ?? props.label ?? "Thinking";
return (
();
@@ -1846,11 +1855,17 @@ function isWorkLogFeedRow(row: ThreadFeedEntry | undefined): boolean {
const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
-function thinkingRow(createdAt: string, runId: RunId | null, label: string | null) {
+function thinkingRow(
+ createdAt: string,
+ runId: RunId | null,
+ label: string | null,
+ providerStartingLabel: string | undefined,
+) {
if (
cachedThinkingRow?.createdAt !== createdAt ||
cachedThinkingRow.runId !== runId ||
- (cachedThinkingRow.label ?? null) !== label
+ (cachedThinkingRow.label ?? null) !== label ||
+ cachedThinkingRow.providerStartingLabel !== providerStartingLabel
) {
cachedThinkingRow = {
type: "thinking",
@@ -1858,6 +1873,7 @@ function thinkingRow(createdAt: string, runId: RunId | null, label: string | nul
createdAt,
runId,
...(label === null ? {} : { label }),
+ ...(providerStartingLabel === undefined ? {} : { providerStartingLabel }),
};
}
return cachedThinkingRow;
diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts
index b6659a2e98283..576460b982334 100644
--- a/apps/mobile/src/state/server.ts
+++ b/apps/mobile/src/state/server.ts
@@ -14,6 +14,9 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
usageLimitSources: true,
usageLimitsCommand: true,
providerCatalogsOnDemand: true,
+ providerCatalogDigests: true,
+ providerStatusDeltas: true,
+ omitRefreshedProviders: true,
});
export const describeOutdatedServer = createOutdatedServerDescriptionCommand(connectionAtomRuntime);
export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime);
diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts
index deb0e2e59fd04..8f92c7c1a42c7 100644
--- a/apps/mobile/src/state/shell.ts
+++ b/apps/mobile/src/state/shell.ts
@@ -1,3 +1,4 @@
+import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections";
import {
createEnvironmentShellAtoms,
createEnvironmentShellSummaryAtom,
@@ -27,6 +28,25 @@ const shellHasError = (state: EnvironmentShellState) => Option.isSome(state.erro
// The empty placeholder stands until the environment has read its cached shell.
const shellCacheRead = (state: EnvironmentShellState) => state !== EMPTY_SHELL_STATE;
+const shellCacheReadAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make((get) => shellCacheRead(get(environmentShell.stateValueAtom(environmentId)))),
+);
+
+/**
+ * True once every enabled environment has read its cached shell. A list that paints before
+ * then gets the cached rows of one environment at a time.
+ */
+export const shellCachesReadAtom = Atom.make((get) => {
+ const catalog = get(environmentCatalog.catalogValueAtom);
+ if (!catalog.isReady) return false;
+ // Reads every environment, so each one loads its cache now and not after the one before it.
+ let allRead = true;
+ for (const environmentId of enabledEnvironmentIds(catalog)) {
+ if (!get(shellCacheReadAtom(environmentId))) allRead = false;
+ }
+ return allRead;
+}).pipe(Atom.withLabel("mobile-shell-caches-read"));
+
export function useEnvironmentShellReadiness(environmentId: EnvironmentId | null) {
const atom =
environmentId === null
diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts
index 032744c75d641..d51370b4abc69 100644
--- a/apps/mobile/src/state/thread-order.test.ts
+++ b/apps/mobile/src/state/thread-order.test.ts
@@ -12,7 +12,7 @@ import {
pendingThreadOrderAtom,
} from "./thread-order";
import { environmentServerConfigsAtom } from "./server";
-import { environmentThreadShells } from "./threads";
+import { steadyNavigationThreadShellsAtom } from "./threads";
vi.mock("./atom-registry", async () => {
const { AtomRegistry } = await import("effect/reactivity");
@@ -21,7 +21,7 @@ vi.mock("./atom-registry", async () => {
vi.mock("./threads", async () => {
const { Atom } = await import("effect/reactivity");
return {
- environmentThreadShells: { navigationThreadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) },
+ steadyNavigationThreadShellsAtom: Atom.make([]).pipe(Atom.keepAlive),
};
});
vi.mock("./server", async () => {
@@ -36,7 +36,7 @@ vi.mock("./use-thread-outbox", async () => {
type ServerConfigs = Atom.Type;
type ServerConfig = ServerConfigs extends ReadonlyMap ? Config : never;
const configsAtom = environmentServerConfigsAtom as Atom.Writable;
-const shellsAtom = environmentThreadShells.navigationThreadShellsAtom as Atom.Writable<
+const shellsAtom = steadyNavigationThreadShellsAtom as Atom.Writable<
readonly EnvironmentThreadShell[],
readonly EnvironmentThreadShell[]
>;
diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts
index 41a4671a8f530..14e8f6505b5fc 100644
--- a/apps/mobile/src/state/thread-order.ts
+++ b/apps/mobile/src/state/thread-order.ts
@@ -12,7 +12,7 @@ import {
} from "../features/threads/threadListV2";
import { appAtomRegistry } from "./atom-registry";
import { environmentServerConfigsAtom } from "./server";
-import { environmentThreadShells } from "./threads";
+import { steadyNavigationThreadShellsAtom } from "./threads";
import { queuedThreadKeysAtom } from "./use-thread-outbox";
export const threadArrangementOpenAtom = Atom.make(false).pipe(Atom.keepAlive);
@@ -64,7 +64,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) {
const current = appAtomRegistry.get(pendingThreadOrderAtom);
if (current === null) return;
const configs = appAtomRegistry.get(environmentServerConfigsAtom);
- const threads = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom);
+ const threads = appAtomRegistry.get(steadyNavigationThreadShellsAtom);
const nowMs = Date.now();
const ordered = getThreadListV2OrderedSection({
threads,
@@ -87,17 +87,12 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) {
teardownPendingOrder = teardown;
appAtomRegistry.set(pendingThreadOrderAtom, pending);
unsubscribers.push(
- appAtomRegistry.subscribe(environmentThreadShells.navigationThreadShellsAtom, refresh),
+ appAtomRegistry.subscribe(steadyNavigationThreadShellsAtom, refresh),
appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh),
minuteTick.subscribe(refresh),
);
- armWake(
- nextSnoozeWakeAfter(
- appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom),
- Date.now(),
- ),
- );
+ armWake(nextSnoozeWakeAfter(appAtomRegistry.get(steadyNavigationThreadShellsAtom), Date.now()));
return {
isPending: () => {
refresh();
diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts
index d6e784377cca6..bf2013aef5398 100644
--- a/apps/mobile/src/state/threads.ts
+++ b/apps/mobile/src/state/threads.ts
@@ -10,6 +10,7 @@ import {
createThreadEnvironmentAtoms,
createThreadFamilyAtoms,
} from "@t3tools/client-runtime/state/threads";
+import { createSteadyShellAtoms } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/reactivity";
@@ -46,6 +47,23 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({
snapshotAtom: threadEnvironment.snapshotAtom,
hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom,
});
+const steadyShells = createSteadyShellAtoms({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ snapshotAtom: threadEnvironment.snapshotAtom,
+ shellStateAtom: environmentShell.stateValueAtom,
+ rowActionsAtom: threadEnvironment.rowActionsAtom,
+ connectionStateAtom: environmentCatalog.stateAtom,
+});
+/**
+ * The threads that lists arrange. Rows wait while their environment synchronizes, so a list
+ * changes once when environments connect, not once per environment.
+ */
+export const steadyShellsHoldingAtom = steadyShells.holdingAtom;
+export const steadyNavigationThreadShellsAtom = createEnvironmentThreadShellAtoms({
+ catalogValueAtom: environmentCatalog.catalogValueAtom,
+ snapshotAtom: steadyShells.snapshotAtom,
+ hiddenThreadTagsAtom: serverEnvironment.hiddenThreadTagsAtom,
+}).navigationThreadShellsAtom;
export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({
childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom,
pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom,
diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.test.ts b/apps/mobile/src/widgets/useSubscriptionUsage.test.ts
index ad4fb5cbbdeb2..820ec68571896 100644
--- a/apps/mobile/src/widgets/useSubscriptionUsage.test.ts
+++ b/apps/mobile/src/widgets/useSubscriptionUsage.test.ts
@@ -1,4 +1,5 @@
import { EnvironmentId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
import { Atom, AtomRegistry } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";
@@ -38,6 +39,18 @@ vi.mock("../state/presentation", async () => {
};
});
+vi.mock("../state/shell", async () => {
+ const { Atom } = await import("effect/reactivity");
+ const Option = await import("effect/Option");
+ return {
+ environmentShell: {
+ stateValueAtom: Atom.family((_id: string) =>
+ Atom.make({ status: "synchronizing", error: Option.none() }),
+ ),
+ },
+ };
+});
+
vi.mock("../state/server", () => ({
serverEnvironment: { refreshProviders: { label: "Refresh providers" } },
}));
@@ -56,6 +69,7 @@ vi.mock("@t3tools/client-runtime/state/runtime", () => ({
import { environmentCatalog } from "../connection/catalog";
import { appAtomRegistry } from "../state/atom-registry";
import { environmentPresentations } from "../state/presentation";
+import { environmentShell } from "../state/shell";
import { installSubscriptionUsageRefresh } from "./useSubscriptionUsage";
const registry = appAtomRegistry as AtomRegistry.AtomRegistry;
@@ -66,8 +80,16 @@ const presentationsAtom = environmentPresentations.presentationsAtom as unknown
Map
>;
+const shellAtom = (environmentId: EnvironmentId) =>
+ environmentShell.stateValueAtom(environmentId) as unknown as Atom.Writable<{
+ status: string;
+ error: Option.Option;
+ }>;
+
const connected = EnvironmentId.make("env-connected");
const connecting = EnvironmentId.make("env-connecting");
+const syncing = EnvironmentId.make("env-syncing");
+const failed = EnvironmentId.make("env-failed");
describe("installSubscriptionUsageRefresh", () => {
it("refreshes connected environments once the catalog is ready and the app is active", () => {
@@ -79,8 +101,13 @@ describe("installSubscriptionUsageRefresh", () => {
new Map([
[connected, { connection: { phase: "connected" } }],
[connecting, { connection: { phase: "connecting" } }],
+ [syncing, { connection: { phase: "connected" } }],
+ [failed, { connection: { phase: "connected" } }],
]),
);
+ registry.set(shellAtom(connected), { status: "live", error: Option.none() });
+ // A shell that failed never goes live; its quotas still refresh.
+ registry.set(shellAtom(failed), { status: "cached", error: Option.some("Shell failed") });
const dispose = installSubscriptionUsageRefresh();
registry.set(catalogAtom, { isReady: true });
@@ -89,7 +116,11 @@ describe("installSubscriptionUsageRefresh", () => {
fixture.appState = "active";
for (const listener of fixture.appStateListeners) listener();
- expect(fixture.refreshed).toEqual([connected]);
+ expect(fixture.refreshed).toEqual([connected, failed]);
+
+ // The refresh waits for the thread list, so it never competes with it on a cold start.
+ registry.set(shellAtom(syncing), { status: "live", error: Option.none() });
+ expect(fixture.refreshed).toEqual([connected, failed, syncing]);
dispose();
expect(fixture.appStateListeners.size).toBe(0);
diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.ts b/apps/mobile/src/widgets/useSubscriptionUsage.ts
index 0ba5ad1f5107d..fe8d1d3e99ce0 100644
--- a/apps/mobile/src/widgets/useSubscriptionUsage.ts
+++ b/apps/mobile/src/widgets/useSubscriptionUsage.ts
@@ -1,13 +1,37 @@
import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { Atom } from "effect/reactivity";
import { environmentCatalog } from "../connection/catalog";
import { isAppActive, subscribeAppState } from "../lib/appState";
import { appAtomRegistry } from "../state/atom-registry";
import { environmentPresentations } from "../state/presentation";
import { serverEnvironment } from "../state/server";
+import { environmentShell } from "../state/shell";
import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot";
+// A shell that failed never goes live, and its environment still needs fresh quotas.
+const shellSettledAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make((get) => {
+ const shell = get(environmentShell.stateValueAtom(environmentId));
+ return shell.status === "live" || Option.isSome(shell.error);
+ }),
+);
+
+/**
+ * Connected environments whose thread list has loaded. A refresh re-probes every provider on
+ * the host, so on a cold start it waits until it cannot compete with the thread list.
+ */
+const refreshableEnvironmentsAtom = Atom.make((get) =>
+ [...get(environmentPresentations.presentationsAtom)]
+ .filter(
+ ([environmentId, presentation]) =>
+ presentation.connection.phase === "connected" && get(shellSettledAtom(environmentId)),
+ )
+ .map(([environmentId]) => environmentId),
+);
+
/**
* App-lifetime worker that keeps provider quotas fresh for the usage widget:
* on resume, on every connection change, and on a fixed interval while active.
@@ -24,23 +48,18 @@ export function installSubscriptionUsageRefresh(): () => void {
);
const update = () => {
if (!isAppActive() || !appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return;
- const connected = [...appAtomRegistry.get(environmentPresentations.presentationsAtom)]
- .filter(([, presentation]) => presentation.connection.phase === "connected")
- .map(([id]) => id);
- void refresh(connected, Date.now());
+ void refresh(appAtomRegistry.get(refreshableEnvironmentsAtom), Date.now());
};
const unsubscribeCatalog = appAtomRegistry.subscribe(environmentCatalog.catalogValueAtom, update);
- const unsubscribePresentations = appAtomRegistry.subscribe(
- environmentPresentations.presentationsAtom,
- update,
- { immediate: true },
- );
+ const unsubscribeEnvironments = appAtomRegistry.subscribe(refreshableEnvironmentsAtom, update, {
+ immediate: true,
+ });
const unsubscribeAppState = subscribeAppState(update);
const timer = setInterval(update, WIDGET_REFRESH_INTERVAL);
return () => {
unsubscribeCatalog();
- unsubscribePresentations();
+ unsubscribeEnvironments();
unsubscribeAppState();
clearInterval(timer);
};
diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts
index 0ec5b90af2ab3..bcfa2f3896f20 100644
--- a/apps/server/integration/TransferBudgetReport.integration.ts
+++ b/apps/server/integration/TransferBudgetReport.integration.ts
@@ -38,6 +38,11 @@ const STALE_TODO_RECORD_KINDS = [
] as const;
type StaleTodoRecordCounts = Readonly>;
+/** One new socket, read from the subscribe call until the subscription caught up. */
+export interface SocketOpenMeasurement {
+ readonly totals: WebSocketTransferTotals;
+}
+
export interface TransferBudgetRun {
readonly provider: ProviderDriverKind;
readonly threadSnapshot: HttpTransferMeasurement;
@@ -49,15 +54,32 @@ export interface TransferBudgetRun {
readonly reconnectShell: WebSocketCatchUpMeasurement;
readonly measuredTurnSqlStatements: number;
readonly reconnectSqlStatements: number;
- /** The compact bounded snapshot web and mobile load when the long-turn thread opens. */
- readonly longTurnColdOpen: TimelineHttpMeasurement;
- readonly longTurnHistoryPage: TimelineHttpMeasurement;
+ /** A thread resumed from the cursor it held before one ordinary turn. */
+ readonly shortTurnResume: SocketOpenMeasurement;
+ /** The long-turn thread while its last turn runs: the bounded snapshot, then one older page. */
+ readonly longTurnRunningColdOpen: TimelineHttpMeasurement;
+ readonly longTurnRunningHistoryPage: TimelineHttpMeasurement;
/** The same reads as an older client that only sends `compactTurnItems`, to show the savings. */
- readonly longTurnColdOpenLegacy: TimelineHttpMeasurement;
- readonly longTurnHistoryPageLegacy: TimelineHttpMeasurement;
+ readonly longTurnRunningColdOpenLegacy: TimelineHttpMeasurement;
+ readonly longTurnRunningHistoryPageLegacy: TimelineHttpMeasurement;
+ /** The same thread once the turn finished, over HTTP (web) and over the socket alone (mobile). */
+ readonly longTurnColdOpen: TimelineHttpMeasurement;
+ readonly longTurnSocketColdOpen: SocketOpenMeasurement & { readonly rows: number };
+ /** The first page of the finished turn's deferred work, loaded when its group is expanded. */
+ readonly longTurnWorkPage: TimelineHttpMeasurement;
+ /** A thread resumed from the cursor of its own last event. */
+ readonly idleThreadResume: SocketOpenMeasurement;
/** The cold-connect shell snapshot after the settled-thread fleet is written. */
readonly fleetShell: TimelineHttpMeasurement;
readonly fleetShellLegacy: TimelineHttpMeasurement;
+ /** The shell subscription a cold connect opens from that snapshot's cursor. */
+ readonly fleetShellSubscribe: SocketOpenMeasurement;
+ /** A client that holds that snapshot's rows asks the HTTP route whether they still resume. */
+ readonly fleetShellResume: TimelineHttpMeasurement;
+ /** The same question from a cursor too old to replay: the route answers with the snapshot. */
+ readonly staleShellReload: TimelineHttpMeasurement;
+ /** A shell resumed over the socket from that old cursor, by a client that reloads over HTTP. */
+ readonly staleShellResume: SocketOpenMeasurement;
/**
* The compact bounded snapshot of the long thread whose finished runs left todo lists open,
* with the count of each record kind that rides along with its rows.
@@ -89,23 +111,61 @@ const TRANSFER_BUDGETS: Readonly> = {
claudeAgent: TRANSFER_BUDGET,
};
-// Decoded bytes, not wire bytes: phones parse and keep the decoded JSON. About 7% above the
-// observed 103 KB, so a cold open without body dedupe (117 KB), the ID table, or row budgets
-// (all 920 rows) fails. Kept apart from TRANSFER_BUDGET because the PR comment validates its keys.
-const LONG_TURN_BUDGET = {
+// Decoded bytes, not wire bytes: phones parse and keep the decoded JSON. About 15% above the
+// observed 95 KB history page, so a page without the ID table (130 KB) or row budgets fails.
+// Kept apart from TRANSFER_BUDGET because the PR comment validates its keys.
+const LONG_TURN_PAGE_BUDGET = {
decodedBytes: 110_000,
rows: 75,
};
+// About 7% above the observed 290 KB. The window holds 75 rows, but every message of a running
+// turn rides along with it: 196 KB here. Without body dedupe or the ID table it is 354 KB.
+const LONG_TURN_RUNNING_BUDGET = {
+ decodedBytes: 310_000,
+ rows: 75,
+};
+
+// About 7% above the observed 393 KB. A finished turn defers its 770 work rows and stays whole:
+// 151 rows of messages, checkpoints and subagents, on both transports.
+const LONG_TURN_FINISHED_BUDGET = {
+ decodedBytes: 420_000,
+ rows: 151,
+};
+
+// About 6% above the observed 193 KB: one older page of work, twice the cold-open row budget.
+const LONG_TURN_WORK_PAGE_BUDGET = {
+ decodedBytes: 205_000,
+ rows: 150,
+};
+
+const SOCKET_BUDGETS = {
+ // One ordinary turn replays in 11 KB. With its tool bodies it is over 900 KB.
+ shortTurnResume: { decodedBytes: 13_000, messages: 2 },
+ longTurnColdOpen: { decodedBytes: LONG_TURN_FINISHED_BUDGET.decodedBytes, messages: 2 },
+ // These three send no rows. A snapshot in their place is 393 KB for the thread, 42 KB for the shell.
+ idleThreadResume: { decodedBytes: 200, messages: 1 },
+ // A subscribe that does not name the snapshot's project identities gets the project again: 851 B.
+ fleetShellSubscribe: { decodedBytes: 200, messages: 1 },
+ staleShellResume: { decodedBytes: 400, messages: 2 },
+};
+
// About 30% above the observed 42 KB. Without the shell opt-ins it carries all 142 rows (217 KB).
const FLEET_SHELL_BUDGET = {
decodedBytes: 55_000,
rows: 27,
};
+// The answer is `{"resumable":true}`. A snapshot in its place is 42 KB.
+const FLEET_SHELL_RESUME_BUDGET = {
+ decodedBytes: 100,
+ rows: 0,
+};
+
// About 14% above the observed 75 KB. The window holds the working run only: 2 nodes, 2 subagents,
// 1 plan, 1 provider turn and the last finished turn's checkpoint. When finished runs ride along
// it holds 82 nodes, 82 subagents, 41 plans, 41 provider turns, 40 checkpoints and 740 KB.
+// The working run's ready handoff ships without its 256 KB history; with it the open is 339 KB.
const STALE_TODO_BUDGET = {
decodedBytes: 85_000,
rows: 75,
@@ -210,31 +270,141 @@ function webSocketRows(
];
}
-function timelineRows(
- provider: ProviderDriverKind,
+interface ScenarioRow {
+ readonly phase: string;
+ readonly metric: string;
+ readonly unit: "bytes" | "count";
+ readonly observed: number;
+ /** Absent on figures that only inform: wire bytes, and what an older client receives. */
+ readonly maximum: number | undefined;
+}
+
+function httpRows(
phase: string,
measured: TimelineHttpMeasurement,
- budget: { readonly decodedBytes: number; readonly rows: number },
-): string[] {
+ budget?: { readonly decodedBytes: number; readonly rows: number },
+): ScenarioRow[] {
return [
- infoRow(provider, phase, "HTTP wire", measured.http.wireBytes),
- row(provider, phase, "HTTP decoded", measured.http.decodedBodyBytes, budget.decodedBytes),
- row(provider, phase, "rows", measured.rows, budget.rows, String),
+ {
+ phase,
+ metric: "HTTP wire",
+ unit: "bytes",
+ observed: measured.http.wireBytes,
+ maximum: undefined,
+ },
+ {
+ phase,
+ metric: "HTTP decoded",
+ unit: "bytes",
+ observed: measured.http.decodedBodyBytes,
+ maximum: budget?.decodedBytes,
+ },
+ { phase, metric: "rows", unit: "count", observed: measured.rows, maximum: budget?.rows },
];
}
-function legacyRows(
- provider: ProviderDriverKind,
+function socketRows(
phase: string,
- measured: TimelineHttpMeasurement,
-): string[] {
+ { totals }: SocketOpenMeasurement,
+ budget: { readonly decodedBytes: number; readonly messages: number },
+): ScenarioRow[] {
+ return [
+ {
+ phase,
+ metric: "WebSocket wire",
+ unit: "bytes",
+ observed: totals.wireBytes,
+ maximum: undefined,
+ },
+ {
+ phase,
+ metric: "WebSocket decoded",
+ unit: "bytes",
+ observed: totals.decodedBytes,
+ maximum: budget.decodedBytes,
+ },
+ {
+ phase,
+ metric: "WebSocket messages",
+ unit: "count",
+ observed: totals.messages,
+ maximum: budget.messages,
+ },
+ ];
+}
+
+/**
+ * Every scenario figure beyond the thread totals. The violations, the report table and the PR
+ * comment's scenario file all read this list, so a row added here reaches all three. The PR
+ * comment only accepts letters, digits, spaces, commas and hyphens in `phase` and `metric`.
+ */
+function scenarioRows(run: TransferBudgetRun): ScenarioRow[] {
return [
- infoRow(provider, `${phase}, older client`, "HTTP wire", measured.http.wireBytes),
- infoRow(provider, `${phase}, older client`, "HTTP decoded", measured.http.decodedBodyBytes),
- infoRow(provider, `${phase}, older client`, "rows", measured.rows, String),
+ ...socketRows("short turn resume", run.shortTurnResume, SOCKET_BUDGETS.shortTurnResume),
+ ...httpRows(
+ "long turn running, cold open",
+ run.longTurnRunningColdOpen,
+ LONG_TURN_RUNNING_BUDGET,
+ ),
+ ...httpRows("long turn running, cold open, older client", run.longTurnRunningColdOpenLegacy),
+ ...httpRows(
+ "long turn running, history page",
+ run.longTurnRunningHistoryPage,
+ LONG_TURN_PAGE_BUDGET,
+ ),
+ ...httpRows(
+ "long turn running, history page, older client",
+ run.longTurnRunningHistoryPageLegacy,
+ ),
+ ...httpRows("long turn finished, cold open", run.longTurnColdOpen, LONG_TURN_FINISHED_BUDGET),
+ ...socketRows(
+ "long turn finished, socket cold open",
+ run.longTurnSocketColdOpen,
+ SOCKET_BUDGETS.longTurnColdOpen,
+ ),
+ {
+ phase: "long turn finished, socket cold open",
+ metric: "rows",
+ unit: "count",
+ observed: run.longTurnSocketColdOpen.rows,
+ maximum: LONG_TURN_FINISHED_BUDGET.rows,
+ },
+ ...httpRows("long turn finished, work page", run.longTurnWorkPage, LONG_TURN_WORK_PAGE_BUDGET),
+ ...socketRows("idle thread resume", run.idleThreadResume, SOCKET_BUDGETS.idleThreadResume),
+ ...httpRows("fleet shell snapshot", run.fleetShell, FLEET_SHELL_BUDGET),
+ ...httpRows("fleet shell snapshot, older client", run.fleetShellLegacy),
+ ...socketRows(
+ "fleet shell subscribe",
+ run.fleetShellSubscribe,
+ SOCKET_BUDGETS.fleetShellSubscribe,
+ ),
+ ...httpRows("fleet shell resume check", run.fleetShellResume, FLEET_SHELL_RESUME_BUDGET),
+ ...httpRows("stale shell reload", run.staleShellReload, FLEET_SHELL_BUDGET),
+ ...socketRows("stale shell resume", run.staleShellResume, SOCKET_BUDGETS.staleShellResume),
+ ...httpRows("stale todo list cold open", run.staleTodoColdOpen, STALE_TODO_BUDGET),
+ ...STALE_TODO_RECORD_KINDS.map((kind): ScenarioRow => ({
+ phase: "stale todo list cold open",
+ metric: kind,
+ unit: "count",
+ observed: run.staleTodoColdOpen.records[kind],
+ maximum: STALE_TODO_BUDGET.records[kind],
+ })),
];
}
+/** The budgeted scenario figures, for the PR comment to compare with `main`. */
+export function formatTransferBudgetScenarios(runs: ReadonlyArray): string {
+ const providers = Object.fromEntries(
+ runs.map((run) => [
+ run.provider,
+ scenarioRows(run).flatMap(({ maximum, ...row }) =>
+ maximum === undefined ? [] : [{ ...row, ceiling: maximum }],
+ ),
+ ]),
+ );
+ return `${JSON.stringify({ schemaVersion: 1, providers }, null, 2)}\n`;
+}
+
export function transferBudgetViolations(runs: ReadonlyArray): string[] {
const violations: string[] = [];
for (const run of runs) {
@@ -261,37 +431,8 @@ export function transferBudgetViolations(runs: ReadonlyArray)
run.measuredTurnWebSocket.messages,
budget.measuredTurnWebSocketMessages,
],
- [
- "long-turn cold open decoded bytes",
- run.longTurnColdOpen.http.decodedBodyBytes,
- LONG_TURN_BUDGET.decodedBytes,
- ],
- ["long-turn cold open rows", run.longTurnColdOpen.rows, LONG_TURN_BUDGET.rows],
- [
- "long-turn history page decoded bytes",
- run.longTurnHistoryPage.http.decodedBodyBytes,
- LONG_TURN_BUDGET.decodedBytes,
- ],
- ["long-turn history page rows", run.longTurnHistoryPage.rows, LONG_TURN_BUDGET.rows],
- [
- "fleet shell snapshot decoded bytes",
- run.fleetShell.http.decodedBodyBytes,
- FLEET_SHELL_BUDGET.decodedBytes,
- ],
- ["fleet shell snapshot rows", run.fleetShell.rows, FLEET_SHELL_BUDGET.rows],
- [
- "stale todo list cold open decoded bytes",
- run.staleTodoColdOpen.http.decodedBodyBytes,
- STALE_TODO_BUDGET.decodedBytes,
- ],
- ["stale todo list cold open rows", run.staleTodoColdOpen.rows, STALE_TODO_BUDGET.rows],
- ...STALE_TODO_RECORD_KINDS.map(
- (kind) =>
- [
- `stale todo list cold open ${kind}`,
- run.staleTodoColdOpen.records[kind],
- STALE_TODO_BUDGET.records[kind],
- ] as const,
+ ...scenarioRows(run).flatMap(({ phase, metric, observed, maximum }) =>
+ maximum === undefined ? [] : [[`${phase} ${metric}`, observed, maximum] as const],
),
] as const;
for (const [metric, observed, maximum] of checks) {
@@ -310,8 +451,9 @@ export function formatTransferBudgetReport(runs: ReadonlyArray
- row(
- run.provider,
- "stale todo list cold open",
- kind,
- run.staleTodoColdOpen.records[kind],
- STALE_TODO_BUDGET.records[kind],
- String,
- ),
- ),
+ ...scenarioRows(run).map(({ phase, metric, unit, observed, maximum }) => {
+ const format = unit === "bytes" ? formatBytes : String;
+ return maximum === undefined
+ ? infoRow(run.provider, phase, metric, observed, format)
+ : row(run.provider, phase, metric, observed, maximum, format);
+ }),
);
}
diff --git a/apps/server/integration/TransferBudgetV2Fixture.integration.ts b/apps/server/integration/TransferBudgetV2Fixture.integration.ts
index 85ac051673c1d..68cb2c4e841da 100644
--- a/apps/server/integration/TransferBudgetV2Fixture.integration.ts
+++ b/apps/server/integration/TransferBudgetV2Fixture.integration.ts
@@ -2,6 +2,7 @@ import {
CheckpointId,
CheckpointRef,
CheckpointScopeId,
+ ContextHandoffId,
EventId,
MessageId,
NodeId,
@@ -13,8 +14,10 @@ import {
RunId,
ThreadId,
TurnItemId,
+ type ApplicationProjectEvent,
type ProviderDriverKind,
type OrchestrationV2DomainEvent,
+ type RepositoryIdentity,
type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
@@ -216,10 +219,10 @@ export const LONG_TURN_THREAD_ID = ThreadId.make("transfer-budget-long-turn");
export const LONG_TURN_WORK_ROWS = 900;
/**
- * Three short user turns, then one agent turn far longer than a cold-open
- * window, with the item kinds a long agent run produces. One batch per turn.
- * Run, node, provider and item IDs follow the composite shapes the Claude
- * adapter stores, and every assistant message has its record, as real threads do.
+ * Three short user turns, then one agent turn far longer than a cold-open window, with the item
+ * kinds a long agent run produces. One batch per turn; the last batch only finishes the long turn.
+ * Run, node, provider and item IDs follow the composite shapes the Claude adapter stores, and
+ * every assistant message has its record, as real threads do.
*/
export function longTurnThreadBatches(
provider: ProviderDriverKind,
@@ -433,12 +436,14 @@ export function longTurnThreadBatches(
return events;
};
+ const longTurn = turn(3, LONG_TURN_WORK_ROWS);
return [
[threadCreated(provider, threadId)],
turn(0, 4),
turn(1, 4),
turn(2, 4),
- turn(3, LONG_TURN_WORK_ROWS),
+ longTurn.slice(0, -1),
+ longTurn.slice(-1),
];
}
@@ -446,6 +451,7 @@ export const STALE_TODO_THREAD_ID = ThreadId.make("transfer-budget-stale-todo-li
export const STALE_TODO_SETTLED_RUNS = 40;
export const STALE_TODO_SUBAGENTS_PER_RUN = 2;
export const STALE_TODO_LIVE_ROWS = 600;
+const STALE_TODO_HANDOFF_HISTORY_BYTES = 256 * 1024;
const staleTodoCheckpointId = (index: number) =>
CheckpointId.make(`checkpoint:thread:${STALE_TODO_THREAD_ID}:ordinal:${index + 1}`);
/** The checkpoint clients read the last finished turn's diff from. */
@@ -742,7 +748,50 @@ export function staleTodoListThreadBatches(
exitCode: 0,
},
});
- if (live) return events;
+ if (live) {
+ // A ready handoff rides along with every window. Its history and delivery are for the
+ // provider turn start and must not reach a client.
+ const earlierItemId = TurnItemId.make(`turn-item:stale-todo:${index}:handoff-source`);
+ events.push({
+ ...common,
+ id: EventId.make(`stale-todo-handoff-${index}`),
+ type: "context-handoff.updated",
+ payload: {
+ id: ContextHandoffId.make(`handoff:run:${runKey}`),
+ transferId: null,
+ threadId,
+ targetRunId: runId,
+ fromProviderThreadIds: [providerThreadId],
+ toProviderThreadId: providerThreadId,
+ coveredRunOrdinals: { from: 1, to: index },
+ strategy: "full_thread_summary",
+ status: "ready",
+ summaryMessageId: null,
+ summaryText: text(index, 6, 1_024),
+ history: {
+ messages: [
+ {
+ role: "user",
+ text: text(index, 7, STALE_TODO_HANDOFF_HISTORY_BYTES),
+ threadId,
+ runId: null,
+ itemId: earlierItemId,
+ providerThreadId,
+ status: "completed",
+ kind: "user_message",
+ },
+ ],
+ coverage: "full",
+ omittedItems: 0,
+ },
+ delivery: { nativeThreadId: "native", status: "injected", itemIds: [earlierItemId] },
+ createdByProviderInstanceId: providerInstanceId,
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+ return events;
+ }
message("assistant", text(index, 5, 1_200));
const checkpointId = staleTodoCheckpointId(index);
@@ -790,6 +839,44 @@ export function staleTodoListThreadBatches(
];
}
+const FLEET_PROJECT_ID = ProjectId.make("transfer-project");
+export const FLEET_PROJECT_IDENTITY: RepositoryIdentity = {
+ canonicalKey: "github.com/t3/transfer-budget",
+ locator: {
+ source: "git-remote",
+ remoteName: "origin",
+ remoteUrl: "git@github.com:t3/transfer-budget.git",
+ },
+ owner: "t3",
+ name: "transfer-budget",
+};
+
+/**
+ * The project every fixture thread belongs to. The test resolves it to FLEET_PROJECT_IDENTITY,
+ * which a shell resume sends again unless the client names the identities it already holds.
+ */
+export const fleetProjectCreated: ApplicationProjectEvent = {
+ sequence: 0,
+ eventId: EventId.make("project-created:transfer-project"),
+ aggregateKind: "project",
+ aggregateId: FLEET_PROJECT_ID,
+ occurredAt: "2026-06-01T00:00:00.000Z",
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ type: "project.created",
+ payload: {
+ projectId: FLEET_PROJECT_ID,
+ title: "Transfer budget",
+ workspaceRoot: "/tmp/transfer-budget",
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: "2026-06-01T00:00:00.000Z",
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ },
+};
+
export const SHELL_SETTLED_THREADS = 100;
export const SHELL_IDLE_SUBAGENTS = 40;
diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts
index 8c5fe2447cedb..4aaac3b9e30e9 100644
--- a/apps/server/integration/transferBudgetV2.integration.test.ts
+++ b/apps/server/integration/transferBudgetV2.integration.test.ts
@@ -19,12 +19,19 @@ import {
OrchestrationV2ThreadBoundedSnapshot,
OrchestrationV2ThreadDetailSnapshot,
OrchestrationV2ThreadHistoryPage,
- OrchestrationV2ShellSnapshot,
+ OrchestrationV2ShellHttpSnapshot,
+ OrchestrationV2ShellResumable,
OrchestrationV2GetThreadProjectionError,
OrchestrationV2GetShellSnapshotError,
ProviderDriverKind,
+ type EnvironmentOrchestrationShellSnapshotQuery,
+ type EnvironmentOrchestrationThreadBoundedSnapshotQuery,
+ type EnvironmentOrchestrationThreadHistoryQuery,
+ type OrchestrationV2SubscribeShellInput,
+ type OrchestrationV2SubscribeThreadInput,
type OrchestrationV2ThreadStreamItem,
type OrchestrationV2ShellStreamItem,
+ type ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Config from "effect/Config";
@@ -63,11 +70,14 @@ import { makeSqlStatementCounter } from "./SqlStatementCounter.integration.ts";
import {
formatTransferBudgetReport,
formatTransferBudgetResult,
+ formatTransferBudgetScenarios,
transferBudgetViolations,
type TransferBudgetRun,
} from "./TransferBudgetReport.integration.ts";
import { TRANSFER_HISTORY_TURN_COUNT } from "./fixtures/transferBudget.ts";
import {
+ FLEET_PROJECT_IDENTITY,
+ fleetProjectCreated,
LONG_TURN_THREAD_ID,
longTurnThreadBatches,
SHELL_IDLE_SUBAGENTS,
@@ -83,11 +93,47 @@ import {
turnEvents,
} from "./TransferBudgetV2Fixture.integration.ts";
-// The opt-ins current web and mobile clients send; keep in sync with client-runtime's
-// boundedThreadSnapshotHttp.ts, threadHistoryHttp.ts, shellSnapshotHttp.ts, threads.ts and shell.ts.
-const CLIENT_BOUNDED_QUERY = "compactTurnItems=1&dedupeBodies=1&compactIds=1";
-const CLIENT_HISTORY_QUERY = "compactIds=1";
-const CLIENT_SHELL_QUERY = "omitIdleSubagents=1&omitOlderSettled=1";
+// The opt-ins current web and mobile clients send, as client-runtime's boundedThreadSnapshotHttp.ts,
+// threadHistoryHttp.ts, shellSnapshotHttp.ts, threads.ts and shell.ts send them. Each is `Required`,
+// so an opt-in added to a contract fails typecheck here until this test sends it. An omitted field
+// differs per request (a cursor, or what the client holds), so each scenario sends its own.
+const CLIENT_BOUNDED_OPT_INS = {
+ compactTurnItems: "1",
+ dedupeBodies: "1",
+ compactIds: "1",
+ deferWork: "1",
+} satisfies Required;
+const CLIENT_HISTORY_OPT_INS = { compactIds: "1", deferWork: "1" } satisfies Required<
+ Omit
+>;
+const CLIENT_SHELL_OPT_INS = {
+ omitIdleSubagents: "1",
+ omitOlderSettled: "1",
+} satisfies Required>;
+const CLIENT_THREAD_SUBSCRIPTION = {
+ requestCompletionMarker: true,
+ acceptBoundedSnapshot: true,
+ acceptDeferredWork: true,
+ acceptCompactTurnItems: true,
+ acceptCompactIds: true,
+ acceptDedupedBodies: true,
+} satisfies Required>;
+type ShellResumeField = "afterSequence" | "httpSnapshotFallback" | "knownProjectIdentities";
+const CLIENT_SHELL_SUBSCRIPTION = {
+ requestCompletionMarker: true,
+ omitIdleSubagents: true,
+ omitOlderSettled: true,
+} satisfies Required>;
+const query = (optIns: Readonly>) => new URLSearchParams(optIns).toString();
+const CLIENT_BOUNDED_QUERY = query(CLIENT_BOUNDED_OPT_INS);
+const CLIENT_HISTORY_QUERY = query(CLIENT_HISTORY_OPT_INS);
+const CLIENT_SHELL_QUERY = query(CLIENT_SHELL_OPT_INS);
+// A client that holds shell rows names their cursor in the same request.
+const clientShellResumeQuery = (resumeFrom: number) =>
+ query({
+ ...CLIENT_SHELL_OPT_INS,
+ resumeFrom: String(resumeFrom),
+ } satisfies Required);
const decodeThreadSnapshot = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot)),
@@ -99,7 +145,10 @@ const decodeHistoryPage = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadHistoryPage)),
);
const decodeShellSnapshot = Schema.decodeUnknownEffect(
- Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ShellSnapshot)),
+ Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ShellHttpSnapshot)),
+);
+const decodeShellResumable = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ShellResumable)),
);
const layerPersistence = EventSink.layer.pipe(
@@ -139,7 +188,7 @@ const layerEnrichment = Layer.unwrap(
return Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({
getAvailable: () =>
Effect.succeed({
- repositoryIdentity: null,
+ repositoryIdentity: FLEET_PROJECT_IDENTITY,
faviconPath: null,
repositoryIdentityResolved: true,
}),
@@ -224,30 +273,33 @@ const collectUntil = Effect.fn("transfer.collectUntil")(function* )
+ | ({ readonly shell: true } & Pick);
+function subscriptionStream(
+ client: MeasuredWsClient,
+ subscription: Subscription,
+): Stream.Stream {
+ if ("thread" in subscription) {
+ const { thread, ...resume } = subscription;
+ return client.client[ORCHESTRATION_V2_WS_METHODS.subscribeThread]({
+ ...CLIENT_THREAD_SUBSCRIPTION,
+ ...resume,
+ threadId: thread,
+ });
+ }
+ const { shell: _, ...resume } = subscription;
+ return client.client[ORCHESTRATION_V2_WS_METHODS.subscribeShell]({
+ ...CLIENT_SHELL_SUBSCRIPTION,
+ ...resume,
+ });
+}
const subscribe = Effect.fn("transfer.subscribe")(function* (
client: MeasuredWsClient,
- kind: "thread" | "shell",
- afterSequence: number,
+ subscription: Subscription,
) {
const queue = yield* Queue.unbounded();
- const stream: Stream.Stream =
- kind === "thread"
- ? client.client[ORCHESTRATION_V2_WS_METHODS.subscribeThread]({
- threadId: THREAD_ID,
- afterSequence,
- requestCompletionMarker: true,
- acceptBoundedSnapshot: true,
- acceptCompactTurnItems: true,
- acceptCompactIds: true,
- acceptDedupedBodies: true,
- })
- : client.client[ORCHESTRATION_V2_WS_METHODS.subscribeShell]({
- afterSequence,
- requestCompletionMarker: true,
- omitIdleSubagents: true,
- omitOlderSettled: true,
- });
- yield* stream.pipe(
+ yield* subscriptionStream(client, subscription).pipe(
Stream.runForEach((item) => Queue.offer(queue, item)),
Scope.provide(client.scope),
Effect.forkIn(client.scope),
@@ -324,10 +376,11 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
const shell = yield* openMeasuredWsClient({ url, cookie: "" });
const second = yield* openMeasuredWsClient({ url, cookie: "" });
assert.include(thread.recorder.negotiatedExtensions(), "permessage-deflate");
- const tq = yield* subscribe(thread, "thread", sequence);
- const sq = yield* subscribe(shell, "shell", sequence);
- const ttq = yield* subscribe(second, "thread", sequence);
- const ssq = yield* subscribe(second, "shell", sequence);
+ const fromSnapshot = { afterSequence: sequence };
+ const tq = yield* subscribe(thread, { thread: THREAD_ID, ...fromSnapshot });
+ const sq = yield* subscribe(shell, { shell: true, ...fromSnapshot });
+ const ttq = yield* subscribe(second, { thread: THREAD_ID, ...fromSnapshot });
+ const ssq = yield* subscribe(second, { shell: true, ...fromSnapshot });
for (const queue of [tq, sq, ttq, ssq])
assert.equal(yield* synchronized(queue), "replay");
const threadBefore = thread.recorder.totals();
@@ -376,8 +429,8 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
const reconnectSqlStart = counter.count();
const resumedThread = yield* openMeasuredWsClient({ url, cookie: "" });
const resumedShell = yield* openMeasuredWsClient({ url, cookie: "" });
- const rq = yield* subscribe(resumedThread, "thread", sequence);
- const rsq = yield* subscribe(resumedShell, "shell", sequence);
+ const rq = yield* subscribe(resumedThread, { thread: THREAD_ID, ...fromSnapshot });
+ const rsq = yield* subscribe(resumedShell, { shell: true, ...fromSnapshot });
const threadMode = yield* synchronized(rq);
const shellMode = yield* synchronized(rsq);
const reconnectThread = { ...resumedThread.recorder.totals(), mode: threadMode };
@@ -385,7 +438,6 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
const reconnectSqlStatements = counter.count() - reconnectSqlStart;
assert.equal(threadMode, "snapshot");
- for (const events of longTurnThreadBatches(provider)) yield* sink.write({ events });
const get = (path: string) =>
measureHttpGet({
url: `${base}${path}`,
@@ -398,8 +450,9 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
Effect.gen(function* () {
const http = yield* get(`${longTurnPath}/bounded?${query}`);
const snapshot = yield* decodeBoundedSnapshot(decoded(http));
- const rows = snapshot.projection.visibleTurnItems.length;
- return { http, rows, cursor: snapshot.historyCursor };
+ const rows = snapshot.projection.visibleTurnItems;
+ const workAnchor = rows.find((row) => row.item.deferredWork !== undefined);
+ return { http, rows: rows.length, cursor: snapshot.historyCursor, workAnchor };
});
const readHistoryPage = (cursor: string, query: string) =>
Effect.gen(function* () {
@@ -413,18 +466,82 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
const readShell = (query: string) =>
Effect.gen(function* () {
const http = yield* get(`/api/orchestration/shell?${query}`);
- return { http, rows: (yield* decodeShellSnapshot(decoded(http))).threads.length };
+ const { threads, snapshotSequence, projectIdentities } = yield* decodeShellSnapshot(
+ decoded(http),
+ );
+ return { http, rows: threads.length, snapshotSequence, projectIdentities };
});
+ // A new socket, read until the subscription caught up or the server ended it.
+ const open = (subscription: Subscription) =>
+ Effect.gen(function* () {
+ const socket = yield* openMeasuredWsClient({ url, cookie: "" });
+ const items = yield* subscriptionStream(socket, subscription).pipe(
+ Stream.takeUntil((item) => item.kind === "synchronized"),
+ Stream.runCollect,
+ Scope.provide(socket.scope),
+ Effect.orDie,
+ );
+ const totals = socket.recorder.totals();
+ yield* socket.close;
+ return { kinds: items.map((item) => item.kind), items, totals };
+ });
+
+ // A phone that comes back after one ordinary turn replays it, without tool bodies.
+ const shortTurn = yield* sink.write({
+ events: turnEvents(provider, TRANSFER_HISTORY_TURN_COUNT + 1, false),
+ });
+ const shortTurnResume = yield* open({ thread: THREAD_ID, afterSequence: last.sequence });
+ assert.notInclude(shortTurnResume.kinds, "snapshot");
+ // The byte cap below also passes when the resume skips the turn.
+ assert.isTrue(
+ shortTurnResume.items.some(
+ (item) => "sequence" in item && item.sequence === shortTurn.at(-1)!.sequence,
+ ),
+ );
- const { cursor: _, ...longTurnColdOpenLegacy } =
- yield* readColdOpen("compactTurnItems=1");
- const { cursor, ...longTurnColdOpen } = yield* readColdOpen(CLIENT_BOUNDED_QUERY);
+ // A turn that is still running cannot defer its work, so it loads in row-budget windows.
+ const [finishLongTurn, ...longTurnHistory] = longTurnThreadBatches(provider).toReversed();
+ for (const events of longTurnHistory.toReversed()) yield* sink.write({ events });
+ const longTurnRunningColdOpenLegacy = yield* readColdOpen("compactTurnItems=1");
+ const longTurnRunningColdOpen = yield* readColdOpen(CLIENT_BOUNDED_QUERY);
+ const { cursor } = longTurnRunningColdOpen;
if (cursor === null) return yield* Effect.die("Expected older long-turn history");
- const longTurnHistoryPageLegacy = yield* readHistoryPage(cursor, "");
- const longTurnHistoryPage = yield* readHistoryPage(cursor, CLIENT_HISTORY_QUERY);
+ const longTurnRunningHistoryPageLegacy = yield* readHistoryPage(cursor, "");
+ const longTurnRunningHistoryPage = yield* readHistoryPage(cursor, CLIENT_HISTORY_QUERY);
+
+ // Once it finishes, its work stays on the server until a group is expanded.
+ yield* sink.write({ events: finishLongTurn! });
+ const longTurnColdOpen = yield* readColdOpen(CLIENT_BOUNDED_QUERY);
+ const { workAnchor } = longTurnColdOpen;
+ if (workAnchor?.item.deferredWork === undefined)
+ return yield* Effect.die("Expected the finished long turn to defer its work");
+ const longTurnWorkPage = yield* readHistoryPage(
+ workAnchor.item.deferredWork.cursor,
+ `view=work&throughEntryId=${encodeURIComponent(workAnchor.sourceItemId)}&compactIds=1`,
+ );
+ // Mobile opens a thread over the socket alone.
+ const socketOpen = yield* open({ thread: LONG_TURN_THREAD_ID });
+ const socketSnapshot = socketOpen.items.find(
+ (item) => item.kind === "snapshot" && "projection" in item,
+ );
+ if (socketSnapshot?.kind !== "snapshot" || !("projection" in socketSnapshot))
+ return yield* Effect.die("Expected a bounded socket snapshot");
+ const longTurnSocketColdOpen = {
+ ...socketOpen,
+ rows: socketSnapshot.projection.visibleTurnItems.length,
+ };
+ // The row cap below also passes when the socket ignores the deferral and sends a window.
+ assert.equal(longTurnSocketColdOpen.rows, longTurnColdOpen.rows);
+ const idleThreadResume = yield* open({
+ thread: LONG_TURN_THREAD_ID,
+ afterSequence: socketSnapshot.snapshotSequence,
+ });
+ assert.deepEqual(idleThreadResume.kinds, ["synchronized"]);
// The transfer thread plus the long-turn thread.
const earlierThreads = decodedShell.threads.length + 1;
+ // The fleet's project is in a repository. Earlier phases have no project, as on `main`.
+ yield* (yield* ProjectStore.ProjectStoreV2).apply(fleetProjectCreated);
yield* sink.write({ events: shellFleetEvents(provider) });
const fleetShellLegacy = yield* readShell("");
const fleetShell = yield* readShell(CLIENT_SHELL_QUERY);
@@ -432,6 +549,33 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
fleetShellLegacy.rows,
earlierThreads + SHELL_SETTLED_THREADS + SHELL_IDLE_SUBAGENTS,
);
+ const { projectIdentities } = fleetShell;
+ if (projectIdentities === undefined)
+ return yield* Effect.die("Expected the shell snapshot to name its project identities");
+ // A cold connect subscribes from the snapshot it just loaded and names its identities.
+ // Without them the resume sends the project again, in a marked snapshot.
+ const fleetShellSubscribe = yield* open({
+ shell: true,
+ afterSequence: fleetShell.snapshotSequence,
+ knownProjectIdentities: projectIdentities,
+ });
+ assert.notInclude(fleetShellSubscribe.kinds, "snapshot");
+ // A client that holds those rows asks the route whether they still resume, and keeps them.
+ const fleetShellResumeHttp = yield* get(
+ `/api/orchestration/shell?${clientShellResumeQuery(fleetShell.snapshotSequence)}`,
+ );
+ yield* decodeShellResumable(decoded(fleetShellResumeHttp));
+ const fleetShellResume = { http: fleetShellResumeHttp, rows: 0 };
+ // A cursor too old to replay gets the snapshot in the same answer.
+ const staleShellReload = yield* readShell(clientShellResumeQuery(sequence));
+ assert.equal(staleShellReload.rows, fleetShell.rows);
+ // When that request fails, the socket tells the client to reload, with no snapshot.
+ const staleShellResume = yield* open({
+ shell: true,
+ afterSequence: sequence,
+ httpSnapshotFallback: true,
+ });
+ assert.deepEqual(staleShellResume.kinds, ["snapshot-required"]);
for (const events of staleTodoListThreadBatches(provider)) yield* sink.write({ events });
const staleTodoHttp = yield* get(
@@ -448,6 +592,8 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
staleTodo.subagents.filter((subagent) => subagent.runId === STALE_TODO_LIVE_RUN_ID),
STALE_TODO_SUBAGENTS_PER_RUN,
);
+ // The byte cap below also passes when the window loses the handoff.
+ assert.lengthOf(staleTodo.contextHandoffs, 1);
const staleTodoColdOpen = {
http: staleTodoHttp,
rows: staleTodo.visibleTurnItems.length,
@@ -470,12 +616,21 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
reconnectThread,
reconnectShell,
reconnectSqlStatements,
- longTurnColdOpenLegacy,
+ shortTurnResume,
+ longTurnRunningColdOpenLegacy,
+ longTurnRunningColdOpen,
+ longTurnRunningHistoryPageLegacy,
+ longTurnRunningHistoryPage,
longTurnColdOpen,
- longTurnHistoryPageLegacy,
- longTurnHistoryPage,
+ longTurnWorkPage,
+ longTurnSocketColdOpen,
+ idleThreadResume,
fleetShellLegacy,
fleetShell,
+ fleetShellSubscribe,
+ fleetShellResume,
+ staleShellReload,
+ staleShellResume,
staleTodoColdOpen,
} satisfies TransferBudgetRun;
}).pipe(
@@ -490,10 +645,14 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
const outputPaths = yield* Config.all({
report: Config.String("T3CODE_TRANSFER_BUDGET_REPORT_PATH").pipe(Config.withDefault("")),
result: Config.String("T3CODE_TRANSFER_BUDGET_RESULT_PATH").pipe(Config.withDefault("")),
+ scenarios: Config.String("T3CODE_TRANSFER_BUDGET_SCENARIOS_PATH").pipe(
+ Config.withDefault(""),
+ ),
});
for (const [path, contents] of [
[outputPaths.report, report],
[outputPaths.result, formatTransferBudgetResult(runs)],
+ [outputPaths.scenarios, formatTransferBudgetScenarios(runs)],
])
if (path && contents) yield* fs.writeFileString(path, contents);
yield* Effect.log(report);
diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts
index 053d3482a63ba..4380134280e3a 100644
--- a/apps/server/src/attachmentStore.test.ts
+++ b/apps/server/src/attachmentStore.test.ts
@@ -176,6 +176,21 @@ describe("threadHtmlRenderAttachmentIds", () => {
).toEqual([own]);
});
+ it("includes pages an OpenCode Code Mode execute script returned", () => {
+ const own = createAttachmentId("thread-a", "html")!;
+ expect(
+ threadHtmlRenderAttachmentIds("thread-a", [
+ {
+ toolName: "execute",
+ output: JSON.stringify({
+ htmlRender: { attachmentId: own, title: "x", height: 300 },
+ message: "Shown to the reader above your reply.",
+ }),
+ },
+ ]),
+ ).toEqual([own]);
+ });
+
it("includes captured MCP App documents from any tool", () => {
const app = createAttachmentId("thread-a", "html")!;
expect(
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index 0bc1387ea7c1b..1f56d49c88b04 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -291,6 +291,8 @@ export const make = Effect.gen(function* () {
linkFence: true,
mcpApprovalCodes: true,
mcpHostedApproval: true,
+ shellThreadFamilies: true,
+ shellOlderSettled: true,
},
};
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index 8e9c3f8da3042..d1904945dadc2 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -4514,6 +4514,48 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);
+ it.effect("creates a fork PR from origin into the upstream remote the provider targets", () =>
+ Effect.gen(function* () {
+ const repoDir = yield* makeTempDir("t3code-git-manager-");
+ yield* initRepo(repoDir);
+ const forkDir = yield* createBareRemote();
+ yield* runGit(repoDir, ["remote", "add", "origin", forkDir]);
+ yield* runGit(repoDir, [
+ "remote",
+ "add",
+ "upstream",
+ "git@github.com:pingdotgg/codething-mvp.git",
+ ]);
+ yield* runGit(repoDir, ["config", "remote.upstream.gh-resolved", "base"]);
+ yield* runGit(repoDir, ["checkout", "-b", "feature/fork-only"]);
+ NodeFS.writeFileSync(NodePath.join(repoDir, "fork-only.txt"), "fork only\n");
+ yield* runGit(repoDir, ["add", "fork-only.txt"]);
+ yield* runGit(repoDir, ["commit", "-m", "Fork-only commit"]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "feature/fork-only"]);
+ yield* configureVisibleRemoteUrlWithLocalRewrite(
+ repoDir,
+ "origin",
+ "git@github.com:octocat/codething-mvp.git",
+ forkDir,
+ );
+
+ const { service, calls } = createFakeSourceControlProvider({ prListSequence: [[], []] });
+ const { manager } = yield* makeManager({
+ sourceControlProvider: {
+ ...service,
+ createsChangeRequestsIn: ({ repository }) =>
+ Effect.succeed(repository === "pingdotgg/codething-mvp"),
+ },
+ });
+ const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" });
+
+ expect(result.pr.status).toBe("created");
+ expect(created(calls)).toEqual([
+ { baseRefName: "main", headSelector: "octocat:feature/fork-only" },
+ ]);
+ }),
+ );
+
it.effect("commit_push_pr publishes the default branch to the push remote's fork", () =>
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 884869f1cb927..d10b09b1be961 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -1480,9 +1480,9 @@ export const make = Effect.gen(function* () {
const readConfigValueNullable = (cwd: string, key: string) =>
gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));
- // Whether the remote's host creates change requests in the only remote. A host's tools can let
- // configuration outside the remotes name another repository; the remote itself otherwise.
- const targetsOnlyRemote = Effect.fn("targetsOnlyRemote")(function* (
+ // Whether the remote's host creates change requests in this remote's repository. A host's tools
+ // can let configuration, such as gh's default repository, name another one.
+ const targetsRemote = Effect.fn("targetsRemote")(function* (
cwd: string,
remoteName: string,
remote: { readonly remoteUrl: string | null; readonly repositoryNameWithOwner: string | null },
@@ -1617,15 +1617,20 @@ export const make = Effect.gen(function* () {
),
Effect.orElseSucceed(() => 0),
)) === 1 &&
- (yield* targetsOnlyRemote(cwd, remoteName, remoteRepository));
+ (yield* targetsRemote(cwd, remoteName, remoteRepository));
const isForeignRemote = remoteName !== null && remoteName !== "origin" && !isOnlyRemote;
const isCrossRepository =
- remoteRepository.repositoryNameWithOwner !== null &&
+ (remoteRepository.repositoryNameWithOwner !== null &&
originRepository.repositoryNameWithOwner !== null
? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
originRepository.repositoryNameWithOwner.toLowerCase()
- : isForeignRemote && remoteRepository.repositoryNameWithOwner !== null;
+ : isForeignRemote && remoteRepository.repositoryNameWithOwner !== null) ||
+ // A fork as origin, with the host targeting another remote such as upstream.
+ (remoteName !== null &&
+ !isOnlyRemote &&
+ remoteRepository.repositoryNameWithOwner !== null &&
+ !(yield* targetsRemote(cwd, remoteName, remoteRepository)));
const ownerHeadSelector =
remoteRepository.ownerLogin && headBranch.length > 0
diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts
index d4d48bd98619f..4ec813d38200a 100644
--- a/apps/server/src/git/linkCreatedPullRequest.test.ts
+++ b/apps/server/src/git/linkCreatedPullRequest.test.ts
@@ -169,7 +169,7 @@ it.effect(
"refreshes PR readers after a push from a thread or project, but not a local commit",
() =>
Effect.gen(function* () {
- const refreshed: string[] = [];
+ const refreshed: Array<[string, boolean | undefined]> = [];
const layerDependencies = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadShell: () => Effect.succeed(v2PullRequestThread(thread)),
@@ -178,9 +178,9 @@ it.effect(
listShells: () => Effect.succeed([project]),
}),
Layer.mock(PullRequestService.PullRequestService)({
- refreshAfterTurn: (id) =>
+ refreshAfterTurn: (id, options) =>
Effect.sync(() => {
- refreshed.push(id);
+ refreshed.push([id, options?.pushed]);
}),
}),
);
@@ -203,6 +203,10 @@ it.effect(
{ cwd: "/draft-worktree", projectId: PROJECT_ID },
{ push: { status: "pushed" } },
).pipe(Effect.provide(layerDependencies));
- expect(refreshed).toEqual([PROJECT_ID, PROJECT_ID, PROJECT_ID]);
+ expect(refreshed).toEqual([
+ [PROJECT_ID, true],
+ [PROJECT_ID, true],
+ [PROJECT_ID, true],
+ ]);
}),
);
diff --git a/apps/server/src/git/refreshPushedPullRequests.ts b/apps/server/src/git/refreshPushedPullRequests.ts
index b99878dd5e21f..2986b6f77f56f 100644
--- a/apps/server/src/git/refreshPushedPullRequests.ts
+++ b/apps/server/src/git/refreshPushedPullRequests.ts
@@ -16,18 +16,18 @@ export const refreshPushedPullRequests = Effect.fn("refreshPushedPullRequests")(
const engine = yield* OrchestratorV2.OrchestratorV2;
const thread = yield* engine.getThreadShell(input.threadId);
if (thread !== null) {
- yield* pullRequests.refreshAfterTurn(thread.projectId);
+ yield* pullRequests.refreshAfterTurn(thread.projectId, { pushed: true });
return;
}
}
if (input.projectId !== undefined) {
- yield* pullRequests.refreshAfterTurn(input.projectId);
+ yield* pullRequests.refreshAfterTurn(input.projectId, { pushed: true });
return;
}
const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells();
yield* Effect.forEach(
projects.filter((project) => project.workspaceRoot === input.cwd),
- (project) => pullRequests.refreshAfterTurn(project.id),
+ (project) => pullRequests.refreshAfterTurn(project.id, { pushed: true }),
{ discard: true },
);
},
diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts
index ed3c7e65c7974..7227e03dade0a 100644
--- a/apps/server/src/httpResponseErrorGuard.test.ts
+++ b/apps/server/src/httpResponseErrorGuard.test.ts
@@ -125,6 +125,83 @@ describe("guardHttpResponseWriteErrors", () => {
});
});
+describe("keepIdleConnections", () => {
+ it("closes a connection whose response finishes after the server stopped listening", async () => {
+ const server = HttpResponseErrorGuard.keepIdleConnections(NodeHttp.createServer());
+ const body = "x".repeat(1_000_000);
+ const closed = Promise.withResolvers();
+ server.on("request", (_request, response) => {
+ // The server shuts down while this request is in flight.
+ server.close(() => closed.resolve());
+ response.end(body);
+ });
+ const port = await listen(server);
+ const agent = new NodeHttp.Agent({ keepAlive: true });
+
+ const received = await new Promise((resolve, reject) => {
+ const request = NodeHttp.get({ host: "127.0.0.1", port, agent }, (response) => {
+ let bytes = 0;
+ response.on("data", (chunk: Buffer) => {
+ bytes += chunk.length;
+ });
+ response.on("end", () => resolve(bytes));
+ response.on("error", reject);
+ });
+ requests.push(request);
+ request.on("error", reject);
+ });
+
+ expect(received).toBe(body.length);
+ // Left alone, the kept-alive connection holds the server open for the keep-alive time.
+ await closed.promise;
+ agent.destroy();
+ });
+
+ it("keeps a connection until every response on it has finished", async () => {
+ const server = HttpResponseErrorGuard.keepIdleConnections(NodeHttp.createServer());
+ const closed = Promise.withResolvers();
+ // Larger than a socket buffer, so it still has output to send after `end`.
+ const thirdBody = "x".repeat(8_000_000);
+ const held: NodeHttp.ServerResponse[] = [];
+ server.on("request", (request, response) => {
+ if (request.url !== "/third") {
+ held.push(response);
+ return;
+ }
+ const [first, second] = held;
+ // All three pipelined requests are in flight when the server shuts down. The third
+ // response is ended before the first two, and the second ends after the first.
+ response.end(thirdBody);
+ server.close(() => closed.resolve());
+ first?.once("finish", () => setImmediate(() => second?.end("second-body")));
+ first?.end("first-body");
+ });
+ const port = await listen(server);
+
+ const received = await new Promise((resolve, reject) => {
+ let data = "";
+ const client = NodeNet.connect(port, "127.0.0.1", () => {
+ client.write(
+ ["/first", "/second", "/third"]
+ .map((path) => `GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n`)
+ .join(""),
+ );
+ });
+ sockets.push(client);
+ client.on("data", (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+ client.on("end", () => resolve(data));
+ client.on("error", reject);
+ });
+
+ expect(received.slice(0, 1_000)).toContain("first-body");
+ expect(received.slice(0, 1_000)).toContain("second-body");
+ expect(received.endsWith(thirdBody)).toBe(true);
+ await closed.promise;
+ });
+});
+
describe("rejectRequestsUntilServing", () => {
it("rejects early HTTP and upgrades, then hands each off to its serving handler", async () => {
const writeErrors: unknown[] = [];
diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts
index 0f215ba797f87..c75ccae660a55 100644
--- a/apps/server/src/httpResponseErrorGuard.ts
+++ b/apps/server/src/httpResponseErrorGuard.ts
@@ -20,6 +20,32 @@ export function guardHttpResponseWriteErrors(
return server;
}
+// cloudflared and Tailscale serve keep an idle connection to this server for 90
+// seconds. Node's 5 second default closes it first, so they can send a request
+// into a closed connection, and a slow client reconnects in the middle of a connect.
+const HTTP_KEEP_ALIVE_TIMEOUT_MS = 95_000;
+
+/** Install before `rejectRequestsUntilServing`, whose handoff starts at the next request listener. */
+export function keepIdleConnections(server: T): T {
+ server.keepAliveTimeout = HTTP_KEEP_ALIVE_TIMEOUT_MS;
+ // `close` drops only the connections idle at that moment. One that finishes
+ // its responses later would hold shutdown for the whole keep-alive time.
+ // `closeIdleConnections` cannot do this: it destroys a socket whose next
+ // pipelined response is ended but still has output to send.
+ const responsesInFlight = new WeakMap();
+ server.on("request", (request, response) => {
+ const socket = request.socket;
+ responsesInFlight.set(socket, (responsesInFlight.get(socket) ?? 0) + 1);
+ response.once("close", () => {
+ const left = (responsesInFlight.get(socket) ?? 1) - 1;
+ responsesInFlight.set(socket, left);
+ // Pipelined requests share the socket, so the last response ends it.
+ if (left === 0 && !server.listening) socket.end();
+ });
+ });
+ return server;
+}
+
/**
* NodeHttpServer listens before serve() attaches its handlers, so requests in
* between would hang. Install this after the write-error guards so only the
diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts
index 5f5cae7563621..7bf7d2efe2c82 100644
--- a/apps/server/src/mcp/OrchestratorMcpService.test.ts
+++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts
@@ -30,14 +30,17 @@ import {
import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
@@ -1274,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => {
"child run completion",
"child question",
"child approval",
+ "Node fetch's 300 s idle timeout",
] as const)("a task wait still returns on %s", (wake) =>
Effect.gen(function* () {
const events = yield* Queue.unbounded();
@@ -1379,6 +1383,24 @@ describe("OrchestratorMcpService provider resolution", () => {
.delegateTask(scope, { task: "Review the change.", mode: "wait" })
.pipe(Effect.forkChild);
yield* Deferred.await(subscribed);
+ if (wake === "Node fetch's 300 s idle timeout") {
+ // Nothing is written during the wait, so undici drops the call at 300 s, before any handle.
+ const beforeClientTimeout = yield* Fiber.join(waiting).pipe(
+ Effect.timeoutOption(Duration.seconds(299)),
+ Effect.forkChild,
+ );
+ yield* TestClock.adjust(Duration.seconds(299));
+ const result = yield* Fiber.join(beforeClientTimeout);
+ assert.isTrue(
+ Option.isSome(result),
+ "a wait without timeoutMs was still silent at 299 s, so a Node MCP client loses the taskId",
+ );
+ if (Option.isSome(result)) {
+ assert.equal(result.value.taskId, taskId);
+ assert.isTrue(result.value.waitTimedOut);
+ }
+ return;
+ }
ready = true;
if (wake === "parent completion") {
yield* Queue.offer(events, {
diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts
index 71cf0efeda10a..b74619e7b28f4 100644
--- a/apps/server/src/mcp/OrchestratorMcpService.ts
+++ b/apps/server/src/mcp/OrchestratorMcpService.ts
@@ -131,6 +131,8 @@ import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
+// Nothing is written while a task wait blocks, and Node fetch drops a silent call at 300 s.
+const DEFAULT_TASK_WAIT_TIMEOUT_MS = 4 * 60 * 1_000;
const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
const TASK_WAKE_EVENTS = [
{ thread: "parent", eventType: "subagent.updated" },
@@ -1698,7 +1700,7 @@ const make = Effect.gen(function* () {
): Effect.fn.Return {
const timeoutMs = Math.min(
MAX_WAIT_TIMEOUT_MS,
- Math.max(1, requestedTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),
+ Math.max(1, requestedTimeoutMs ?? DEFAULT_TASK_WAIT_TIMEOUT_MS),
);
const upgradeWake = threadManagement
.dispatch({
diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts
index 99937d68b80d3..9682a45f5e592 100644
--- a/apps/server/src/mcp/toolkits/core.test.ts
+++ b/apps/server/src/mcp/toolkits/core.test.ts
@@ -10,6 +10,7 @@ import {
EnvironmentId,
NodeId,
OrchestratorMcpThreadReadResult,
+ ProjectId,
ProviderDriverKind,
ProviderInstanceId,
RunId,
@@ -59,6 +60,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
import { dispatchFailure } from "../threadAccess.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
+import { ThreadToolkit } from "./thread/tools.ts";
import * as PeerForwarding from "../../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../../peer/PeerLinks.ts";
@@ -76,6 +78,9 @@ import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput);
const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
+const decodeThreadSearchResult = Schema.decodeUnknownEffect(
+ ThreadToolkit.tools.t3_thread_search.successSchema,
+);
const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
@@ -589,6 +594,59 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
),
);
+it.effect("search names the calling thread, so an agent can tell its own match apart", () =>
+ Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ const search = (invocation: McpInvocationContext.McpInvocationScope) =>
+ server
+ .callTool({ name: "t3_thread_search", arguments: { query: "release notes" } })
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+ Effect.provideService(McpSchema.McpServerClient, client),
+ );
+
+ const fromThread = yield* search(scope);
+ expect(fromThread.structuredContent).toMatchObject({
+ currentThreadId: threadId,
+ matches: [{ threadId }, { threadId: "earlier-thread" }],
+ });
+
+ const fromClient = yield* search(clientScope("auto"));
+ expect(fromClient.structuredContent).toMatchObject({ currentThreadId: null });
+
+ // A linked environment on an older version answers without the field.
+ const fromOlderPeer = yield* decodeThreadSearchResult({ matches: [] });
+ expect(fromOlderPeer).toEqual({ matches: [] });
+ }).pipe(
+ Effect.provide(
+ McpHttpServer.layerThreadToolkit.pipe(
+ Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
+ Layer.provide(NodeCrypto.layer),
+ Layer.provide(McpToolAccessTestkit.liveThreadsLayer),
+ Layer.provide(
+ Layer.mock(ThreadSearch.ThreadSearch)({
+ searchThreads: () =>
+ Effect.succeed({
+ matches: [threadId, ThreadId.make("earlier-thread")].map((id) => ({
+ threadId: id,
+ projectId: ProjectId.make("project:mcp-test"),
+ title: "Release notes",
+ source: "title" as const,
+ snippet: "",
+ messageCreatedAt: null,
+ })),
+ }),
+ }),
+ ),
+ Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
+ Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
+ Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
+ Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
+ ),
+ ),
+ ),
+);
+
it.effect("a read-only client reads threads and is refused every write before it runs", () =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts
index 32e45b5d0a1c0..62023dd986b55 100644
--- a/apps/server/src/mcp/toolkits/thread/handlers.ts
+++ b/apps/server/src/mcp/toolkits/thread/handlers.ts
@@ -132,6 +132,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
.searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId })
.pipe(Effect.mapError(unavailable));
return {
+ currentThreadId: scope.thread?.threadId ?? null,
matches: matches.map((match) => ({
...match,
link: McpInvocationContext.threadLinkFor(scope, match.threadId, match.title),
diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts
index c6670c4e953d3..750bf15afc019 100644
--- a/apps/server/src/mcp/toolkits/thread/tools.ts
+++ b/apps/server/src/mcp/toolkits/thread/tools.ts
@@ -199,6 +199,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", {
projectId: Schema.optional(ProjectId),
}),
success: Schema.Struct({
+ currentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
matches: Schema.Array(
Schema.Struct({
threadId: OrchestrationThreadSearchMatch.fields.threadId,
diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts
index e17d6f084a931..635d63d91473a 100644
--- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts
@@ -23,6 +23,7 @@ import * as Stream from "effect/Stream";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/http/HttpClient";
@@ -471,6 +472,7 @@ describe("AcpRegistryAdapterV2", () => {
const openWithStoredPicks = Effect.fn("openWithStoredPicks")(function* (input: {
readonly setup: unknown;
readonly pickFrames: ReadonlyArray ;
+ readonly agentId?: string;
readonly customModels?: ReadonlyArray;
readonly storedModel?: string;
readonly storedModePick?: string;
@@ -478,6 +480,7 @@ describe("AcpRegistryAdapterV2", () => {
readonly rejectedModel?: string;
readonly storedOptions?: ReadonlyArray<{ readonly id: string; readonly value: string }>;
readonly failsConfiguration?: boolean;
+ readonly liveConfiguration?: AcpRegistryLiveConfiguration;
}) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
// Scope release gives the replay agent at most a second to exit, so the status
@@ -524,7 +527,7 @@ describe("AcpRegistryAdapterV2", () => {
selfInvocation: yield* resolveSelfInvocation(),
instanceId,
settings: yield* decodeAcpRegistryAdapterSettings({
- agentId: "fixture-agent",
+ agentId: input.agentId ?? "fixture-agent",
authMethodId: "test",
customModels: input.customModels ?? [],
}),
@@ -545,6 +548,8 @@ describe("AcpRegistryAdapterV2", () => {
layerInjectedRuntimeCatalog,
Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({
withForegroundStartup: (_startupKey, effect) => effect,
+ getLiveConfiguration: () =>
+ Effect.sync(() => Option.fromUndefinedOr(published ?? input.liveConfiguration)),
publishLiveConfiguration: (_instanceId, configuration) =>
Effect.sync(() => {
published = configuration;
@@ -799,6 +804,85 @@ describe("AcpRegistryAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);
+ // Copilot's session/new reports its built-in default, not the model it will run
+ // (github/copilot-cli#5363), so the selected model must be written even when it matches,
+ // and an unset effort gets the value the composer shows.
+ it.effect(
+ "writes the selected Copilot model and the shown effort when session/new reports defaults",
+ () => {
+ const copilotModel = (currentValue: string) => ({
+ ...modelOption("model", currentValue),
+ options: ["claude-sonnet-5.5", "claude-haiku-4.5"].map((value) => ({
+ value,
+ name: value,
+ })),
+ });
+ const effort = (currentValue: string) => ({
+ id: "reasoning_effort",
+ name: "Reasoning effort",
+ category: "thought_level",
+ type: "select",
+ currentValue,
+ options: ["medium", "xhigh"].map((value) => ({ value, name: value })),
+ });
+ return openWithStoredPicks({
+ agentId: "github-copilot-cli",
+ setup: { configOptions: [copilotModel("claude-sonnet-5.5"), effort("medium")] },
+ storedModel: "claude-sonnet-5.5",
+ liveConfiguration: {
+ models: [],
+ currentModelId: null,
+ configOptions: [
+ {
+ id: "reasoning_effort",
+ label: "Reasoning effort",
+ type: "select",
+ role: "reasoning",
+ currentValue: "xhigh",
+ options: [
+ { id: "medium", label: "medium" },
+ { id: "xhigh", label: "xhigh" },
+ ],
+ },
+ ],
+ },
+ pickFrames: [
+ // The new session's own update replaces the shared live configuration.
+ {
+ type: "emit_inbound",
+ frame: {
+ kind: "notification",
+ method: "session/update",
+ params: {
+ sessionId: "agent-session",
+ update: {
+ sessionUpdate: "config_option_update",
+ configOptions: [copilotModel("claude-sonnet-5.5"), effort("medium")],
+ },
+ },
+ },
+ },
+ outbound("session/set_config_option", {
+ sessionId: "agent-session",
+ configId: "model",
+ value: "claude-sonnet-5.5",
+ }),
+ answer("session/set_config_option", {
+ configOptions: [copilotModel("claude-sonnet-5.5"), effort("medium")],
+ }),
+ outbound("session/set_config_option", {
+ sessionId: "agent-session",
+ configId: "reasoning_effort",
+ value: "xhigh",
+ }),
+ answer("session/set_config_option", {
+ configOptions: [copilotModel("claude-sonnet-5.5"), effort("xhigh")],
+ }),
+ ],
+ }).pipe(Effect.provide(layerTest), Effect.scoped);
+ },
+ );
+
it.effect.each([
"config",
"legacy",
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index ec2873f387fe8..95432c40e6887 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -3664,6 +3664,64 @@ describe("ClaudeAdapterV2 background wake turns", () => {
});
const makeWakeHarness = makeWakeHarnessWithOptions();
+ it.effect("resumes the session a /clear selected after the Claude process is replaced", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarnessWithOptions({
+ freshQueueOnReopen: true,
+ close: (queue) => Queue.shutdown(queue),
+ });
+ const turn = (ordinal: number, providerThread: OrchestrationV2ProviderThread) =>
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread,
+ now: DateTime.makeUnsafe(0),
+ attemptId: RunAttemptId.make(`clear-session-${ordinal}`),
+ text: ordinal === 1 ? "/clear" : "Continue the cleared conversation",
+ attachments: [],
+ providerTurnOrdinal: ordinal,
+ });
+ yield* harness.runtime.startTurn(turn(1, harness.providerThread));
+ // Claude Code reports the session /clear started in its next init frame.
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ type: "system",
+ subtype: "init",
+ session_id: "cleared-native-session",
+ uuid: "00000000-0000-4000-8000-000000000501",
+ }),
+ );
+ yield* harness.offerAndWait(turnOneResult);
+ yield* Queue.take(harness.terminalReceipts);
+ const latest = (): OrchestrationV2ProviderThread => {
+ const event = harness.events.findLast(
+ (candidate) => candidate.type === "provider_thread.updated",
+ );
+ return event?.type === "provider_thread.updated"
+ ? event.providerThread
+ : harness.providerThread;
+ };
+ assert.equal(latest().nativeThreadRef?.nativeId, "cleared-native-session");
+ yield* harness.runtime.interruptTurn({
+ providerThread: latest(),
+ providerTurnId: lastProviderTurn(harness.events).id,
+ requestRuntimeRestart: true,
+ });
+ yield* harness.runtime.startTurn(turn(2, latest()));
+ assert.equal(harness.processQueues.length, 2);
+ assert.equal(harness.getOpenedOptions()?.resume, "cleared-native-session");
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ Layer.mergeAll(
+ IdAllocator.layer,
+ McpProviderSessions.layer,
+ NodeServices.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ ),
+ );
+
it.effect.each(["release-after", "cancel-after", "release-before", "resume-pending"] as const)(
"keeps attachment reservations across compaction: %s boundary",
(outcome) =>
@@ -6832,6 +6890,94 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ // regression: #17270 root text_delta frames stream the reply before the block's snapshot
+ it.effect("streams Claude assistant text deltas before the completed snapshot", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarness;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("text-stream-attempt"),
+ text: "Write two paragraphs",
+ attachments: [],
+ }),
+ );
+ const stream = (event: unknown) =>
+ claudeSdkFrame({
+ type: "stream_event",
+ event,
+ parent_tool_use_id: null,
+ session_id: WAKE_NATIVE_SESSION,
+ uuid: "stream-frame",
+ });
+ yield* harness.offerAndWait(
+ stream({ type: "message_start", message: { id: "text-message" } }),
+ );
+ yield* harness.offerAndWait(
+ stream({
+ type: "content_block_start",
+ index: 0,
+ content_block: { type: "text", text: "" },
+ }),
+ );
+ yield* harness.offerAndWait(
+ stream({
+ type: "content_block_delta",
+ index: 0,
+ delta: { type: "text_delta", text: "First paragraph.\n\n" },
+ }),
+ );
+ yield* harness.offerAndWait(
+ stream({
+ type: "content_block_delta",
+ index: 0,
+ delta: { type: "text_delta", text: "Second paragraph." },
+ }),
+ );
+ yield* harness.offerAndWait(stream({ type: "content_block_stop", index: 0 }));
+ yield* Queue.offer(
+ harness.sdkMessages,
+ claudeSdkFrame({
+ type: "assistant",
+ uuid: "text-snapshot",
+ session_id: WAKE_NATIVE_SESSION,
+ parent_tool_use_id: null,
+ message: {
+ id: "text-message",
+ model: "claude-sonnet-4-6",
+ content: [{ type: "text", text: "First paragraph.\n\nSecond paragraph." }],
+ },
+ }),
+ );
+ yield* Queue.offer(harness.sdkMessages, makeResultFrame({ uuid: "text-result", result: "" }));
+ yield* Queue.take(harness.terminalReceipts);
+ const replies = harness.events.flatMap((event) =>
+ event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
+ ? [event.turnItem]
+ : [],
+ );
+ // The stream's own item carries the reply; the block's snapshot does not repeat it.
+ assert.deepEqual(
+ [...new Set(replies.map((item) => item.nativeItemRef?.nativeId))],
+ ["text-message:text:0"],
+ );
+ assert.equal(replies.at(-1)?.text, "First paragraph.\n\nSecond paragraph.");
+ assert.equal(replies.at(-1)?.status, "completed");
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ Layer.mergeAll(
+ IdAllocator.layer,
+ McpProviderSessions.layer,
+ NodeServices.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ ),
+ );
+
// regression: #15892 non-execution metadata is carried through, not guessed from result text
it.effect.each(["cancelled", "interrupted", "denied", "permission_denied", undefined])(
"preserves native tool non-execution metadata %s without inferring a denial from text",
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index afa0925fbb4c2..3e10e09906796 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -4516,9 +4516,11 @@ function buildAssistantArtifacts(input: {
readonly nativeItemId: string;
readonly text: string;
readonly ordinal: number;
- readonly status?: "completed" | "interrupted";
+ readonly status?: "running" | "completed" | "interrupted";
readonly startedAt: DateTime.Utc;
readonly completedAt: DateTime.Utc;
+ // A streamed message keeps its first timestamp across updates.
+ readonly createdAt?: DateTime.Utc;
}): {
readonly node: OrchestrationV2ExecutionNode;
readonly message: OrchestrationV2ConversationMessage;
@@ -4541,6 +4543,9 @@ function buildAssistantArtifacts(input: {
nativeId: input.nativeItemId,
strength: "strong" as const,
};
+ const status = input.status ?? "completed";
+ const streaming = status === "running";
+ const completedAt = streaming ? null : input.completedAt;
return {
node: {
@@ -4550,7 +4555,7 @@ function buildAssistantArtifacts(input: {
parentNodeId: input.turnInput.rootNodeId,
rootNodeId: input.turnInput.rootNodeId,
kind: "assistant_message",
- status: input.status ?? "completed",
+ status,
countsForRun: false,
providerThreadId: input.turnInput.providerThread.id,
providerTurnId: input.providerTurnId,
@@ -4558,7 +4563,7 @@ function buildAssistantArtifacts(input: {
runtimeRequestId: null,
checkpointScopeId: null,
startedAt: input.startedAt,
- completedAt: input.completedAt,
+ completedAt,
},
message: {
createdBy: "agent",
@@ -4570,8 +4575,8 @@ function buildAssistantArtifacts(input: {
role: "assistant",
text: input.text,
attachments: [],
- streaming: false,
- createdAt: input.completedAt,
+ streaming,
+ createdAt: input.createdAt ?? input.completedAt,
updatedAt: input.completedAt,
},
turnItem: {
@@ -4584,15 +4589,15 @@ function buildAssistantArtifacts(input: {
nativeItemRef,
parentItemId: null,
ordinal: input.ordinal,
- status: input.status ?? "completed",
+ status,
title: null,
startedAt: input.startedAt,
- completedAt: input.completedAt,
+ completedAt,
updatedAt: input.completedAt,
type: "assistant_message",
messageId,
text: input.text,
- streaming: false,
+ streaming,
},
};
}
@@ -4654,6 +4659,19 @@ interface ActiveClaudeTurnContext {
fallbackText: string;
fallbackNativeItemId: string;
emittedNativeItemIds: Set;
+ // Text blocks stream as `${message.id}:text:${n}`; the block's snapshot has its own uuid.
+ readonly streamBlocks: Map;
+ readonly nextBlockIndex: Map;
+ readonly snapshotBlockIndex: Map;
+ readonly streamItems: Map<
+ string,
+ {
+ readonly startedAt: DateTime.Utc;
+ status: "completed" | "interrupted";
+ // Set when the block stops; the snapshot only corrects it.
+ text: string | null;
+ }
+ >;
// The root reply the latest assistant frame carried; a steer's aborted_streaming cuts it off.
lastReply: {
readonly nativeItemId: string;
@@ -5828,6 +5846,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
);
const absorbedWakes = yield* Ref.make(new Set());
const heldProposedPlansByToolUseId = new Map();
+ // /clear moves a live process to a new native session; persist that one so a reopen resumes it.
+ const clearedNativeSessions = new Map();
const runtimeContext = yield* Effect.context();
const runPromise = Effect.runPromiseWith(runtimeContext);
@@ -5849,6 +5869,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
},
};
}
+ const nativeSessionId = clearedNativeSessions.get(
+ event.providerThread.nativeThreadRef?.nativeId ?? "",
+ );
+ if (nativeSessionId !== undefined) {
+ event = {
+ ...event,
+ providerThread: {
+ ...event.providerThread,
+ nativeThreadRef: {
+ driver: CLAUDE_PROVIDER,
+ nativeId: nativeSessionId,
+ strength: "strong",
+ },
+ nativeConversationHeadRef: null,
+ },
+ };
+ }
}
return Queue.offer(events, event).pipe(Effect.asVoid);
};
@@ -8400,6 +8437,78 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}),
});
+ const publishAssistantArtifacts = Effect.fnUntraced(function* (input: {
+ readonly context: ActiveClaudeTurnContext;
+ readonly nativeItemId: string;
+ readonly text: string;
+ readonly status: "running" | "completed" | "interrupted";
+ readonly startedAt: DateTime.Utc;
+ readonly completedAt: DateTime.Utc;
+ readonly createdAt?: DateTime.Utc;
+ }) {
+ const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId);
+ const artifacts = buildAssistantArtifacts({
+ idAllocator,
+ turnInput: input.context.input,
+ providerTurnId: input.context.providerTurnId,
+ nativeItemId: input.nativeItemId,
+ text: input.text,
+ ordinal,
+ status: input.status,
+ startedAt: input.startedAt,
+ completedAt: input.completedAt,
+ ...(input.createdAt === undefined ? {} : { createdAt: input.createdAt }),
+ });
+ yield* Effect.all(
+ [
+ emitProviderEvent({
+ type: "node.updated",
+ driver: CLAUDE_PROVIDER,
+ node: artifacts.node,
+ }),
+ emitProviderEvent({
+ type: "message.updated",
+ driver: CLAUDE_PROVIDER,
+ message: artifacts.message,
+ }),
+ emitProviderEvent({
+ type: "turn_item.updated",
+ driver: CLAUDE_PROVIDER,
+ turnItem: artifacts.turnItem,
+ }),
+ ],
+ { concurrency: 1 },
+ );
+ });
+
+ const assistantDeltas = yield* makeProviderTextDeltaCoalescer({
+ flushIntervalMs: 50,
+ emit:
+ /* Provider events include streaming deltas; avoid a span for each update. */
+ Effect.fnUntraced(function* (update) {
+ const context = yield* Ref.get(activeTurn);
+ if (context === null || context.nativeTurnId !== update.turnId) return;
+ const item = context.assistant.streamItems.get(update.itemId);
+ if (item === undefined || update.text.length === 0) return;
+ context.assistant.emittedNativeItemIds.add(update.itemId);
+ yield* publishAssistantArtifacts({
+ context,
+ nativeItemId: update.itemId,
+ text: update.text,
+ status: update.completed ? item.status : "running",
+ startedAt: item.startedAt,
+ createdAt: item.startedAt,
+ completedAt: yield* DateTime.now,
+ });
+ }),
+ });
+
+ // Streamed text no snapshot will complete ends where the stream stopped.
+ const cutOffStreamedText = Effect.fnUntraced(function* (context: ActiveClaudeTurnContext) {
+ for (const item of context.assistant.streamItems.values()) item.status = "interrupted";
+ yield* assistantDeltas.flushTurn(context.nativeTurnId);
+ });
+
const endToolCalls = Effect.fnUntraced(function* (input: {
readonly context: ActiveClaudeTurnContext | null;
readonly toolCalls: ReadonlyArray;
@@ -8497,6 +8606,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}) {
input.context.steersClosed = true;
yield* reasoningDeltas.flushTurn(input.context.nativeTurnId);
+ if (input.status === "completed") {
+ yield* assistantDeltas.flushTurn(input.context.nativeTurnId);
+ } else {
+ yield* cutOffStreamedText(input.context);
+ }
// A turn that completes has taken in every steer; one that stops drops them unread.
const droppedSteers = [...input.context.pendingSteers];
input.context.pendingSteers.clear();
@@ -8863,48 +8977,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});
});
- const publishAssistantArtifacts = Effect.fnUntraced(function* (input: {
- readonly context: ActiveClaudeTurnContext;
- readonly nativeItemId: string;
- readonly text: string;
- readonly status: "completed" | "interrupted";
- readonly startedAt: DateTime.Utc;
- readonly completedAt: DateTime.Utc;
- }) {
- const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId);
- const artifacts = buildAssistantArtifacts({
- idAllocator,
- turnInput: input.context.input,
- providerTurnId: input.context.providerTurnId,
- nativeItemId: input.nativeItemId,
- text: input.text,
- ordinal,
- status: input.status,
- startedAt: input.startedAt,
- completedAt: input.completedAt,
- });
- yield* Effect.all(
- [
- emitProviderEvent({
- type: "node.updated",
- driver: CLAUDE_PROVIDER,
- node: artifacts.node,
- }),
- emitProviderEvent({
- type: "message.updated",
- driver: CLAUDE_PROVIDER,
- message: artifacts.message,
- }),
- emitProviderEvent({
- type: "turn_item.updated",
- driver: CLAUDE_PROVIDER,
- turnItem: artifacts.turnItem,
- }),
- ],
- { concurrency: 1 },
- );
- });
-
const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: {
readonly context: ActiveClaudeTurnContext;
readonly nativeItemId: string;
@@ -9877,6 +9949,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (event.type === "message_start") {
reasoning.messageId = event.message.id;
reasoning.streamBlocks.clear();
+ context.assistant.streamBlocks.clear();
const usage: ClaudeMessageUsage | undefined = event.message.usage;
context.streamUsage =
usage === undefined ? null : { messageId: event.message.id, usage };
@@ -9915,6 +9988,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
delta: event.delta.thinking,
});
}
+ } else if (
+ event.type === "content_block_start" &&
+ event.content_block.type === "text" &&
+ reasoning.messageId !== null
+ ) {
+ const assistant = context.assistant;
+ const index = assistant.nextBlockIndex.get(reasoning.messageId) ?? 0;
+ assistant.nextBlockIndex.set(reasoning.messageId, index + 1);
+ const itemId = `${reasoning.messageId}:text:${index}`;
+ assistant.streamBlocks.set(event.index, itemId);
+ assistant.lastReply = null;
+ assistant.streamItems.set(itemId, {
+ startedAt: yield* DateTime.now,
+ status: "completed",
+ text: null,
+ });
+ yield* resolveItemOrdinal(context, itemId);
+ yield* assistantDeltas.append({
+ turnId: context.nativeTurnId,
+ itemId,
+ delta: event.content_block.text,
+ });
+ } else if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
+ const itemId = context.assistant.streamBlocks.get(event.index);
+ if (itemId !== undefined) {
+ yield* assistantDeltas.append({
+ turnId: context.nativeTurnId,
+ itemId,
+ delta: event.delta.text,
+ });
+ }
} else if (event.type === "content_block_stop") {
const itemId = reasoning.streamBlocks.get(event.index);
if (itemId !== undefined) {
@@ -9925,9 +10029,27 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});
reasoning.streamBlocks.delete(event.index);
}
+ const textItemId = context.assistant.streamBlocks.get(event.index);
+ const textItem =
+ textItemId === undefined
+ ? undefined
+ : context.assistant.streamItems.get(textItemId);
+ if (textItemId !== undefined && textItem !== undefined) {
+ textItem.text = yield* assistantDeltas.complete({
+ turnId: context.nativeTurnId,
+ itemId: textItemId,
+ emitEmpty: false,
+ });
+ context.assistant.streamBlocks.delete(event.index);
+ }
}
return;
}
+ // Snapshot text the stream did not deliver, once any of it streamed.
+ const unstreamedSnapshotText: Array = [];
+ let streamedSnapshotReply: NonNullable<
+ ActiveClaudeTurnContext["assistant"]["lastReply"]
+ > | null = null;
if (
message.type === "assistant" &&
!message.parent_tool_use_id &&
@@ -9936,6 +10058,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
context.reasoning.snapshots.add(message.uuid);
// The SDK snapshots each completed block; count thinking blocks separately to preserve streamed ids.
for (const block of message.message.content) {
+ if (block.type === "text") {
+ const assistant = context.assistant;
+ const index = assistant.snapshotBlockIndex.get(message.message.id) ?? 0;
+ assistant.snapshotBlockIndex.set(message.message.id, index + 1);
+ const itemId = `${message.message.id}:text:${index}`;
+ const streamed = assistant.streamItems.get(itemId);
+ if (streamed === undefined) {
+ unstreamedSnapshotText.push(block.text);
+ } else {
+ const text =
+ streamed.text !== null && (!block.text || block.text === streamed.text)
+ ? streamed.text
+ : yield* assistantDeltas.complete({
+ turnId: context.nativeTurnId,
+ itemId,
+ ...(block.text ? { finalText: block.text } : {}),
+ emitEmpty: false,
+ });
+ streamed.text = text;
+ if (text.length > 0) {
+ streamedSnapshotReply = {
+ nativeItemId: itemId,
+ text,
+ startedAt: streamed.startedAt,
+ };
+ }
+ }
+ }
if (block.type !== "thinking") {
const update = context.reasoning.lastThinking;
context.reasoning.lastThinking = null;
@@ -9975,6 +10125,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
: null;
}
+ // A repeated snapshot frame must not re-emit streamed text under its uuid.
+ if (streamedSnapshotReply !== null && unstreamedSnapshotText.join("").length === 0) {
+ context.assistant.emittedNativeItemIds.add(message.uuid);
+ }
}
if (message.type === "assistant" && input.replayed !== true) {
@@ -10752,7 +10906,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
const assistantText = assistantTextFromSdkMessage(message);
if (message.type === "assistant" && assistantParentToolUseId === null) {
- context.assistant.lastReply = null;
+ context.assistant.lastReply = streamedSnapshotReply;
}
if (
assistantText !== null &&
@@ -10802,13 +10956,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
return;
}
if (assistantText !== null && assistantText.text.length > 0) {
+ const text =
+ streamedSnapshotReply === null ? assistantText.text : unstreamedSnapshotText.join("");
+ if (text.length === 0) return;
const startedAt = yield* emitAssistantTextArtifacts({
context,
nativeItemId: assistantText.nativeItemId,
- text: assistantText.text,
+ text,
});
if (startedAt !== undefined) {
- context.assistant.lastReply = { ...assistantText, startedAt };
+ context.assistant.lastReply = {
+ nativeItemId: assistantText.nativeItemId,
+ text,
+ startedAt,
+ };
}
return;
}
@@ -10884,6 +11045,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) {
const cutOffReply = context.assistant.lastReply;
context.assistant.lastReply = null;
+ if (message.terminal_reason === "aborted_streaming") {
+ yield* cutOffStreamedText(context);
+ }
if (message.terminal_reason === "aborted_streaming" && cutOffReply !== null) {
yield* publishAssistantArtifacts({
context,
@@ -11854,6 +12018,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
path,
});
const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null;
+ clearedNativeSessions.delete(nativeThreadId);
if (existing !== null) {
yield* existing.query.close.pipe(Effect.ignore);
if (existing.nativeThreadId !== nativeThreadId) {
@@ -12014,6 +12179,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
context.capabilities = new Set(message.capabilities ?? []);
context.claudeCodeVersion = message.claude_code_version;
}
+ if (
+ message.type === "system" &&
+ message.subtype === "init" &&
+ message.session_id.length > 0 &&
+ message.session_id !== context.nativeThreadId
+ ) {
+ clearedNativeSessions.set(context.nativeThreadId, message.session_id);
+ }
if (
message.type === "system" &&
(message.subtype === "init" || message.subtype === "status") &&
@@ -12188,6 +12361,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
fallbackText: "",
fallbackNativeItemId: `assistant:${turnInput.runId}`,
emittedNativeItemIds: new Set(),
+ streamBlocks: new Map(),
+ nextBlockIndex: new Map(),
+ snapshotBlockIndex: new Map(),
+ streamItems: new Map(),
lastReply: null,
noticeTexts: new Set(),
},
@@ -12754,7 +12931,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const closeLiveQueryForNativeThread = Effect.fnUntraced(function* (nativeThreadId: string) {
const existing = yield* Ref.get(queryContext);
- if (existing === null || existing.nativeThreadId !== nativeThreadId) {
+ if (
+ existing === null ||
+ (existing.nativeThreadId !== nativeThreadId &&
+ clearedNativeSessions.get(existing.nativeThreadId) !== nativeThreadId)
+ ) {
return;
}
@@ -12776,7 +12957,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
);
if (ownedLiveQuery) {
yield* finalizeActiveTurnAfterQueryExit(existing);
- yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted");
+ yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted");
} else {
yield* endSubagentsOfReplacedQuery(existing);
}
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
index a803025d825b4..791320220b0b3 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
@@ -183,6 +183,8 @@ describe("CodexAdapterV2 runtime policy", () => {
},
});
assert.equal(params.effort, reasoningEffort);
+ // Some Responses providers reject `detailed`; `auto` is the most detailed a model supports.
+ assert.equal(params.summary, "auto");
assert.equal(params.additionalContext !== undefined, hasT3Mcp === true);
}
}
@@ -683,7 +685,7 @@ function codexReplayPreamble(input: {
approvalPolicy: "never",
approvalsReviewer: "user",
sandboxPolicy: { type: "dangerFullAccess" },
- summary: "detailed",
+ summary: "auto",
...(input.effort === undefined ? {} : { effort: input.effort }),
collaborationMode: {
mode: "default",
@@ -717,6 +719,61 @@ function codexReplayPreamble(input: {
];
}
+/** Every turn/start follows a config/read; inserts that exchange and shifts later client ids. */
+function withConfigReadBeforeTurnStart(
+ entries: ReadonlyArray,
+): Array {
+ const result: Array = [];
+ let offset = 0;
+ let clientIds = new Map();
+ for (const entry of entries) {
+ const frame = entry.type === "runtime_exit" ? undefined : entry.frame;
+ if (!Predicate.isObject(frame) || typeof frame.id !== "number") {
+ result.push(entry);
+ continue;
+ }
+ if (entry.type === "expect_outbound" && typeof frame.method === "string") {
+ if (frame.method === "initialize") {
+ offset = 0;
+ clientIds = new Map();
+ }
+ if (frame.method === "turn/start") {
+ const id = frame.id + offset;
+ const params = Predicate.isObject(frame.params) ? frame.params : {};
+ result.push(
+ {
+ type: "expect_outbound",
+ label: "config/read",
+ frame: { id, method: "config/read", params: { cwd: params.cwd, includeLayers: false } },
+ },
+ {
+ type: "emit_inbound",
+ label: "config/read",
+ frame: { id, result: { config: {}, origins: {} } },
+ },
+ );
+ offset += 1;
+ }
+ clientIds.set(frame.id, frame.id + offset);
+ result.push(withReplayRequestId(entry, frame.id + offset));
+ continue;
+ }
+ const mapped = clientIds.get(frame.id);
+ result.push(
+ entry.type === "emit_inbound" && frame.method === undefined && mapped !== undefined
+ ? withReplayRequestId(entry, mapped)
+ : entry,
+ );
+ }
+ return result;
+}
+
+function withConfigReads(
+ transcript: CodexReplay.CodexAppServerReplayTranscript,
+): CodexReplay.CodexAppServerReplayTranscript {
+ return { ...transcript, entries: withConfigReadBeforeTurnStart(transcript.entries) };
+}
+
function makeCodexReplayTranscript(input: {
readonly scenario: string;
readonly entries: ReadonlyArray;
@@ -757,7 +814,9 @@ describe("CodexAdapterV2 process exit", () => {
const turnStarted = yield* Deferred.make();
const turnCompleted = yield* Deferred.make();
const events: Array = [];
- const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" });
+ const preamble = withConfigReadBeforeTurnStart(
+ codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }),
+ );
const terminationError =
exit === "numeric"
? new CodexError.CodexAppServerProcessExitedError({ code: 17, pid: 52 })
@@ -963,7 +1022,7 @@ describe("CodexAdapterV2 session initialize", () => {
) =>
Effect.gen(function* () {
const driver = yield* CodexReplay.makeReplayDriver(
- transcript,
+ withConfigReads(transcript),
beforeEmitInbound === undefined ? {} : { beforeEmitInbound },
);
let initializeRequests = 0;
@@ -1294,7 +1353,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
const continuationRequests: Array =
[];
const driver = yield* CodexReplay.makeReplayDriver(
- transcript,
+ withConfigReads(transcript),
beforeEmitInbound === undefined ? {} : { beforeEmitInbound },
);
const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = {
@@ -2505,7 +2564,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
cwd: "/workspace",
model: "gpt-5.4",
...turnPolicies[turnModes[index]!],
- summary: "detailed",
+ summary: "auto",
collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } },
},
},
@@ -3666,7 +3725,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
approvalPolicy: "never",
approvalsReviewer: "user",
sandboxPolicy: { type: "dangerFullAccess" },
- summary: "detailed",
+ summary: "auto",
collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } },
},
},
@@ -3920,7 +3979,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
makeCodexReplayTranscript({ scenario, entries }),
)).replaceAll(yield* encodeStringJson("/workspace"), yield* encodeStringJson(cwd)),
);
- const driver = yield* CodexReplay.makeReplayDriver(localTranscript);
+ const driver = yield* CodexReplay.makeReplayDriver(withConfigReads(localTranscript));
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
@@ -5556,7 +5615,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
yield* encodeStringJson(cwd),
),
);
- const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript);
+ const replayDriver = yield* CodexReplay.makeReplayDriver(withConfigReads(localTranscript));
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
assert.equal(
Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))),
@@ -5779,7 +5838,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
Effect.provide(
ProviderReplayHarness.layerWithRegistry(
{ name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } },
- CodexAdapterV2Testkit.layer({ transcript: localTranscript }),
+ CodexAdapterV2Testkit.layer({ transcript: withConfigReads(localTranscript) }),
{ runEffectWorker: false },
),
),
@@ -8045,7 +8104,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
approvalPolicy: "never",
approvalsReviewer: "user",
sandboxPolicy: { type: "dangerFullAccess" },
- summary: "detailed",
+ summary: "auto",
collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } },
},
},
@@ -8193,7 +8252,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
approvalPolicy: "never",
approvalsReviewer: "user",
sandboxPolicy: { type: "dangerFullAccess" },
- summary: "detailed",
+ summary: "auto",
collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } },
},
},
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index 10f52c5c09c9a..da5e52b517ef8 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -782,6 +782,8 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur
/** ChatGPT token sharing does not accept service tiers. */
readonly omitServiceTier?: boolean;
readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"];
+ /** The user's `model_reasoning_summary` from the Codex config, when set. */
+ readonly configuredReasoningSummary?: CodexSchema.V2TurnStartParams__ReasoningSummary;
}) {
const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode);
const approvalPolicy =
@@ -828,8 +830,9 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur
...(additionalContext ? { additionalContext } : {}),
cwd: input.runtimePolicy.cwd,
model: input.modelSelection.model,
- // Catalogues can default summaries to "none"; request them explicitly on every turn.
- summary: "detailed",
+ // Catalogues can default summaries to "none", and turn/start overrides the user's config,
+ // so pass theirs through or `auto`: some providers (Amazon Bedrock) reject `detailed`.
+ summary: input.configuredReasoningSummary ?? "auto",
// Omitting the reviewer on resume preserves the previous Auto reviewer.
approvalsReviewer: runtimeModeDefaults.approvalsReviewer,
...(approvalPolicy === undefined ? {} : { approvalPolicy }),
@@ -7242,6 +7245,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
) {
const threadId = yield* getNativeThreadId(turnInput.providerThread);
const mcpSession = yield* mcpSessions.read(turnInput.threadId);
+ const configuredReasoningSummary = yield* client
+ .request("config/read", { cwd: turnInput.runtimePolicy.cwd, includeLayers: false })
+ .pipe(
+ Effect.map((response) => response.config.model_reasoning_summary ?? undefined),
+ Effect.catch(() =>
+ Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined)),
+ ),
+ );
const turnStartParams = yield* buildCodexTurnStartParams({
nativeThreadId: threadId,
codexInput,
@@ -7254,6 +7265,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
omitServiceTier: adapterOptions.resolveRuntime !== undefined,
...(turnInput.appContext === undefined ? {} : { appContext: turnInput.appContext }),
+ ...(configuredReasoningSummary === undefined ? {} : { configuredReasoningSummary }),
});
yield* Ref.update(pendingRootTurns, (current) => {
const updated = new Map(current);
diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
index 4f2a838152d7c..1efbbfad09d81 100644
--- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
@@ -4150,26 +4150,37 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
promptAccepted,
event("session.execution.succeeded", { sessionID: SESSION }),
];
+ const prompts: Array = [];
// Idle Location eviction drops OpenCode's runtime MCP servers without a reconnect.
- const { runtime, thread } = yield* resumed([
- add,
- reply("mcp.add", null),
- ...turn,
- add,
- reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }),
- out("session.instructions.entry.put", {
- sessionID: SESSION,
- key: "t3-code",
- value: buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }),
- }),
- reply("session.instructions.entry.put", null),
- ...turn,
- out("mcp.remove", { server, "location[directory]": WORK }),
- reply("mcp.remove", null),
- ]);
+ const { runtime, thread } = yield* resumed(
+ [
+ add,
+ reply("mcp.add", null),
+ ...turn,
+ add,
+ reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }),
+ out("session.instructions.entry.put", {
+ sessionID: SESSION,
+ key: "t3-code",
+ value: buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }),
+ }),
+ reply("session.instructions.entry.put", null),
+ ...turn,
+ out("mcp.remove", { server, "location[directory]": WORK }),
+ reply("mcp.remove", null),
+ ],
+ { onPrompt: (input) => prompts.push(input) },
+ );
for (let index = 0; index < 2; index++) {
const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
- yield* runtime.startTurn(turnInput(thread));
+ const started = yield* runtime.startTurn(turnInput(thread)).pipe(Effect.forkScoped);
+ // OpenCode before 2.2.6 loads a newly added server's tools after a debounce.
+ if (index === 0) {
+ yield* TestClock.adjust("249 millis");
+ assert.lengthOf(prompts, 0, "prompted before OpenCode loaded the new MCP server's tools");
+ yield* TestClock.adjust("1 millis");
+ }
+ yield* Fiber.join(started);
assert.equal((yield* Fiber.join(terminal))?.status, "completed");
}
}).pipe(Effect.scoped),
@@ -4230,7 +4241,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
runtimePolicy: policy(),
});
const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
- yield* runtime.startTurn({ ...turnInput(thread), threadId: child });
+ const started = yield* runtime
+ .startTurn({ ...turnInput(thread), threadId: child })
+ .pipe(Effect.forkScoped);
+ yield* TestClock.adjust("250 millis");
+ yield* Fiber.join(started);
assert.equal((yield* Fiber.join(terminal))?.status, "completed");
yield* runtime.unloadThread!({ providerThread: thread });
}).pipe(Effect.scoped),
diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts
index c61f9bc175edc..e5ef81ad46b54 100644
--- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts
+++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts
@@ -174,8 +174,13 @@ it.effect.each([
{ order: "cleanup-first", restoreFiles: true },
{ order: "rollback-first", restoreFiles: true },
{ order: "cleanup-first", restoreFiles: false, unmanaged: true },
-])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false }) =>
+ { order: "cleanup-first", restoreFiles: true, leavesFolder: true },
+ { order: "cleanup-first", restoreFiles: false, leavesFolder: true },
+ { order: "cleanup-first", restoreFiles: true, leavesFolder: true, shared: true },
+ { order: "cleanup-first", restoreFiles: true, leavesFolder: true, createFails: true },
+])("locks rollback with %s", (row) =>
Effect.gen(function* () {
+ const { order, restoreFiles, unmanaged, leavesFolder, shared, createFails } = row;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* fileSystem.realPath(
@@ -185,7 +190,7 @@ it.effect.each([
const worktreePath = unmanaged
? path.join(workspaceRoot, "elsewhere", "feature")
: path.join(worktreesDir, "repo", "feature");
- yield* fileSystem.makeDirectory(worktreePath, { recursive: true });
+ yield* fileSystem.makeDirectory(path.join(worktreePath, ".git"), { recursive: true });
const threadId = ThreadId.make("rollback-worktree");
const projectId = ProjectId.make("rollback-worktree-project");
const providerThreadId = ProviderThreadId.make("rollback-worktree-provider-thread");
@@ -246,14 +251,19 @@ it.effect.each([
path: worktreePath,
});
calls.push("create");
- yield* fileSystem.makeDirectory(worktreePath).pipe(Effect.orDie);
+ if (createFails) return yield* Effect.die("the branch is gone");
+ yield* fileSystem
+ .makeDirectory(path.join(worktreePath, ".git"), { recursive: true })
+ .pipe(Effect.orDie);
return {} as never;
}),
}),
Layer.mock(CheckpointService.CheckpointServiceV2)({
restore: () =>
Effect.gen(function* () {
- assert.isTrue(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie));
+ assert.isTrue(
+ yield* fileSystem.exists(path.join(worktreePath, ".git")).pipe(Effect.orDie),
+ );
calls.push("files");
}),
}),
@@ -272,16 +282,31 @@ it.effect.each([
Effect.succeed({
schemaVersion: 1,
snapshotSequence: 0,
- threads: [],
+ threads: shared
+ ? [
+ {
+ id: ThreadId.make("rollback-worktree-sharer"),
+ projectId,
+ worktreePath,
+ deletedAt: null,
+ lineage: {},
+ },
+ ]
+ : [],
archivedThreads: [],
- }),
+ } as never),
+ getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] } as never),
+ getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never),
}),
Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
open: (input) =>
Effect.gen(function* () {
assert.equal(input.runtimePolicy.cwd, worktreePath);
if (!(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie))) {
- return yield* new ProviderWorkspaceMissingError({ threadId, cwd: worktreePath });
+ return yield* new ProviderWorkspaceMissingError({
+ threadId,
+ cwd: worktreePath,
+ });
}
calls.push("session");
yield* Deferred.succeed(openEntered, undefined);
@@ -306,6 +331,11 @@ it.effect.each([
cleanupEntered = true;
if (order === "cleanup-first") yield* Deferred.await(release);
yield* fileSystem.remove(worktreePath, { recursive: true, force: true });
+ // A failed `git worktree remove` can leave the folder without its checkout.
+ if (leavesFolder) {
+ yield* fileSystem.makeDirectory(worktreePath);
+ yield* fileSystem.writeFileString(path.join(worktreePath, "unsaved.txt"), "keep");
+ }
calls.push("cleanup");
}),
);
@@ -325,10 +355,24 @@ it.effect.each([
if (order === "rollback-first") assert.isFalse(cleanupEntered);
yield* Deferred.succeed(release, undefined);
if (cleanup !== undefined) yield* Fiber.join(cleanup);
- if (unmanaged) {
+ if (unmanaged || shared) {
const error = yield* Fiber.join(rollback).pipe(Effect.flip);
assert.equal(error._tag, "CheckpointRollbackExecutionError");
assert.deepEqual(calls, ["cleanup"]);
+ if (shared) {
+ assert.equal(error.reason, "shared-workspace");
+ assert.isTrue(yield* fileSystem.exists(path.join(worktreePath, "unsaved.txt")));
+ }
+ return;
+ }
+ if (createFails) {
+ yield* Fiber.join(rollback).pipe(Effect.exit);
+ assert.deepEqual(calls, ["cleanup", "prune", "create"]);
+ assert.equal(
+ yield* fileSystem.readFileString(path.join(worktreePath, "unsaved.txt")),
+ "keep",
+ );
+ assert.deepEqual(yield* fileSystem.readDirectory(path.dirname(worktreePath)), ["feature"]);
return;
}
yield* Fiber.join(rollback);
@@ -336,12 +380,16 @@ it.effect.each([
const rollbackCalls = restoreFiles
? ["session", "files", "projection"]
: ["session", "projection"];
+ const recreates = !leavesFolder || restoreFiles;
assert.deepEqual(
calls,
order === "cleanup-first"
- ? ["cleanup", "prune", "create", ...rollbackCalls]
+ ? ["cleanup", ...(recreates ? ["prune", "create"] : []), ...rollbackCalls]
: [...rollbackCalls, "cleanup"],
);
+ if (!recreates) {
+ assert.isTrue(yield* fileSystem.exists(path.join(worktreePath, "unsaved.txt")));
+ }
}).pipe(Effect.provide(testLayer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts
index 4d40641fa54db..16cbe8b101e28 100644
--- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts
+++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts
@@ -10,6 +10,7 @@ import {
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
@@ -217,42 +218,87 @@ export const layer: Layer.Layer<
};
});
- const { worktreePath, branch } = context.thread;
- // Recreated only where the server would have made it, as turn start does.
- if (
- worktreePath != null &&
- branch != null &&
- !(yield* fileSystem.exists(worktreePath)) &&
- (yield* isManagedWorktreePath(worktreePath))
- ) {
- const project = yield* projects.get(context.thread.projectId);
- if (Option.isSome(project)) {
- yield* gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot });
- yield* gitWorkflow.createWorktree({
- cwd: project.value.workspaceRoot,
- refName: branch,
- path: worktreePath,
- });
- }
- }
-
- if (
- input.restoreFiles !== false &&
- !(yield* isCheckpointRestoreIsolated(context.thread, scope, {
- fileSystem,
- projections,
- projects,
- path,
- }))
- ) {
- return yield* new CheckpointRollbackExecutionError({
+ const restoreFiles = input.restoreFiles !== false;
+ const isolated = isCheckpointRestoreIsolated(context.thread, scope, {
+ fileSystem,
+ projections,
+ projects,
+ path,
+ });
+ const sharedWorkspace = () =>
+ new CheckpointRollbackExecutionError({
reason: "shared-workspace",
threadId: input.threadId,
providerThreadId: input.providerThreadId,
checkpointId: input.checkpointId,
});
+
+ const { worktreePath, branch } = context.thread;
+ // Recreated only where the server would have made it, as turn start does.
+ if (worktreePath != null && branch != null) {
+ // A failed removal can leave the folder without its checkout; only a file restore replaces it.
+ const leftover =
+ restoreFiles &&
+ (yield* fileSystem.exists(worktreePath)) &&
+ !(yield* fileSystem.exists(path.join(worktreePath, ".git")));
+ if (
+ (leftover || !(yield* fileSystem.exists(worktreePath))) &&
+ (yield* isManagedWorktreePath(worktreePath))
+ ) {
+ const project = yield* projects.get(context.thread.projectId);
+ if (Option.isSome(project)) {
+ const recreate = gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot }).pipe(
+ Effect.andThen(
+ gitWorkflow.createWorktree({
+ cwd: project.value.workspaceRoot,
+ refName: branch,
+ path: worktreePath,
+ }),
+ ),
+ );
+ if (leftover) {
+ if (!(yield* isolated)) return yield* sharedWorkspace();
+ // Set aside, not removed: a checkout that fails puts the folder and its files back.
+ yield* Effect.acquireUseRelease(
+ Effect.gen(function* () {
+ const aside = yield* fileSystem.makeTempDirectory({
+ directory: path.dirname(worktreePath),
+ prefix: ".t3-replaced-",
+ });
+ yield* fileSystem.rename(worktreePath, path.join(aside, "folder"));
+ return aside;
+ }),
+ () => recreate,
+ (aside, exit) =>
+ (Exit.isSuccess(exit)
+ ? Effect.void
+ : fileSystem
+ .remove(worktreePath, { recursive: true, force: true })
+ .pipe(
+ Effect.andThen(
+ fileSystem.rename(path.join(aside, "folder"), worktreePath),
+ ),
+ )
+ ).pipe(
+ Effect.andThen(fileSystem.remove(aside, { recursive: true, force: true })),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Edit from here left a worktree folder set aside", {
+ worktreePath,
+ setAside: path.join(aside, "folder"),
+ cause,
+ }),
+ ),
+ ),
+ );
+ } else {
+ yield* recreate;
+ }
+ }
+ }
}
+ if (restoreFiles && !(yield* isolated)) return yield* sharedWorkspace();
+
const modelSelection = context.thread.modelSelection;
const resolvedRuntimePolicy = yield* runtimePolicy.resolve({
thread: context.thread,
diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts
index dfb47bd7b3744..9dd5079805745 100644
--- a/apps/server/src/orchestration-v2/EffectWorker.ts
+++ b/apps/server/src/orchestration-v2/EffectWorker.ts
@@ -5,6 +5,7 @@ import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Metric from "effect/Metric";
import * as Option from "effect/Option";
@@ -943,11 +944,15 @@ export interface OrchestrationEffectDaemonOptions {
const DEFAULT_EFFECT_WORKER_CONCURRENCY = 4;
const DEFAULT_EFFECT_WORKER_LIVENESS_POLL_INTERVAL_MS = 30_000;
+// A provider request can go unanswered indefinitely. After this wait the slot claims other work
+// while the execution keeps its running row (which blocks its own thread) and settles it later.
+const EFFECT_WORKER_SLOT_WAIT = Duration.seconds(30);
export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions = {}) =>
Effect.scoped(
Effect.gen(function* () {
const worker = yield* OrchestrationEffectWorkerV2;
+ const scope = yield* Effect.scope;
const requestedConcurrency = options.concurrency ?? DEFAULT_EFFECT_WORKER_CONCURRENCY;
const concurrency = Number.isFinite(requestedConcurrency)
? Math.max(1, Math.floor(requestedConcurrency))
@@ -962,13 +967,18 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions =
// Notifications wake promptly; availableAt persists retry timing, and liveness polling recovers missed wakeups.
const runWorker = Effect.gen(function* () {
while (true) {
- const outcome = yield* worker.runOnce(claimLimits).pipe(
+ const attempt = yield* worker.runOnce(claimLimits).pipe(
Effect.map((worked) => (worked ? ("worked" as const) : ("idle" as const))),
Effect.catchCause((cause) =>
Effect.logWarning("Orchestration effect worker failed", cause).pipe(
Effect.as("failed" as const),
),
),
+ Effect.forkIn(scope),
+ );
+ const outcome = yield* Fiber.join(attempt).pipe(
+ Effect.timeoutOption(EFFECT_WORKER_SLOT_WAIT),
+ Effect.map(Option.getOrElse(() => "worked" as const)),
);
if (outcome === "worked") {
yield* Effect.yieldNow;
diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts
index 796c10b411944..6a4c51a7a4278 100644
--- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts
+++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts
@@ -3254,6 +3254,98 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
}),
);
+ it.effect(
+ "starts another thread's turn while every worker holds a start that never returns",
+ () =>
+ Effect.gen(function* () {
+ const outbox = yield* EffectOutbox.EffectOutboxV2;
+ const key = "foundation-hung-provider-starts";
+ const wedgedThreadIds = [0, 1].map((index) =>
+ ThreadId.make(`thread:${key}:wedged:${index}`),
+ );
+ const otherThreadId = ThreadId.make(`thread:${key}:other`);
+ const wedgedStarted = yield* Deferred.make();
+ const otherStarted = yield* Deferred.make();
+ const wedgedCount = yield* Ref.make(0);
+ yield* outbox.enqueue(
+ wedgedThreadIds.map((threadId, index) => ({
+ id: `effect:${key}:wedged:${index}`,
+ commandId: CommandId.make(`command:${key}:wedged:${index}`),
+ threadId,
+ request: {
+ type: "provider-turn.start",
+ runId: RunId.make(`run:${key}:wedged:${index}`),
+ },
+ })),
+ );
+
+ const layerExecutor = Layer.succeed(
+ EffectWorker.OrchestrationEffectExecutorV2,
+ EffectWorker.OrchestrationEffectExecutorV2.of({
+ execute: (effect) =>
+ effect.threadId === otherThreadId
+ ? Deferred.succeed(otherStarted, undefined)
+ : Ref.updateAndGet(wedgedCount, (count) => count + 1).pipe(
+ Effect.flatMap((count) =>
+ count === wedgedThreadIds.length
+ ? Deferred.succeed(wedgedStarted, undefined)
+ : Effect.void,
+ ),
+ Effect.andThen(Effect.never),
+ ),
+ }),
+ );
+ const layerWorker = EffectWorker.layerWithOptions({
+ workerId: "hung-provider-starts-worker",
+ }).pipe(
+ Layer.provide(
+ Layer.merge(Layer.succeed(EffectOutbox.EffectOutboxV2, outbox), layerExecutor),
+ ),
+ );
+
+ yield* Effect.gen(function* () {
+ yield* EffectWorker.runDaemonWithOptions({ concurrency: wedgedThreadIds.length }).pipe(
+ Effect.forkScoped,
+ );
+ yield* Deferred.await(wedgedStarted);
+ yield* outbox.enqueue([
+ {
+ id: `effect:${key}:other`,
+ commandId: CommandId.make(`command:${key}:other`),
+ threadId: otherThreadId,
+ request: { type: "provider-turn.start", runId: RunId.make(`run:${key}:other`) },
+ },
+ ]);
+ yield* outbox.notifyAvailable();
+
+ const other = yield* Deferred.await(otherStarted).pipe(
+ Effect.timeoutOption("1 hour"),
+ Effect.forkChild,
+ );
+ yield* TestClock.adjust("1 hour");
+ assert.isTrue(
+ Option.isSome(yield* Fiber.join(other)),
+ "another thread's provider-turn.start never ran behind starts that never return",
+ );
+ }).pipe(
+ Effect.provide(layerWorker),
+ Effect.scoped,
+ Effect.ensuring(
+ Effect.forEach(
+ [...wedgedThreadIds, otherThreadId],
+ (threadId) =>
+ outbox.cancelUnsettled({
+ threadId,
+ effectTypes: ["provider-turn.start"],
+ reason: "fixture complete",
+ }),
+ { discard: true },
+ ).pipe(Effect.orDie),
+ ),
+ );
+ }),
+ );
+
it.effect(
"persists shutdown continuation intent through the real event sink without domain events",
() =>
@@ -3864,6 +3956,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
});
const afterUpdate = yield* projectionStore.getThreadProjection(threadId);
assert.equal(afterUpdate.turnItems[0]?.ordinal, ordinals[0]);
+ assert.equal((yield* projectionStore.getThreadShell(threadId))?.itemCount, 151);
assert.equal((yield* maintenance.verify).valid, true);
yield* sql`
@@ -3878,6 +3971,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
const rebuilt = yield* maintenance.rebuild;
assert.isTrue(rebuilt.valid);
assert.lengthOf((yield* projectionStore.getThreadProjection(threadId)).turnItems, 151);
+ assert.equal((yield* projectionStore.getThreadShell(threadId))?.itemCount, 151);
}),
);
});
diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
index 6cb8356889e3b..f7773665aa9b0 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
@@ -827,6 +827,38 @@ it.effect(
yield* sql`INSERT INTO orchestration_v2_projection_context_handoffs
(context_handoff_id, thread_id, target_run_id, to_provider_thread_id, strategy, status, updated_at, payload_json)
VALUES ('obsolete-handoff', ${threadId}, 'old-run', 'old-provider-thread', 'full_thread_summary', 'ready', ${DateTime.formatIso(now)}, '{"obsolete":true}')`;
+ for (const status of [
+ "resolved_native",
+ "resolved_portable",
+ "failed",
+ "consumed",
+ "superseded",
+ ]) {
+ yield* sql`INSERT INTO orchestration_v2_projection_context_transfers
+ (context_transfer_id, source_thread_id, target_thread_id, target_run_id,
+ type, status, updated_at, payload_json)
+ VALUES (${`obsolete-transfer:${status}`}, 'old-source', ${threadId}, NULL,
+ 'merge_back', ${status}, ${DateTime.formatIso(now)}, '{"obsolete":true}')`;
+ }
+ assert.equal(
+ (yield* Effect.exit(projections.getThreadRecords(threadId, ["contextTransfers"])))._tag,
+ "Failure",
+ );
+ // A delegated task that ended and handed its result over.
+ yield* sql`INSERT INTO orchestration_v2_projection_subagents
+ (subagent_id, thread_id, run_id, parent_node_id, provider, provider_thread_id,
+ child_thread_id, origin, status, updated_at, payload_json, completion_delivery_state)
+ VALUES ('obsolete-task', ${threadId}, 'old-run', 'old-node', 'codex', 'obsolete-task-thread',
+ 'old-child', 'app_owned', 'completed', ${DateTime.formatIso(now)}, '{"obsolete":true}',
+ 'delivered')`;
+ yield* sql`INSERT INTO orchestration_v2_projection_nodes
+ (node_id, thread_id, run_id, root_node_id, kind, status, payload_json)
+ VALUES ('obsolete-task', ${threadId}, 'old-run', 'old-node', 'subagent', 'completed',
+ '{"obsolete":true}')`;
+ yield* sql`INSERT INTO orchestration_v2_projection_provider_threads
+ (provider_thread_id, thread_id, provider, status, updated_at, payload_json)
+ VALUES ('obsolete-task-thread', 'old-child', 'codex', 'idle', ${DateTime.formatIso(now)},
+ '{"obsolete":true}')`;
yield* orchestrator.dispatch({
type: "message.dispatch",
commandId: CommandId.make("dispatch-with-old-history"),
@@ -842,6 +874,15 @@ it.effect(
turnItemTypes: ["user_message"],
});
assert.isAbove(fresh.turnItems.at(-1)!.ordinal, 900);
+ // Deletion reads transfer history separately to find side chats.
+ yield* sql`DELETE FROM orchestration_v2_projection_context_transfers
+ WHERE target_thread_id = ${threadId}`;
+ // Archiving reads every task of the thread to stop the ones still running.
+ yield* sql`DELETE FROM orchestration_v2_projection_subagents
+ WHERE subagent_id = 'obsolete-task'`;
+ yield* sql`DELETE FROM orchestration_v2_projection_nodes WHERE node_id = 'obsolete-task'`;
+ yield* sql`DELETE FROM orchestration_v2_projection_provider_threads
+ WHERE provider_thread_id = 'obsolete-task-thread'`;
yield* orchestrator.dispatch({
type: "thread.archive",
commandId: CommandId.make("archive-with-old-history"),
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index 6a585abc5e5d5..c186c40f9e73a 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -34,6 +34,7 @@ import {
OrchestrationV2Command,
type OrchestrationV2InternalCommand,
type OrchestrationV2LimitRecoveryUpdate,
+ type OrchestrationV2Notification,
type OrchestrationV2ServerCommand,
type ThreadPullRequestLink,
type ThreadPullRequestWatch,
@@ -80,6 +81,7 @@ import {
import * as KeyedLock from "@t3tools/shared/KeyedLock";
import { modelSelectionsEqual } from "@t3tools/shared/model";
import { modelSwitchSince } from "./modelSwitch.ts";
+import { combinePullRequestWatchWakes, type PullRequestWatchWake } from "./pullRequestWatch.ts";
import { recordThreadOpen } from "@t3tools/shared/paletteSearch";
import {
backgroundWorkHoldsCompletion,
@@ -428,6 +430,10 @@ export interface OrchestratorV2Shape {
options: ProjectionTimelinePageOptions,
) => Effect.Effect;
readonly getMessageCount: (threadId: ThreadId) => Effect.Effect;
+ readonly getTranscriptByteFloor: (
+ threadId: ThreadId,
+ limit: number,
+ ) => Effect.Effect;
readonly getTurnItem: (input: {
readonly threadId: ThreadId;
readonly itemId: TurnItemId;
@@ -960,14 +966,21 @@ function pullRequestSnapshotEndsWatch(
);
}
-function isStalePullRequestWatchWake(
+type MonitorPullRequestStamp = NonNullable<
+ Extract["pullRequest"]
+>;
+
+function monitorPullRequestStamps(
+ source: OrchestrationV2Notification["source"] | undefined,
+): ReadonlyArray {
+ if (source?.kind !== "monitor" || source.pullRequest === undefined) return [];
+ return [source.pullRequest, ...(source.otherPullRequests ?? [])];
+}
+
+function isStalePullRequestWatchStamp(
projection: Pick,
- run: OrchestrationV2Run,
+ stamp: MonitorPullRequestStamp,
): boolean {
- const source = projection.messages.find((message) => message.id === run.userMessageId)
- ?.notification?.source;
- if (source?.kind !== "monitor" || source.pullRequest === undefined) return false;
- const stamp = source.pullRequest;
const link = threadPullRequestsOf(projection.thread).find(
(candidate) =>
candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, stamp),
@@ -980,15 +993,14 @@ function isStalePullRequestWatchWake(
}
if (link.watch === undefined) {
// A watch the server ended with a final notice still owes the wakes it queued before it.
- const endedWithNotice = projection.messages.some((message) => {
- const ending = message.notification?.source;
- return (
- ending?.kind === "monitor" &&
- ending.pullRequest?.watchEnded === true &&
- ending.pullRequest.watchStartedAt === stamp.watchStartedAt &&
- threadPullRequestKeysEqual(ending.pullRequest, stamp)
- );
- });
+ const endedWithNotice = projection.messages.some((message) =>
+ monitorPullRequestStamps(message.notification?.source).some(
+ (ending) =>
+ ending.watchEnded === true &&
+ ending.watchStartedAt === stamp.watchStartedAt &&
+ threadPullRequestKeysEqual(ending, stamp),
+ ),
+ );
return (
!endedWithNotice ||
link.endedWatch?.startedAt !== stamp.watchStartedAt ||
@@ -1002,6 +1014,58 @@ function isStalePullRequestWatchWake(
);
}
+function isStalePullRequestWatchWake(
+ projection: Pick,
+ run: OrchestrationV2Run,
+): boolean {
+ const stamps = monitorPullRequestStamps(
+ projection.messages.find((message) => message.id === run.userMessageId)?.notification?.source,
+ );
+ return (
+ stamps.length > 0 && stamps.every((stamp) => isStalePullRequestWatchStamp(projection, stamp))
+ );
+}
+
+/**
+ * A queued wake covering several pull requests, rebuilt from the ones still current, or
+ * undefined when it needs no change.
+ */
+function currentPullRequestWatchWake(
+ projection: Pick,
+ run: OrchestrationV2Run,
+): OrchestrationV2ConversationMessage | undefined {
+ const message = projection.messages.find((candidate) => candidate.id === run.userMessageId);
+ const stamps = monitorPullRequestStamps(message?.notification?.source);
+ if (message === undefined || stamps.length < 2) return undefined;
+ const current = stamps.filter((stamp) => !isStalePullRequestWatchStamp(projection, stamp));
+ const [first, ...rest] = current;
+ if (first === undefined || rest.length === stamps.length - 1) return undefined;
+ const sections = current.flatMap(({ section }) => (section === undefined ? [] : [section]));
+ if (sections.length !== current.length) return undefined;
+ const wake = combinePullRequestWatchWakes(
+ sections.map(({ text, outcome, summary }) => ({
+ text,
+ notification: { source: { kind: "monitor" }, outcome, summary },
+ })) as [PullRequestWatchWake, ...Array],
+ );
+ return {
+ ...message,
+ text: wake.text,
+ notification: {
+ ...wake.notification,
+ source: {
+ kind: "monitor",
+ pullRequest: rest.length === 0 ? withoutSection(first) : first,
+ ...(rest.length === 0 ? {} : { otherPullRequests: rest }),
+ },
+ },
+ };
+}
+
+function withoutSection({ section: _, ...stamp }: MonitorPullRequestStamp) {
+ return stamp;
+}
+
function queuedRunCancellationEvents(
projection: Pick,
run: OrchestrationV2Run,
@@ -1464,7 +1528,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});
// Runs in every command transaction; avoid reading thousands of finished leaf nodes.
- const readCommandProjection = (threadId: ThreadId) =>
+ const readCommandProjection = (
+ threadId: ThreadId,
+ recent?: ProjectionRecordFilter["sinceLastCompaction"],
+ ) =>
projectionStore
.getThreadRecords(
threadId,
@@ -1489,6 +1556,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
// Commands here read only undelivered or active tasks; a dispatcher thread keeps
// thousands of delivered ones, each carrying its full prompt.
omitSettledSubagents: true,
+ // No command reads a settled task's node or its child's provider thread.
+ omitSettledSubagentRecords: true,
+ // Commands act on pending forks and merge-backs only; consumed ones stay unread.
+ contextTransferStatuses: ["pending"],
+ ...(recent === undefined ? {} : { sinceLastCompaction: recent }),
},
)
.pipe(
@@ -1577,9 +1649,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
Effect.fnUntraced(function* (
threadId: ThreadId,
events: Ref.Ref>,
+ recent?: ProjectionRecordFilter["sinceLastCompaction"],
) {
const pending = (yield* Ref.get(events)).filter((event) => event.threadId === threadId);
- const stored = yield* Effect.option(readCommandProjection(threadId));
+ const stored = yield* Effect.option(readCommandProjection(threadId, recent));
let projection: OrchestrationV2ThreadProjection;
if (Option.isSome(stored)) {
projection = stored.value;
@@ -2111,6 +2184,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
projection = yield* readCommandProjection(threadId);
storedQueuedRun = nextQueuedRun(projection);
}
+ // A wake that outlived some of its pull requests tells only the current ones.
+ const currentWake =
+ storedQueuedRun === undefined
+ ? undefined
+ : currentPullRequestWatchWake(projection, storedQueuedRun);
+ if (storedQueuedRun !== undefined && currentWake !== undefined) {
+ const now = yield* DateTime.now;
+ yield* writeSystemEvents([
+ {
+ type: "message.updated",
+ threadId,
+ runId: storedQueuedRun.id,
+ ...(storedQueuedRun.rootNodeId === null ? {} : { nodeId: storedQueuedRun.rootNodeId }),
+ providerInstanceId: storedQueuedRun.providerInstanceId,
+ occurredAt: now,
+ payload: { ...currentWake, updatedAt: now },
+ },
+ ]);
+ const runId = storedQueuedRun.id;
+ projection = yield* readCommandProjection(threadId);
+ storedQueuedRun = projection.runs.find((run) => run.id === runId);
+ }
if (storedQueuedRun === undefined) {
return;
}
@@ -3548,57 +3643,79 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
(cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
),
);
- const key = normalizeThreadPullRequestKey(command);
- const link = threadPullRequestsOf(thread).find(
- (candidate) =>
- candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, key),
- );
+ const { others = [], wake, ...primary } = command;
+ const entries = [
+ { ...primary, includesRemarks: wake?.includesRemarks },
+ ...others.map((other) => ({
+ type: primary.type,
+ commandId: primary.commandId,
+ threadId: primary.threadId,
+ ...other,
+ })),
+ ].map((entry) => {
+ const key = normalizeThreadPullRequestKey(entry);
+ const link = threadPullRequestsOf(thread).find(
+ (candidate) =>
+ candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, key),
+ );
+ return { entry, key, link };
+ });
const inactive =
thread.archivedAt !== null ||
thread.settledOverride === "settled" ||
thread.settledAt !== null ||
isProviderNativeSubagentThread(thread);
- if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) {
+ // An idle thread dispatches immediately, bypassing queued-wake validation.
+ const outdated = entries.map(
+ ({ entry, link }) =>
+ wake?.notification.source.kind === "monitor" &&
+ entry.watch !== null &&
+ pullRequestSnapshotEndsWatch(link?.snapshot ?? null, entry.startedAt),
+ );
+ if (
+ entries.some(({ entry, link }) => link?.watch?.startedAt !== entry.startedAt) ||
+ (wake && inactive) ||
+ // One wake cannot drop the news of just some of its pull requests; the sender splits it.
+ (entries.length > 1 && outdated.some(Boolean))
+ ) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
cause: "The pull request watch ended or its thread settled while it was read.",
});
}
- yield* dispatchThreadMutation(command, events, effects);
- if (command.wake === undefined) return;
- // An idle thread dispatches immediately, bypassing queued-wake validation.
- if (
- command.wake.notification.source.kind === "monitor" &&
- command.watch !== null &&
- pullRequestSnapshotEndsWatch(link.snapshot, command.startedAt)
- ) {
- return;
- }
+ for (const { entry } of entries) {
+ const { includesRemarks: _, ...mutation } = entry;
+ yield* dispatchThreadMutation(mutation, events, effects);
+ }
+ if (wake === undefined || outdated[0]) return;
+ const { sections, ...wakeMessage } = wake;
+ const stamps = entries.map(({ entry, key }, index) => ({
+ ...key,
+ watchStartedAt: entry.startedAt,
+ headSha: entry.includesRemarks === true ? null : (entry.watch?.headSha ?? null),
+ ...(entry.watch === null ? { watchEnded: true } : {}),
+ ...(entries.length > 1 && sections?.[index] !== undefined
+ ? { section: sections[index] }
+ : {}),
+ }));
yield* dispatchMessage(
{
type: "message.dispatch",
commandId: command.commandId,
threadId: command.threadId,
- messageId: command.wake.messageId,
- text: command.wake.text,
+ messageId: wakeMessage.messageId,
+ text: wakeMessage.text,
notification: {
- ...command.wake.notification,
+ ...wakeMessage.notification,
source:
- command.wake.notification.source.kind === "monitor"
+ wake.notification.source.kind === "monitor"
? {
kind: "monitor",
- pullRequest: {
- ...key,
- watchStartedAt: command.startedAt,
- headSha:
- command.wake.includesRemarks === true
- ? null
- : (command.watch?.headSha ?? null),
- ...(command.watch === null ? { watchEnded: true } : {}),
- },
+ pullRequest: stamps[0]!,
+ ...(stamps.length > 1 ? { otherPullRequests: stamps.slice(1) } : {}),
}
- : command.wake.notification.source,
+ : wake.notification.source,
},
attachments: [],
dispatchMode: { type: "queue_after_active" },
@@ -4071,6 +4188,42 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
})
: null;
+ // Archive and settle stop idle background work; serialized commands prevent starts racing detach.
+ // Settlement rejects active or blocked runs.
+ const detachSessionIds = new Set(
+ command.type === "thread.archive" || command.type === "thread.settle"
+ ? (providerContext?.providerSessions ?? []).map((session) => session.id)
+ : command.type === "thread.metadata.update" &&
+ command.worktreePath !== undefined &&
+ command.worktreePath !== thread.worktreePath
+ ? (providerContext?.providerSessions ?? []).map((session) => session.id)
+ : command.type === "thread.runtime-mode.set"
+ ? (providerContext?.providerSessions ?? [])
+ .filter(
+ (session) => !session.capabilities.sessions.supportsRuntimeModeSwitchInSession,
+ )
+ .map((session) => session.id)
+ : (providerSwitchPlan?.releaseProviderSessionIds ?? []),
+ );
+ const liveSessions = (providerContext?.providerSessions ?? []).filter(
+ (session) =>
+ detachSessionIds.has(session.id) &&
+ session.status !== "stopped" &&
+ session.status !== "error",
+ );
+ if (command.type !== "thread.archive" && command.type !== "thread.settle") {
+ for (const session of liveSessions) {
+ if (yield* sessionHasBackgroundWork(session, providerContext!.providerThreads)) {
+ return yield* new OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause:
+ "The current session is still running background agents or commands, and this change would end them. Wait for them to finish, or press Stop, then try again.",
+ });
+ }
+ }
+ }
+
const now = yield* DateTime.now;
if (command.type === "thread.metadata.update" && command.limitRecovery != null) {
const projection = yield* loadProjectionForCommand(
@@ -4862,30 +5015,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
yield* closePendingLinkRequests({ command, events, threadId: command.threadId, now });
}
- // Archive and settle stop idle background work; serialized commands prevent starts racing detach.
- // Settlement rejects active or blocked runs.
- const detachSessionIds = new Set(
- command.type === "thread.archive" || command.type === "thread.settle"
- ? (providerContext?.providerSessions ?? []).map((session) => session.id)
- : command.type === "thread.metadata.update" &&
- command.worktreePath !== undefined &&
- command.worktreePath !== thread.worktreePath
- ? (providerContext?.providerSessions ?? []).map((session) => session.id)
- : command.type === "thread.runtime-mode.set"
- ? (providerContext?.providerSessions ?? [])
- .filter(
- (session) => !session.capabilities.sessions.supportsRuntimeModeSwitchInSession,
- )
- .map((session) => session.id)
- : (providerSwitchPlan?.releaseProviderSessionIds ?? []),
- );
- if (detachSessionIds.size > 0) {
- const liveSessions = (providerContext?.providerSessions ?? []).filter(
- (session) =>
- detachSessionIds.has(session.id) &&
- session.status !== "stopped" &&
- session.status !== "error",
- );
+ if (liveSessions.length > 0) {
yield* Effect.forEach(
liveSessions,
(session) =>
@@ -6182,7 +6312,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
events: Ref.Ref>,
effects: Ref.Ref>,
) {
- let projection = yield* getProjectionWithPendingEvents(command.threadId, events);
+ // A compacted thread reads every run and turn item, but attempts, nodes, messages and provider
+ // turns only for its recent runs and the runs this command names. An idle compaction reads
+ // in full: its busy check below looks for a delegated result's message under any old run.
+ let projection = yield* getProjectionWithPendingEvents(
+ command.threadId,
+ events,
+ command.idleCompaction === undefined
+ ? {
+ keepRunIds: [
+ command.manualContinuationOfRunId,
+ command.restartContinuationOfRunId,
+ command.usageLimitContinuationOfRunId,
+ command.steerFollowUpOfRunId,
+ ...(command.dispatchMode.type === "steer_active" ||
+ command.dispatchMode.type === "restart_active"
+ ? [command.dispatchMode.targetRunId]
+ : []),
+ ].flatMap((runId) => (runId === undefined ? [] : [runId])),
+ keepMessageId: command.messageId,
+ }
+ : undefined,
+ );
if (command.steerFollowUpOfRunId !== undefined) {
const source = projection.runs.find((run) => run.id === command.steerFollowUpOfRunId);
if (
@@ -6219,6 +6370,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});
return;
}
+ const activeProviderInstanceId = projection.providerThreads.find(
+ (candidate) => candidate.id === projection.thread.activeProviderThreadId,
+ )?.providerInstanceId;
+ // Another provider is handed what ran since it last saw the thread, which can be older than
+ // the compaction, so a provider switch reads the whole history.
+ if (
+ activeProviderInstanceId !== undefined &&
+ activeProviderInstanceId !==
+ (command.modelSelection ?? projection.thread.modelSelection).instanceId
+ ) {
+ projection = yield* getProjectionWithPendingEvents(command.threadId, events);
+ }
if (command.manualContinuationOfRunId !== undefined) {
const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId);
const limited = latestRootProviderFailure(source ?? null, projection.turnItems);
@@ -6422,15 +6585,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
projection = applyToProjection(projection, event);
}
const userMessages = projection.messages.filter((message) => message.role === "user");
- const onlyMaintenanceHistory =
- userMessages.length > 0 && userMessages.every(isNativeMaintenanceCommand);
+ // The recent runs of a compacted thread can hold only a `/compact` while an older prompt
+ // exists. Every prompt's turn item is loaded; without one, every user message is checked.
+ const onlyMaintenanceHistory = [
+ ...userMessages,
+ ...projection.turnItems.flatMap((item) => (item.type === "user_message" ? [item] : [])),
+ ].every(isNativeMaintenanceCommand)
+ ? projectionStore
+ .getThreadRecords(command.threadId, ["messages"], { messageRoles: ["user"] })
+ .pipe(
+ Effect.map(({ messages }) => [...messages, ...userMessages]),
+ Effect.map((all) => all.length > 0 && all.every(isNativeMaintenanceCommand)),
+ mapDispatchError(command),
+ )
+ : Effect.succeed(false);
if (
!isNativeMaintenanceCommand(command) &&
((command.titleSeed !== undefined &&
(yield* projectionStore
.getMessageCount(command.threadId)
.pipe(mapDispatchError(command))) === 0) ||
- (command.createdBy === "user" && onlyMaintenanceHistory))
+ (command.createdBy === "user" && (yield* onlyMaintenanceHistory)))
) {
const now = yield* DateTime.now;
const thread: OrchestrationV2AppThread = {
@@ -15403,6 +15578,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
.getMessageCount(threadId)
.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })));
}),
+ getTranscriptByteFloor: Effect.fn("OrchestratorV2.getTranscriptByteFloor")(
+ function (threadId, limit) {
+ return projectionStore
+ .getTranscriptByteFloor(threadId, limit)
+ .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })));
+ },
+ ),
getTurnItem: Effect.fn("OrchestratorV2.getTurnItem")(function (input) {
return projectionStore
.getTurnItem(input)
diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts
index e5482b2f99432..8563448071548 100644
--- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts
@@ -484,11 +484,12 @@ it.effect("selects unfinished recovery work without reading settled thread histo
WHERE thread_id = ${queued} AND ordinal = 1
`;
assert.deepEqual(yield* projections.getRecoveryThreadIds("queued-runs"), [queued]);
- assert.deepEqual(
- new Set(yield* projections.getRecoveryThreadIds("runtime")),
- new Set([queued, archived, blocked, background, outboxOnly, requestOnly]),
- );
for (const [kind, expected, index] of [
+ [
+ "runtime",
+ [queued, archived, blocked, background, outboxOnly, requestOnly],
+ "nodes_live_idx",
+ ],
["delegated-completions", [delivery], "runs_delegated_delivery_idx"],
["subagent-results", [], "threads_subagent_result_idx"],
] as const) {
@@ -498,10 +499,12 @@ it.effect("selects unfinished recovery work without reading settled thread histo
return Effect.succeed(statement);
};
assert.deepEqual(
- yield* projections
- .getRecoveryThreadIds(kind)
- .pipe(Effect.provideService(Statement.CurrentTransformer, observe)),
- expected,
+ new Set(
+ yield* projections
+ .getRecoveryThreadIds(kind)
+ .pipe(Effect.provideService(Statement.CurrentTransformer, observe)),
+ ),
+ new Set(expected),
);
const [query, params] = statements[0]!;
const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params);
@@ -1128,14 +1131,25 @@ it.effect("bounds runtime provider-thread reads by ownership and preserves recov
${ProviderThreadId.make("provider-thread:ownership:item")}, 1, 'assistant_message', 'waiting', '', '{}')
`;
const statements: Array["compile"]>> = [];
+ const nodeStatements: typeof statements = [];
const observe: Statement.Transformer = (statement) => {
const compiled = statement.compile();
if (compiled[0].includes("SELECT provider_thread.payload_json")) statements.push(compiled);
+ if (compiled[0].includes("SELECT node.payload_json")) nodeStatements.push(compiled);
return Effect.succeed(statement);
};
const projection = yield* projections
.getRuntimeRecoveryProjection(threadId)
.pipe(Effect.provideService(Statement.CurrentTransformer, observe));
+ assert.equal(nodeStatements.length, 1);
+ const nodePlan = yield* sql.unsafe<{ detail: string }>(
+ `EXPLAIN QUERY PLAN ${nodeStatements[0]![0]}`,
+ nodeStatements[0]![1],
+ );
+ assert.isTrue(
+ nodePlan.some((row) => /^SEARCH node USING INDEX .*nodes_live_idx/.test(row.detail)),
+ JSON.stringify(nodePlan),
+ );
assert.deepEqual(
projection.providerThreads.map((row) => row.id),
cases
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index 06fc3ca868ae1..fa670c448c898 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -9,6 +9,7 @@ import {
ContextTransferId,
CheckpointRef,
CheckpointScopeId,
+ ContextHandoffId,
MessageId,
type ModelSelection,
type OrchestrationV2ProviderThread,
@@ -55,6 +56,7 @@ import {
THREAD_HISTORY_PAGE_POLICY,
THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT,
} from "./threadHistoryPaging.ts";
+import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts";
import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
import { layerReaderDatabase, traceSqlRouting } from "../persistence/testkit/SqliteRouting.ts";
@@ -741,6 +743,37 @@ it.effect("idle compaction filters keep the latest run, live work and the newest
.join("\n");
assert.include(plan, `USING INDEX sqlite_autoindex_orchestration_v2_projection_${table}_1`);
}
+ // The filters every command reads with seek the rows they keep, not every row of the thread.
+ const command = traceSqlStatements();
+ yield* store
+ .getThreadRecords(threadId, ["nodes", "subagents", "turnItems", "contextTransfers"], {
+ turnItemTypes: ["user_message", "error"],
+ omitSettledLeafNodes: true,
+ omitSettledSubagents: true,
+ contextTransferStatuses: ["pending"],
+ })
+ .pipe(Effect.withTracer(command.tracer));
+ for (const [table, seeks] of [
+ ["turn_items", ["USING INDEX orchestration_v2_projection_turn_items_conversation_idx"]],
+ ["nodes", ["nodes_settled_leaf_idx (thread_id=? AND =?)"]],
+ ["subagents", ["USING COVERING INDEX orchestration_v2_projection_subagents_settled_idx"]],
+ [
+ "context_transfers",
+ [
+ "source_thread_status_idx (source_thread_id=? AND status=?)",
+ "target_thread_idx (target_thread_id=? AND status=?)",
+ ],
+ ],
+ ] as const) {
+ const query = command.statements.find((statement) =>
+ statement.includes(`FROM orchestration_v2_projection_${table}`),
+ );
+ assert.isDefined(query);
+ const plan = (yield* sql.unsafe<{ readonly detail: string }>(`EXPLAIN QUERY PLAN ${query}`))
+ .map((row) => row.detail)
+ .join("\n");
+ for (const seek of seeks) assert.include(plan, seek);
+ }
assert.deepEqual(
records.providerTurns.map((turn) => turn.id),
["turn:idle-newest"],
@@ -762,6 +795,441 @@ it.effect("idle compaction filters keep the latest run, live work and the newest
}).pipe(Effect.provide(layerTest)),
);
+// One thread holds every way a run can still matter after a compaction. A run is requested at
+// its ordinal in minutes and ends 30 seconds later, unless the table says otherwise.
+it.effect("a compacted thread reads every run, and run records only for its recent runs", () =>
+ Effect.gen(function* () {
+ const store = yield* ProjectionStore.ProjectionStoreV2;
+ const threadId = yield* addRolledBackRecoveryCandidate("compacted");
+ const created = yield* DateTime.now;
+ const at = (minutes: number) => DateTime.add(created, { minutes });
+ const runId = (ordinal: number) => RunId.make(`run:compacted:${ordinal}`);
+ const rootNodeId = (ordinal: number) => NodeId.make(`node:compacted:${ordinal}`);
+ const attemptId = (ordinal: number) => RunAttemptId.make(`attempt:compacted:${ordinal}`);
+ const providerThreadId = ProviderThreadId.make("provider-thread:compacted");
+ const messageId = (ordinal: number) => MessageId.make(`message:compacted:${ordinal}`);
+ const item = (
+ ordinal: number,
+ itemOrdinal: number,
+ status: "completed" | "failed" | "running",
+ ) => ({
+ threadId,
+ runId: runId(ordinal),
+ nodeId: rootNodeId(ordinal),
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: itemOrdinal,
+ status,
+ title: null,
+ startedAt: at(ordinal),
+ completedAt: status === "running" ? null : at(ordinal),
+ updatedAt: at(ordinal),
+ });
+ const compaction = (ordinal: number, status: "completed" | "failed" | "running") =>
+ store.apply({
+ id: EventId.make(`event:compacted:compaction-${ordinal}`),
+ type: "turn-item.updated",
+ threadId,
+ runId: runId(ordinal),
+ nodeId: rootNodeId(ordinal),
+ driver,
+ occurredAt: at(ordinal),
+ payload: {
+ ...item(ordinal, ordinal * 10 + 1, status),
+ id: TurnItemId.make(`item:compacted:compaction-${ordinal}`),
+ type: "compaction",
+ driver,
+ },
+ });
+ for (const [ordinal, status, endedAt] of [
+ [2, "queued", null],
+ [3, "completed"],
+ // A held queue ran this old run after the compactions.
+ [4, "completed", at(11.5)],
+ [5, "completed"],
+ [6, "completed"],
+ [7, "completed"],
+ [8, "completed"],
+ [9, "completed"],
+ [10, "completed"],
+ [11, "completed"],
+ [12, "completed"],
+ [13, "rolled_back"],
+ [14, "completed"],
+ [15, "running", null],
+ ] as const) {
+ const text = `Prompt ${ordinal}`;
+ const authored = { createdBy: "user", creationSource: "web" } as const;
+ const nodeId = rootNodeId(ordinal);
+ const ended = endedAt === undefined ? at(ordinal + 0.5) : endedAt;
+ yield* store.apply({
+ id: EventId.make(`event:compacted:run-${ordinal}`),
+ type: "run.created",
+ threadId,
+ runId: runId(ordinal),
+ nodeId,
+ driver,
+ providerInstanceId,
+ occurredAt: at(ordinal),
+ payload: {
+ id: runId(ordinal),
+ threadId,
+ ordinal,
+ providerInstanceId,
+ modelSelection,
+ providerThreadId: null,
+ userMessageId: messageId(ordinal),
+ rootNodeId: nodeId,
+ activeAttemptId: attemptId(ordinal),
+ status,
+ requestedAt: at(ordinal),
+ startedAt: status === "queued" ? null : at(ordinal),
+ completedAt: ended,
+ checkpointId: null,
+ contextHandoffId: null,
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:compacted:attempt-${ordinal}`),
+ type: "run-attempt.created",
+ threadId,
+ runId: runId(ordinal),
+ nodeId,
+ driver,
+ occurredAt: at(ordinal),
+ payload: {
+ id: attemptId(ordinal),
+ runId: runId(ordinal),
+ attemptOrdinal: 1,
+ rootNodeId: nodeId,
+ providerInstanceId,
+ providerThreadId,
+ providerTurnId: null,
+ reason: "initial",
+ status: ended === null ? "running" : "completed",
+ startedAt: at(ordinal),
+ completedAt: ended,
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:compacted:node-${ordinal}`),
+ type: "node.updated",
+ threadId,
+ runId: runId(ordinal),
+ nodeId,
+ driver,
+ occurredAt: at(ordinal),
+ payload: {
+ id: nodeId,
+ threadId,
+ runId: runId(ordinal),
+ rootNodeId: nodeId,
+ parentNodeId: null,
+ kind: "root_turn",
+ status: ended === null ? "running" : "completed",
+ countsForRun: true,
+ providerThreadId,
+ providerTurnId: null,
+ nativeItemRef: null,
+ runtimeRequestId: null,
+ checkpointScopeId: null,
+ startedAt: at(ordinal),
+ completedAt: ended,
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:compacted:turn-${ordinal}`),
+ type: "provider-turn.updated",
+ threadId,
+ nodeId,
+ driver,
+ occurredAt: at(ordinal),
+ payload: {
+ id: ProviderTurnId.make(`turn:compacted:${ordinal}`),
+ providerThreadId,
+ nodeId,
+ runAttemptId: attemptId(ordinal),
+ nativeTurnRef: null,
+ ordinal,
+ status: ended === null ? "running" : "completed",
+ startedAt: at(ordinal),
+ completedAt: ended,
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:compacted:message-${ordinal}`),
+ type: "message.updated",
+ threadId,
+ runId: runId(ordinal),
+ occurredAt: at(ordinal),
+ payload: {
+ ...authored,
+ id: messageId(ordinal),
+ threadId,
+ runId: runId(ordinal),
+ nodeId,
+ role: "user",
+ text,
+ attachments: [],
+ streaming: false,
+ createdAt: at(ordinal),
+ updatedAt: at(ordinal),
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:compacted:prompt-${ordinal}`),
+ type: "turn-item.updated",
+ threadId,
+ runId: runId(ordinal),
+ nodeId,
+ driver,
+ occurredAt: at(ordinal),
+ payload: {
+ ...authored,
+ ...item(ordinal, ordinal * 10, status === "queued" ? "running" : "completed"),
+ ...(status === "queued" ? { status: "pending" as const } : {}),
+ id: TurnItemId.make(`item:compacted:prompt-${ordinal}`),
+ type: "user_message",
+ messageId: messageId(ordinal),
+ inputIntent: status === "queued" ? "queued_turn" : "turn_start",
+ text,
+ attachments: [],
+ },
+ });
+ }
+ // Each collection as the ordinals of the runs it was read for. Run 1 is the helper's.
+ const ordinals = (ids: ReadonlyArray) =>
+ [...new Set(ids.map((id) => Number(id?.split(":").at(-1)) || 1))].toSorted((a, b) => a - b);
+ const read = (keepRunIds: ReadonlyArray = [], keepMessageId = messageId(0)) =>
+ store
+ .getThreadRecords(
+ threadId,
+ ["runs", "attempts", "nodes", "providerTurns", "messages", "turnItems"],
+ {
+ messageRoles: ["user"],
+ omitSettledLeafNodes: true,
+ sinceLastCompaction: { keepRunIds, keepMessageId },
+ },
+ )
+ .pipe(
+ Effect.map((records) => ({
+ runs: records.runs.length,
+ attempts: ordinals(records.attempts.map((attempt) => attempt.runId)),
+ nodes: ordinals(records.nodes.map((node) => node.runId)),
+ providerTurns: ordinals(records.providerTurns.map((turn) => turn.runAttemptId)),
+ messages: ordinals(records.messages.map((message) => message.runId)),
+ turnItems: ordinals(records.turnItems.map((row) => row.runId)),
+ })),
+ );
+ const all = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
+ // Every run and every turn item is read, so a model switch is still measured from the last
+ // prompt. Run 1, the helper's, holds only a command item.
+ const expected = (recent: ReadonlyArray) => ({
+ runs: 15,
+ attempts: recent,
+ nodes: recent,
+ providerTurns: recent,
+ messages: recent,
+ turnItems: [1, ...all],
+ });
+
+ // Nothing is compacted yet, so the whole history is current.
+ assert.deepEqual(yield* read(), expected(all));
+
+ // The window starts at the compaction's run. Queued run 2 and held run 4 are older but had
+ // not ended by then.
+ yield* compaction(7, "completed");
+ assert.deepEqual(yield* read(), expected([2, 4, 7, 8, 9, 10, 11, 12, 13, 14, 15]));
+
+ // The latest compaction wins.
+ yield* compaction(11, "completed");
+ const latest = expected([2, 4, 11, 12, 13, 14, 15]);
+ assert.deepEqual(yield* read(), latest);
+
+ // A compaction that was rolled back, failed or is still running does not move the window.
+ yield* compaction(13, "completed");
+ yield* compaction(14, "failed");
+ yield* compaction(15, "running");
+ assert.deepEqual(yield* read(), latest);
+
+ // A named run and the run that holds a re-sent message are read too.
+ assert.deepEqual(
+ yield* read([runId(3)], messageId(6)),
+ expected([2, 3, 4, 6, 11, 12, 13, 14, 15]),
+ );
+
+ // A command also skips the node and the provider thread of each settled task. Tasks ended on
+ // both sides of the compaction; one ran in the provider thread the thread's own runs use.
+ const addProviderThread = (id: ProviderThreadId, appThreadId: ThreadId) =>
+ store.apply({
+ id: EventId.make(`event:compacted:${id}`),
+ type: "provider-thread.updated",
+ threadId,
+ occurredAt: created,
+ payload: {
+ id,
+ appThreadId,
+ driver,
+ providerInstanceId,
+ providerSessionId: null,
+ ownerNodeId: null,
+ nativeThreadRef: null,
+ nativeConversationHeadRef: null,
+ lastRunOrdinal: null,
+ handoffIds: [],
+ forkedFrom: null,
+ status: "idle",
+ firstRunOrdinal: null,
+ createdAt: created,
+ updatedAt: created,
+ },
+ });
+ yield* addProviderThread(providerThreadId, threadId);
+ for (const [name, ordinal, delivery] of [
+ ["old-delivered", 5, "delivered"],
+ ["old-pending", 5, "pending"],
+ ["old-own-thread", 5, "delivered"],
+ ["recent-delivered", 12, "delivered"],
+ ["recent-pending", 12, "pending"],
+ ] as const) {
+ const id = NodeId.make(`node:compacted:task:${name}`);
+ const taskProviderThreadId =
+ name === "old-own-thread"
+ ? providerThreadId
+ : ProviderThreadId.make(`provider-thread:compacted:task:${name}`);
+ if (taskProviderThreadId !== providerThreadId) {
+ yield* addProviderThread(
+ taskProviderThreadId,
+ ThreadId.make(`thread:compacted:child:${name}`),
+ );
+ }
+ const task = {
+ id,
+ threadId,
+ runId: runId(ordinal),
+ parentNodeId: rootNodeId(ordinal),
+ status: "completed",
+ startedAt: at(ordinal),
+ completedAt: at(ordinal),
+ } as const;
+ yield* store.apply({
+ id: EventId.make(`event:compacted:task:${name}`),
+ type: "subagent.updated",
+ threadId,
+ runId: runId(ordinal),
+ nodeId: id,
+ driver,
+ providerInstanceId,
+ occurredAt: at(ordinal),
+ payload: {
+ ...task,
+ origin: "app_owned",
+ createdBy: "agent",
+ driver,
+ providerInstanceId,
+ providerThreadId: taskProviderThreadId,
+ childThreadId: null,
+ nativeTaskRef: null,
+ prompt: "Do the work",
+ title: name,
+ model: null,
+ completionDelivery: { state: delivery, observedByRunId: null },
+ result: null,
+ updatedAt: at(ordinal),
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:compacted:task-node:${name}`),
+ type: "node.updated",
+ threadId,
+ runId: runId(ordinal),
+ nodeId: id,
+ driver,
+ occurredAt: at(ordinal),
+ payload: {
+ ...task,
+ rootNodeId: rootNodeId(ordinal),
+ kind: "subagent",
+ countsForRun: false,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ runtimeRequestId: null,
+ checkpointScopeId: null,
+ },
+ });
+ }
+ const names = (ids: ReadonlyArray) => ids.map((id) => id.split(":").at(-1)).toSorted();
+ const readWithTasks = (filter: ProjectionStore.ProjectionRecordFilter) =>
+ store
+ .getThreadRecords(
+ threadId,
+ [
+ "runs",
+ "attempts",
+ "nodes",
+ "providerThreads",
+ "providerTurns",
+ "messages",
+ "turnItems",
+ ],
+ { messageRoles: ["user"], omitSettledLeafNodes: true, ...filter },
+ )
+ .pipe(
+ Effect.map((records) => ({
+ runs: names(records.runs.map((run) => run.id)),
+ turnItems: names(records.turnItems.map((row) => row.id)),
+ attempts: names(records.attempts.map((attempt) => attempt.id)),
+ rootNodes: names(
+ records.nodes.flatMap((node) => (node.kind === "root_turn" ? [node.id] : [])),
+ ),
+ taskNodes: names(
+ records.nodes.flatMap((node) => (node.kind === "subagent" ? [node.id] : [])),
+ ),
+ providerThreads: names(records.providerThreads.map((thread) => thread.id)),
+ providerTurns: names(records.providerTurns.map((turn) => turn.id)),
+ messages: names(records.messages.map((message) => message.id)),
+ })),
+ );
+ const window = {
+ sinceLastCompaction: { keepRunIds: [runId(3)], keepMessageId: messageId(6) },
+ };
+ const windowed = yield* readWithTasks(window);
+ const unsettled = yield* readWithTasks({ omitSettledSubagentRecords: true });
+ const both = yield* readWithTasks({ ...window, omitSettledSubagentRecords: true });
+ // Each filter alone keeps records the other one drops.
+ assert.deepEqual(windowed.taskNodes, ["recent-delivered", "recent-pending"]);
+ assert.lengthOf(windowed.providerThreads, 5);
+ assert.deepEqual(unsettled.taskNodes, ["old-pending", "recent-pending"]);
+ assert.deepEqual(unsettled.providerThreads, ["compacted", "old-pending", "recent-pending"]);
+ assert.lengthOf(unsettled.rootNodes, all.length);
+ assert.lengthOf(unsettled.runs, 15);
+ // Together: every run, every turn item and the provider thread the runs use stay, and the
+ // windowed records are read for the recent and named runs, less each settled task's node.
+ const recentRuns = ["11", "12", "13", "14", "15", "2", "3", "4", "6"];
+ assert.deepEqual(both, {
+ ...unsettled,
+ attempts: recentRuns,
+ rootNodes: recentRuns,
+ taskNodes: ["recent-pending"],
+ providerTurns: recentRuns,
+ messages: recentRuns,
+ });
+ const keptUnsettled = new Map(Object.entries(unsettled));
+ assert.deepEqual(
+ Object.fromEntries(
+ Object.entries(windowed).map(([key, kept]) => [
+ key,
+ kept.filter((name) => keptUnsettled.get(key)?.includes(name)),
+ ]),
+ ),
+ both,
+ );
+ }).pipe(Effect.provide(layerTest)),
+);
+
it.effect("record reads filter notification sources without dropping matching message bodies", () =>
Effect.gen(function* () {
const store = yield* ProjectionStore.ProjectionStoreV2;
@@ -828,6 +1296,16 @@ it.effect("record reads filter notification sources without dropping matching me
plan.some((detail) => detail.includes("turn_items_user_message_idx")),
plan.join("\n"),
);
+ // With every user item loaded, the items give the order and the lookup does not run.
+ const withItems = traceSqlStatements();
+ const ordered = yield* store
+ .getThreadRecords(threadId, ["messages", "turnItems"], {
+ messageRoles: ["user"],
+ turnItemTypes: ["user_message"],
+ })
+ .pipe(Effect.withTracer(withItems.tracer));
+ assert.deepEqual(ordered.messages, userMessages.messages);
+ assert.isFalse(withItems.statements.some((statement) => statement.includes("MIN(ordinal)")));
assert.lengthOf((yield* store.getThreadRecords(threadId, ["messages"])).messages, 4);
assert.lengthOf(
(yield* store.getThreadRecords(threadId, ["messages"], {
@@ -1532,6 +2010,8 @@ const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () {
const commandReadsSkipSettledLeafNodes = Effect.gen(function* () {
const store = yield* ProjectionStore.ProjectionStoreV2;
+ const sql = yield* SqlClient.SqlClient;
+ const sharedSessionId = ProviderSessionId.make("session:settled-subagents:shared");
const now = yield* DateTime.now;
const threadId = yield* addRolledBackRecoveryCandidate("settled-leaf-nodes");
const settledRun = (yield* store.getThreadProjection(threadId)).runs[0]!;
@@ -1742,7 +2222,7 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () {
createdBy: "agent",
driver,
providerInstanceId,
- providerThreadId: null,
+ providerThreadId: ProviderThreadId.make(`provider-thread:settled-subagents:${name}`),
childThreadId,
nativeTaskRef: null,
prompt: "Do the work",
@@ -1758,6 +2238,110 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () {
updatedAt: now,
},
});
+ // Each task has a node in this thread and a provider thread in its child thread.
+ yield* store.apply({
+ id: EventId.make(`event:settled-subagents:node:${name}`),
+ type: "node.updated",
+ threadId,
+ runId: run.id,
+ nodeId: id,
+ driver,
+ occurredAt: now,
+ payload: {
+ id,
+ threadId,
+ runId: run.id,
+ rootNodeId: run.rootNodeId!,
+ parentNodeId: run.rootNodeId,
+ kind: "subagent",
+ status,
+ countsForRun: false,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ runtimeRequestId: null,
+ checkpointScopeId: null,
+ startedAt: now,
+ completedAt: status === "running" ? null : now,
+ },
+ });
+ yield* store.apply({
+ id: EventId.make(`event:settled-subagents:provider-thread:${name}`),
+ type: "provider-thread.updated",
+ threadId,
+ occurredAt: now,
+ payload: {
+ id: ProviderThreadId.make(`provider-thread:settled-subagents:${name}`),
+ appThreadId: ThreadId.make(`thread:settled-subagents:child:${name}`),
+ driver,
+ providerInstanceId,
+ // One settled task ran in a provider session this thread also uses.
+ providerSessionId: name === "acknowledged" ? sharedSessionId : null,
+ ownerNodeId: null,
+ nativeThreadRef: null,
+ nativeConversationHeadRef: null,
+ lastRunOrdinal: null,
+ handoffIds: [],
+ forkedFrom: null,
+ status: "idle",
+ firstRunOrdinal: null,
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+ }
+ yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings
+ (provider_session_id, thread_id) VALUES (${sharedSessionId}, ${threadId})`;
+ // A command drops the node and the provider thread of each task it drops. The provider thread
+ // in the shared session stays: detaching that session probes it for background work.
+ const kept = ["active-run-delivered", "claimed", "no-delivery", "pending-delivery", "running"];
+ const commandTrace = traceSqlStatements();
+ const commandRecords = yield* store
+ .getThreadRecords(threadId, ["nodes", "providerThreads"], {
+ omitSettledLeafNodes: true,
+ omitSettledSubagentRecords: true,
+ })
+ .pipe(Effect.withTracer(commandTrace.tracer));
+ assert.deepEqual(
+ commandRecords.nodes
+ .flatMap((node) => (node.kind === "subagent" ? [node.id.split(":").at(-1)] : []))
+ .toSorted(),
+ // A subagent node that no task record names stays.
+ [...kept, "settled-subagent"],
+ );
+ assert.deepEqual(
+ commandRecords.providerThreads.map((thread) => thread.id.split(":").at(-1)).toSorted(),
+ ["acknowledged", ...kept],
+ );
+ assert.lengthOf(
+ (yield* store.getThreadRecords(threadId, ["providerThreads"])).providerThreads,
+ subagents.length,
+ );
+ // Both reads stay inside their indexes: no scan of the thread's nodes or provider threads.
+ for (const [table, seeks] of [
+ [
+ "nodes",
+ [
+ "SEARCH node USING INTEGER PRIMARY KEY",
+ "SEARCH settled USING COVERING INDEX",
+ "SEARCH settled_node USING COVERING INDEX",
+ "subagents_settled_idx (thread_id=?)",
+ ],
+ ],
+ [
+ "provider_threads",
+ ["MULTI-INDEX OR", "provider_threads_session_idx (provider_session_id=?)"],
+ ],
+ ] as const) {
+ const query = commandTrace.statements.find((statement) =>
+ statement.includes(`FROM orchestration_v2_projection_${table}`),
+ );
+ assert.isDefined(query);
+ const plan = (yield* sql.unsafe<{ readonly detail: string }>(`EXPLAIN QUERY PLAN ${query}`))
+ .map((row) => row.detail)
+ .join("\n");
+ for (const seek of seeks) assert.include(plan, seek);
+ assert.notInclude(plan, `SCAN orchestration_v2_projection_${table}`);
}
const subagentNames = (records: {
readonly subagents: ReadonlyArray<{ readonly title: string | null }>;
@@ -2536,20 +3120,156 @@ it.effect("SQL snapshot windows keep cards for requests still pending", () =>
projection: snapshot.projection,
snapshotSequence: snapshot.snapshotSequence,
});
- assert.isTrue(bounded.hasMoreHistory);
- assert.notInclude(
- bounded.projection.turnItems.map((item) => item.id),
- TurnItemId.make("item:pending-request-window:question:resolved"),
+ assert.isTrue(bounded.hasMoreHistory);
+ assert.notInclude(
+ bounded.projection.turnItems.map((item) => item.id),
+ TurnItemId.make("item:pending-request-window:question:resolved"),
+ );
+ for (const projection of [snapshot.projection, bounded.projection]) {
+ const visibleIds = projection.visibleTurnItems.map((row) => row.sourceItemId);
+ assert.notInclude(visibleIds, TurnItemId.make("item:pending-request-window:message:1"));
+ assert.includeMembers(
+ projection.turnItems.map((item) => item.id),
+ pendingCardIds,
+ );
+ for (const id of pendingCardIds) assert.notInclude(visibleIds, id);
+ }
+ }).pipe(Effect.provide(layerTest)),
+);
+
+it.effect("SQL snapshot windows leave handoff bodies in SQLite without changing the wire", () =>
+ Effect.gen(function* () {
+ const store = yield* ProjectionStore.ProjectionStoreV2;
+ const now = yield* DateTime.now;
+ const threadId = ThreadId.make("thread:handoff-window");
+ yield* store.apply({
+ id: EventId.make("event:handoff-window:thread"),
+ type: "thread.created",
+ threadId,
+ occurredAt: now,
+ payload: {
+ createdBy: "user",
+ creationSource: "web",
+ id: threadId,
+ projectId: ProjectId.make("project:handoff-window"),
+ title: "Handoff window",
+ providerInstanceId,
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
+ forkedFrom: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ lastVisitedAt: null,
+ deletedAt: null,
+ },
+ });
+ const providerThreadId = ProviderThreadId.make("provider-thread:handoff-window");
+ const earlierItemId = TurnItemId.make("item:handoff-window:earlier");
+ const handoff = (id: string, status: "ready" | "superseded") => ({
+ id: ContextHandoffId.make(`handoff:handoff-window:${id}`),
+ transferId: null,
+ threadId,
+ targetRunId: RunId.make("run:handoff-window"),
+ fromProviderThreadIds: [providerThreadId],
+ toProviderThreadId: providerThreadId,
+ coveredRunOrdinals: { from: 1, to: 1 },
+ strategy: "full_thread_summary" as const,
+ status,
+ summaryMessageId: null,
+ summaryText: `Summary ${id}`,
+ history: {
+ messages: [
+ {
+ role: "user" as const,
+ text: "Earlier request ".repeat(1_000),
+ threadId,
+ runId: null,
+ itemId: earlierItemId,
+ providerThreadId,
+ status: "completed",
+ kind: "user_message",
+ },
+ ],
+ coverage: "full",
+ omittedItems: 0,
+ },
+ delivery: { nativeThreadId: "native", status: "injected" as const, itemIds: [earlierItemId] },
+ createdByProviderInstanceId: providerInstanceId,
+ createdAt: now,
+ updatedAt: now,
+ });
+ // The window keeps a ready handoff and one its rows show; the third is outside it.
+ const handoffs = [
+ handoff("ready", "ready"),
+ handoff("shown", "superseded"),
+ handoff("old", "superseded"),
+ ];
+ for (const payload of handoffs) {
+ yield* store.apply({
+ id: EventId.make(`event:${payload.id}`),
+ type: "context-handoff.updated",
+ threadId,
+ occurredAt: now,
+ payload,
+ });
+ }
+ yield* store.apply({
+ id: EventId.make("event:handoff-window:item"),
+ type: "turn-item.updated",
+ threadId,
+ occurredAt: now,
+ payload: {
+ id: TurnItemId.make("item:handoff-window:handoff"),
+ threadId,
+ runId: null,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 1,
+ status: "completed",
+ title: null,
+ startedAt: now,
+ completedAt: now,
+ updatedAt: now,
+ type: "handoff",
+ contextHandoffId: handoffs[1]!.id,
+ fromProviderThreadIds: [providerThreadId],
+ toProviderThreadId: providerThreadId,
+ fromProviderInstanceIds: [providerInstanceId],
+ toProviderInstanceId: providerInstanceId,
+ strategy: "full_thread_summary",
+ },
+ });
+
+ const window = yield* store.getThreadSnapshotWindow(threadId, { rowLimit: 10 });
+ const windowHandoffs = window.projection.contextHandoffs;
+ assert.deepEqual(
+ windowHandoffs.map((row) => row.id),
+ [handoffs[0]!.id, handoffs[1]!.id],
);
- for (const projection of [snapshot.projection, bounded.projection]) {
- const visibleIds = projection.visibleTurnItems.map((row) => row.sourceItemId);
- assert.notInclude(visibleIds, TurnItemId.make("item:pending-request-window:message:1"));
- assert.includeMembers(
- projection.turnItems.map((item) => item.id),
- pendingCardIds,
- );
- for (const id of pendingCardIds) assert.notInclude(visibleIds, id);
+ for (const row of windowHandoffs) {
+ assert.isUndefined(row.history);
+ assert.isUndefined(row.delivery);
}
+ const full = yield* store.getThreadSnapshot(threadId);
+ assert.deepEqual(full.projection.contextHandoffs, handoffs);
+ assert.deepEqual(
+ buildBoundedThreadStreamSnapshot(window),
+ buildBoundedThreadStreamSnapshot({
+ ...window,
+ projection: { ...window.projection, contextHandoffs: handoffs.slice(0, 2) },
+ }),
+ );
}).pipe(Effect.provide(layerTest)),
);
@@ -2931,14 +3651,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
const threadId = ThreadId.make("thread:bounded-sql-history");
// The raw SQL edits below stand in for committed writes, which always append a thread event.
let revision = 0;
- const historyPage = (...args: Parameters) =>
+ const committed = (read: Effect.Effect ) =>
sql`INSERT INTO orchestration_events (
event_id, aggregate_kind, stream_id, stream_version, event_type,
occurred_at, actor_kind, payload_json, metadata_json, application_event_version
) VALUES (
${`event:bounded-sql-history:revision:${++revision}`}, 'thread', ${threadId}, ${revision},
'turn-item.updated', ${nowIso}, 'user', '{}', '{}', 2
- )`.pipe(Effect.andThen(projectionStore.getThreadHistoryPage(...args)));
+ )`.pipe(Effect.andThen(read));
+ const historyPage = (...args: Parameters) =>
+ committed(projectionStore.getThreadHistoryPage(...args));
yield* projectionStore.apply({
id: EventId.make("event:bounded-sql-history:thread"),
type: "thread.created",
@@ -3330,6 +4052,134 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
WHERE thread_id = ${threadId} AND ordinal IN (500, 580)
`;
+ // Older pages and timeline pages read the rows near the page, not the whole history. A
+ // full index that another read left cached gives the same page.
+ const pageStatements: Array]> = [];
+ const recordPageStatements = (read: Effect.Effect ) =>
+ committed(
+ read.pipe(
+ Effect.provideService(Statement.CurrentTransformer, (statement) => {
+ pageStatements.push(statement.compile());
+ return Effect.succeed(statement);
+ }),
+ ),
+ );
+ const olderCursor = encodeThreadHistoryCursor({
+ snapshotSequence: 0,
+ sourceThreadId: threadId,
+ sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:990"),
+ position: 989,
+ });
+ const ordinalsFrom = (first: number, length: number) =>
+ Array.from({ length }, (_, index) => first + index);
+ for (const workMode of [undefined, "deferred"] as const) {
+ const read = (throughEntryId?: string) =>
+ projectionStore.getThreadHistoryPage(
+ threadId,
+ olderCursor,
+ throughEntryId,
+ false,
+ workMode,
+ );
+ const page = yield* recordPageStatements(read());
+ assert.deepEqual(
+ page.items.map((row) => row.item.ordinal),
+ ordinalsFrom(915, 75),
+ );
+ assert.strictEqual(decodeThreadHistoryCursor(page.nextCursor!).p, 914);
+ // A jump to an entry reads the full index and leaves it cached. With work, it scans
+ // payloads for inline output only in its page's runs, not in a run after its cursor.
+ const scannedRuns: Array = [];
+ yield* read("turn-item:bounded-sql-history:950").pipe(
+ Effect.provideService(Statement.CurrentTransformer, (statement) => {
+ const [query, params] = statement.compile();
+ if (query.includes("LIKE '%htmlRender%'")) scannedRuns.push(String(params[0]));
+ return Effect.succeed(statement);
+ }),
+ );
+ assert.deepEqual(
+ [
+ scannedRuns.length,
+ scannedRuns.some((runs) => runs.includes('"run:bounded-sql-history:950"')),
+ scannedRuns.some((runs) => runs.includes('"run:bounded-sql-history:995"')),
+ ],
+ workMode === undefined ? [0, false, false] : [1, true, false],
+ );
+ assert.deepEqual(yield* read(), page);
+ }
+ for (const [view, ordinals] of [
+ ["activity", ordinalsFrom(902, 20)],
+ ["messages", [1000]],
+ ] as const) {
+ const read = projectionStore.getTimelinePage(threadId, {
+ afterPosition: 900,
+ limit: 20,
+ view,
+ });
+ const page = yield* recordPageStatements(read);
+ assert.deepEqual(
+ page.items.map((row) => [row.item.ordinal, row.position]),
+ ordinals.map((ordinal) => [ordinal, ordinal - 1]),
+ );
+ assert.strictEqual(page.totalItems, 1000);
+ // An attachment lookup reads the full index and leaves it cached.
+ yield* projectionStore.getTimelinePage(threadId, { attachmentId: "absent", limit: 1 });
+ assert.deepEqual(yield* read, page);
+ }
+ {
+ const pagePath = "file:/history-page-steps?vfs=memdb";
+ using copy = new NodeSqlite.DatabaseSync(pagePath);
+ yield* sql`VACUUM INTO ${pagePath}`;
+ // Only statements that count positions may grow with the history, and those read an
+ // index alone. Every other statement costs the same when the rows below the page are gone.
+ const countsPositions = ([query]: readonly [string, ReadonlyArray]) =>
+ /COUNT\(\*\)|OFFSET/.test(query);
+ for (const [query, params] of pageStatements.filter(countsPositions)) {
+ const plan = copy
+ .prepare(`EXPLAIN QUERY PLAN ${query}`)
+ .all(...(params as ReadonlyArray))
+ .map((row) => String(row.detail))
+ .filter((detail) => detail.includes("orchestration_v2_projection_turn_items"));
+ assert.isTrue(
+ plan.every((detail) => detail.includes("COVERING INDEX")),
+ `${query}\n${plan.join("\n")}`,
+ );
+ }
+ const rowReads = pageStatements.filter((statement) => !countsPositions(statement));
+ const rowReadSteps = () =>
+ rowReads.reduce((steps, [query, params]) => {
+ const statement = copy.prepare(query);
+ statement.all(...(params as ReadonlyArray));
+ return steps + statement.stat("vmStep");
+ }, 0);
+ const fullSteps = rowReadSteps();
+ assert.isAbove(fullSteps, 0);
+ copy.exec(
+ "DELETE FROM orchestration_v2_projection_turn_items WHERE ordinal BETWEEN 2 AND 400",
+ );
+ assert.isAtMost(fullSteps, rowReadSteps() * 1.1);
+ }
+ // Rows that a rolled-back run hides below the page still shift every position above them.
+ const setOldRunStatus = (status: "rolled_back" | "completed") => sql`
+ UPDATE orchestration_v2_projection_runs
+ SET status = ${status}, payload_json = json_set(payload_json, '$.status', ${status})
+ WHERE thread_id = ${threadId} AND ordinal BETWEEN 100 AND 109`;
+ yield* setOldRunStatus("rolled_back");
+ const shiftedPage = yield* historyPage(threadId, olderCursor);
+ assert.strictEqual(decodeThreadHistoryCursor(shiftedPage.nextCursor!).p, 904);
+ const shiftedTimeline = yield* committed(
+ projectionStore.getTimelinePage(threadId, {
+ afterPosition: 900,
+ limit: 1,
+ view: "activity",
+ }),
+ );
+ assert.deepEqual(
+ [shiftedTimeline.totalItems, shiftedTimeline.items[0]?.item.ordinal],
+ [990, 912],
+ );
+ yield* setOldRunStatus("completed");
+
const retainedRequestPlan = yield* sql<{ readonly detail: string }>`
EXPLAIN QUERY PLAN
WITH selected AS (
@@ -3485,23 +4335,115 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
window.projection.visibleTurnItems.find((row) => row.item.deferredWork)?.item.ordinal,
3,
);
- const page = yield* historyPage(
+ const newestCursor = encodeThreadHistoryCursor({
+ snapshotSequence: 0,
+ sourceThreadId: threadId,
+ sourceItemId: "turn-item:bounded-sql-history:1000",
+ position: 1000,
+ });
+ const page = yield* historyPage(threadId, newestCursor, undefined, false, "deferred");
+ assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork);
+ // Tool payloads hold megabytes, so a page that reads the full index, as a jump to an
+ // entry does, looks for inline output only in its own runs.
+ const pageRead = traceSqlStatements();
+ yield* historyPage(
+ threadId,
+ newestCursor,
+ "turn-item:bounded-sql-history:2",
+ false,
+ "deferred",
+ ).pipe(Effect.withTracer(pageRead.tracer));
+ const payloadScans = pageRead.statements.filter((statement) =>
+ statement.includes("LIKE '%htmlRender%'"),
+ );
+ assert.lengthOf(payloadScans, 1);
+ assert.include(payloadScans[0], "item.run_id IS json_extract(wanted.value, '$.id')");
+ }
+ // Until its run is scanned, a cursor on inline output is hidden inside a work group.
+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool',
+ payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', 't3-code.delegate_task')
+ WHERE thread_id = ${threadId} AND ordinal = 4`;
+ const sharedRunId = "run:bounded-sql-history:1";
+ // A work page of rows that have no run leaves their inline output out too.
+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = NULL
+ WHERE thread_id = ${threadId} AND ordinal BETWEEN 3 AND 6`;
+ const runlessWork = yield* historyPage(
+ threadId,
+ encodeThreadHistoryCursor({
+ snapshotSequence: 0,
+ sourceThreadId: threadId,
+ sourceItemId: "turn-item:bounded-sql-history:6",
+ position: 6,
+ inclusive: true,
+ }),
+ "turn-item:bounded-sql-history:3",
+ false,
+ "work",
+ );
+ assert.deepEqual(
+ runlessWork.items.map((row) => row.item.ordinal),
+ [3, 5, 6],
+ );
+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = ${sharedRunId}
+ WHERE thread_id = ${threadId} AND ordinal BETWEEN 3 AND 6`;
+ // A stale position selects from the cursor row itself, or from more than a page after it
+ // once the later rows are another run's and cannot fold.
+ for (const [position, laterStatus, laterRun] of [
+ [0, "completed", sharedRunId],
+ [999, "running", "run:bounded-sql-history:300"],
+ ] as const) {
+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET status = ${laterStatus},
+ run_id = ${laterRun},
+ payload_json = json_set(payload_json, '$.status', ${laterStatus}, '$.runId', ${laterRun})
+ WHERE thread_id = ${threadId} AND ordinal >= 300`;
+ const beforeInlineOutput = yield* historyPage(
threadId,
encodeThreadHistoryCursor({
snapshotSequence: 0,
sourceThreadId: threadId,
- sourceItemId: "turn-item:bounded-sql-history:1000",
- position: 1000,
+ sourceItemId: "turn-item:bounded-sql-history:4",
+ position,
}),
undefined,
false,
"deferred",
);
- assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork);
+ assert.deepEqual(
+ beforeInlineOutput.items.map((row) => row.item.ordinal),
+ [1, 2, 3],
+ );
}
+ // A cursor whose row is gone falls back to its position. Inline output between the page
+ // and that position can belong to a run with no other row there.
+ yield* sql`UPDATE orchestration_v2_projection_turn_items
+ SET status = CASE WHEN ordinal <= 700 THEN 'running' ELSE 'completed' END,
+ run_id = CASE WHEN ordinal <= 700 THEN 'run:bounded-sql-history:300' ELSE ${sharedRunId} END,
+ payload_json = json_set(payload_json,
+ '$.status', CASE WHEN ordinal <= 700 THEN 'running' ELSE 'completed' END,
+ '$.runId', CASE WHEN ordinal <= 700 THEN 'run:bounded-sql-history:300' ELSE ${sharedRunId} END)
+ WHERE thread_id = ${threadId} AND ordinal >= 100`;
+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool',
+ payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', 't3-code.delegate_task')
+ WHERE thread_id = ${threadId} AND ordinal = 720`;
+ const beforeMissingCursor = yield* historyPage(
+ threadId,
+ encodeThreadHistoryCursor({
+ snapshotSequence: 0,
+ sourceThreadId: threadId,
+ sourceItemId: "turn-item:bounded-sql-history:gone",
+ position: 800,
+ }),
+ undefined,
+ false,
+ "deferred",
+ );
+ assert.strictEqual(beforeMissingCursor.items.at(-1)?.item.ordinal, 720);
+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET status = 'completed',
+ run_id = ${sharedRunId},
+ payload_json = json_set(payload_json, '$.status', 'completed', '$.runId', ${sharedRunId})
+ WHERE thread_id = ${threadId} AND ordinal >= 100`;
yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution',
- payload_json = json_set(payload_json, '$.type', 'command_execution') WHERE thread_id = ${threadId} AND ordinal = 2`;
- const sharedRunId = "run:bounded-sql-history:1";
+ payload_json = json_set(payload_json, '$.type', 'command_execution') WHERE thread_id = ${threadId} AND ordinal IN (2, 4, 720)`;
for (const [ordinal, status] of [
[0, "completed"],
[1, "superseded"],
@@ -4749,6 +5691,339 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
}),
);
+ it.effect("a page read from a window equals the page read from the full index", () =>
+ Effect.gen(function* () {
+ const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
+ const sql = yield* SqlClient.SqlClient;
+ const now = yield* DateTime.now;
+ const nowIso = DateTime.formatIso(now);
+ // Seeded, so every run builds the same threads.
+ let seed = 0x5eed;
+ const random = () => {
+ seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
+ return seed / 2 ** 32;
+ };
+ const chance = (probability: number) => random() < probability;
+ const spans: Array = [];
+ const tracer = Tracer.make({
+ span(options) {
+ spans.push(options.name);
+ return new Tracer.NativeSpan(options);
+ },
+ });
+ let windowReads = 0;
+ let foldedGroups = 0;
+
+ for (let round = 0; round < 4; round += 1) {
+ const threadId = ThreadId.make(`thread:window-equivalence:${round}`);
+ const created = {
+ id: EventId.make(`event:window-equivalence:${round}`),
+ type: "thread.created",
+ threadId,
+ occurredAt: now,
+ payload: {
+ createdBy: "user",
+ creationSource: "web",
+ id: threadId,
+ projectId: ProjectId.make("project:window-equivalence"),
+ title: "Window equivalence",
+ providerInstanceId,
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
+ forkedFrom: null,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ lastVisitedAt: null,
+ deletedAt: null,
+ },
+ } satisfies Parameters[0];
+ yield* projectionStore.apply(created);
+ // One run of each kind in a random order, then an active run. Every other thread has
+ // one user turn and then only wake turns.
+ // The last thread is a long run that a late notification wakes, then enough one-row
+ // runs that a page stops at the raw turn limit only if it counts that wake as a turn.
+ const kinds =
+ round === 3
+ ? ["late wake", ...Array.from({ length: 148 }, () => "one row")]
+ : [
+ "rolled back",
+ "cancelled queued turn",
+ "replaced attempt",
+ "stopped",
+ "stopped",
+ "folds work",
+ "folds work",
+ "short",
+ "short",
+ "short",
+ ]
+ .map((kind) => ({ kind, order: random() }))
+ .toSorted((left, right) => left.order - right.order)
+ .map(({ kind }) => kind);
+ const agentDriven = round === 1;
+ const runs: Array = [];
+ const attempts: Array = [];
+ const items: Array = [];
+ const tiedWithHidden: Array = [];
+ const repliesInStoppedRuns: Array = [];
+ let ordinal = 0;
+ for (const [index, kind] of [...kinds, "active"].entries()) {
+ const run = index + 1;
+ const runId = `run:window-equivalence:${round}:${run}`;
+ const nodeId = `node:window-equivalence:${round}:${run}`;
+ const status =
+ kind === "active"
+ ? "running"
+ : kind === "rolled back"
+ ? "rolled_back"
+ : kind === "cancelled queued turn"
+ ? "cancelled"
+ : kind === "short" && chance(0.5)
+ ? "interrupted"
+ : "completed";
+ runs.push({
+ id: runId,
+ threadId,
+ ordinal: run,
+ providerInstanceId,
+ modelSelection,
+ providerThreadId: null,
+ userMessageId: `message:window-equivalence:${round}:${run}`,
+ rootNodeId: nodeId,
+ activeAttemptId: null,
+ status,
+ requestedAt: nowIso,
+ startedAt: nowIso,
+ completedAt: nowIso,
+ checkpointId: null,
+ contextHandoffId: null,
+ });
+ attempts.push({
+ id: `attempt:window-equivalence:${round}:${run}`,
+ runId,
+ attemptOrdinal: 1,
+ rootNodeId: nodeId,
+ providerInstanceId,
+ providerThreadId: "provider-thread:window-equivalence",
+ providerTurnId: null,
+ reason: "initial",
+ status: kind === "replaced attempt" || chance(0.2) ? "superseded" : "completed",
+ startedAt: nowIso,
+ completedAt: nowIso,
+ });
+ // The first row after a rolled-back run shares the ordinal of that run's last row.
+ let tie = kinds[index - 1] === "rolled back";
+ if (tie)
+ tiedWithHidden.push(
+ TurnItemId.make(`turn-item:window-equivalence:${round}:${items.length}`),
+ );
+ const add = (type: string, fields: Record = {}) => {
+ if (ordinal === 0 || !(tie || chance(0.04))) ordinal += 1;
+ tie = false;
+ items.push({
+ id: `turn-item:window-equivalence:${round}:${items.length}`,
+ threadId,
+ runId,
+ nodeId,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal,
+ status: "completed",
+ title: null,
+ startedAt: nowIso,
+ completedAt: nowIso,
+ updatedAt: nowIso,
+ type,
+ ...fields,
+ });
+ };
+ const userMessage = (inputIntent: string) =>
+ add("user_message", {
+ createdBy: "user",
+ creationSource: "web",
+ messageId: `message:window-equivalence:${round}:${items.length}`,
+ inputIntent,
+ text: "Turn",
+ attachments: [],
+ });
+ const notification = () =>
+ add("notification", {
+ source: { kind: "system" },
+ outcome: "completed",
+ summary: "Wake",
+ });
+ const wakes =
+ kind === "one row"
+ ? run % 10 !== 0
+ : kind !== "late wake" && (agentDriven ? run > 1 : chance(0.2));
+ if (wakes) notification();
+ else userMessage(kind === "cancelled queued turn" ? "queued_turn" : "turn_start");
+ // A stopped run never folds, so it is long enough for page ends to fall inside it.
+ const work =
+ kind === "late wake"
+ ? 420 + Math.floor(random() * 40)
+ : kind === "stopped"
+ ? 320 + Math.floor(random() * 60)
+ : kind === "folds work"
+ ? 155 + Math.floor(random() * 60)
+ : kind === "one row"
+ ? 0
+ : Math.floor(random() * 30);
+ for (let row = 0; row < work; row += 1) {
+ if (chance(0.5)) add("command_execution", { input: "echo", output: "ok", exitCode: 0 });
+ else add("reasoning", { text: "", streaming: false });
+ // A reply deep in a stopped run: a page that ends on it has enough work below it
+ // to fold, and only the stop request above it says that it does not.
+ const reply = kind === "stopped" && row === 200;
+ if (reply)
+ repliesInStoppedRuns.push(`turn-item:window-equivalence:${round}:${items.length}`);
+ if (reply || chance(0.02))
+ add("assistant_message", {
+ messageId: `message:window-equivalence:${round}:${items.length}`,
+ text: "Reply",
+ streaming: false,
+ });
+ if (chance(0.01)) notification();
+ if (chance(0.004) && !agentDriven) userMessage("steer");
+ }
+ if (kind === "late wake") notification();
+ if (kind === "stopped") add("run_interrupt_request", { message: "Stop" });
+ if (kind === "replaced attempt" || (kind === "stopped" && chance(0.5)))
+ add("run_interrupt_result", { message: "Stopped" });
+ if (kind === "short" && chance(0.3))
+ add("error", {
+ status: "failed",
+ failure: { class: "unknown", message: "Failed", code: null, retryable: false },
+ });
+ }
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_runs (
+ run_id, thread_id, ordinal, provider, provider_thread_id, status,
+ requested_at, completed_at, payload_json
+ )
+ SELECT json_extract(value, '$.id'), ${threadId}, json_extract(value, '$.ordinal'), 'codex',
+ NULL, json_extract(value, '$.status'), ${nowIso}, ${nowIso}, value
+ FROM json_each(${encodeUnknownJsonString(runs)})`;
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_run_attempts (
+ attempt_id, thread_id, run_id, attempt_ordinal, root_node_id, provider,
+ provider_instance_id, provider_thread_id, provider_turn_id, status, payload_json
+ )
+ SELECT json_extract(value, '$.id'), ${threadId}, json_extract(value, '$.runId'), 1,
+ json_extract(value, '$.rootNodeId'), ${providerInstanceId}, ${providerInstanceId},
+ json_extract(value, '$.providerThreadId'), NULL, json_extract(value, '$.status'), value
+ FROM json_each(${encodeUnknownJsonString(attempts)})`;
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_turn_items (
+ turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
+ parent_item_id, ordinal, type, status, updated_at, payload_json
+ )
+ SELECT json_extract(value, '$.id'), ${threadId}, json_extract(value, '$.runId'),
+ json_extract(value, '$.nodeId'), NULL, NULL, NULL, json_extract(value, '$.ordinal'),
+ json_extract(value, '$.type'), json_extract(value, '$.status'), ${nowIso}, value
+ FROM json_each(${encodeUnknownJsonString(items)})`;
+
+ // Applying an event drops the thread's cached index, as each event of a run does, so
+ // each of these reads a window.
+ const requests: Array<{
+ readonly read: Effect.Effect;
+ readonly page: unknown;
+ }> = [];
+ const windowed = (read: Effect.Effect ) =>
+ Effect.gen(function* () {
+ yield* projectionStore.apply(created);
+ spans.length = 0;
+ const page = yield* read.pipe(Effect.withTracer(tracer));
+ if (spans.includes("ProjectionStore.readTimelineWindow")) windowReads += 1;
+ requests.push({ read, page });
+ return page;
+ });
+ const history = (
+ cursor: string,
+ throughEntryId?: string,
+ conversationOnly?: boolean,
+ workMode?: "deferred" | "work",
+ ) =>
+ projectionStore.getThreadHistoryPage(
+ threadId,
+ cursor,
+ throughEntryId,
+ conversationOnly,
+ workMode,
+ );
+ const timeline = (options: ProjectionStore.ProjectionTimelinePageOptions) =>
+ projectionStore.getTimelinePage(threadId, options);
+
+ const { totalItems } = yield* windowed(timeline({ limit: 0 }));
+ const newest = (yield* windowed(
+ timeline({ view: "activity", afterPosition: totalItems - 2, limit: 1 }),
+ )).items[0]!;
+ const firstCursor = encodeThreadHistoryCursor({
+ snapshotSequence: 0,
+ sourceThreadId: threadId,
+ sourceItemId: newest.sourceItemId,
+ position: newest.position,
+ });
+ const groups = new Map();
+ for (const [conversationOnly, workMode] of [
+ [false, "deferred"],
+ [false, undefined],
+ [true, undefined],
+ ] as const) {
+ for (let cursor: string | null = firstCursor; cursor !== null;) {
+ const page: OrchestrationV2ThreadHistoryPage = yield* windowed(
+ history(cursor, undefined, conversationOnly, workMode),
+ );
+ for (const { item } of page.items)
+ if (item.deferredWork !== undefined) groups.set(item.id, item.deferredWork.cursor);
+ cursor = page.nextCursor;
+ }
+ }
+ for (const sourceItemId of repliesInStoppedRuns) {
+ const cursor = encodeThreadHistoryCursor({
+ snapshotSequence: 0,
+ sourceThreadId: threadId,
+ sourceItemId,
+ position: 0,
+ });
+ yield* windowed(history(cursor, undefined, false, "deferred"));
+ }
+ foldedGroups += groups.size;
+ for (const [anchorId, groupCursor] of groups) {
+ for (let cursor: string | null = groupCursor; cursor !== null;)
+ cursor = (yield* windowed(history(cursor, anchorId, false, "work"))).nextCursor;
+ }
+ for (const view of ["messages", "activity"] as const) {
+ for (const afterPosition of [-1, Math.floor(totalItems / 3), totalItems - 5])
+ yield* windowed(timeline({ view, afterPosition, limit: 20 }));
+ }
+ for (const itemId of [newest.sourceItemId, ...tiedWithHidden])
+ yield* windowed(timeline({ itemId, limit: 1 }));
+
+ // An attachment lookup and a jump to an entry read the full index and leave it cached,
+ // so the same requests now come from it.
+ yield* timeline({ attachmentId: "absent", limit: 1 });
+ yield* history(firstCursor, "absent", false, "deferred");
+ spans.length = 0;
+ for (const { read, page } of requests)
+ assert.deepEqual(yield* read.pipe(Effect.withTracer(tracer)), page);
+ assert.notInclude(spans, "ProjectionStore.readTimelineWindow");
+ }
+ assert.isAtLeast(foldedGroups, 2);
+ assert.isAbove(windowReads, 60);
+ }),
+ );
+
it.effect("scopes actionable provider state to the requested bounded thread", () =>
Effect.gen(function* () {
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
@@ -5180,7 +6455,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
}),
);
- it.effect("looks up shell stop requests through the interrupt request index", () =>
+ it.effect("reads shell runs and item counts without walking a thread's history", () =>
Effect.gen(function* () {
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
const sql = yield* SqlClient.SqlClient;
@@ -5189,20 +6464,44 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
yield* projectionStore
.getShellSnapshot({ projectId, location: "active" })
.pipe(Effect.withTracer(tracer));
+ const planOf = (statement: string) =>
+ sql
+ .unsafe<{ readonly detail: string }>(`EXPLAIN QUERY PLAN ${statement}`)
+ .pipe(Effect.map((rows) => rows.map((row) => row.detail)));
const threadQuery = statements.find((statement) =>
statement.includes("AS latest_run_stop_requested"),
);
assert.isDefined(threadQuery);
- const stopRequestLookups = (yield* sql.unsafe<{ readonly detail: string }>(
- `EXPLAIN QUERY PLAN ${threadQuery}`,
- ))
- .map((row) => row.detail)
- .filter((detail) => detail.startsWith("SEARCH stop_request "));
+ const plan = yield* planOf(threadQuery);
+ const stopRequestLookups = plan.filter((detail) => detail.startsWith("SEARCH stop_request "));
assert.lengthOf(stopRequestLookups, 2);
for (const lookup of stopRequestLookups) {
assert.include(lookup, "orchestration_v2_projection_turn_items_stop_request_idx");
}
+ // The active, activity, and pending-secret lookups; walking the ordinal index instead
+ // reads every run of an idle thread.
+ const activeRunLookups = plan.filter(
+ (detail) => detail.startsWith("SEARCH r ") && detail.includes("(thread_id=?"),
+ );
+ assert.lengthOf(activeRunLookups, 4);
+ for (const lookup of activeRunLookups) {
+ assert.include(lookup, "orchestration_v2_projection_runs_thread_status_idx");
+ }
+ const lastExecutedIndex = plan.findIndex((detail) =>
+ detail.includes("orchestration_v2_projection_runs_last_executed_idx"),
+ );
+ assert.notStrictEqual(lastExecutedIndex, -1);
+ // The lookup that follows the search is the sort it no longer needs.
+ assert.notInclude(plan[lastExecutedIndex + 1] ?? "", "USE TEMP B-TREE");
+
+ const countQueries = statements.filter((statement) => statement.includes("item_count"));
+ assert.isNotEmpty(countQueries);
+ for (const countQuery of countQueries) {
+ for (const detail of yield* planOf(countQuery)) {
+ assert.notInclude(detail, "orchestration_v2_projection_turn_items");
+ }
+ }
}),
);
@@ -5457,6 +6756,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
},
});
}
+ let runWrites = 0;
const applyRun = (
threadId: ThreadId,
suffix: string,
@@ -5465,9 +6765,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
) => {
const runId = RunId.make(`run:shell-item-counts:${suffix}`);
const rootNodeId = NodeId.make(`node:shell-item-counts:${suffix}`);
+ runWrites += 1;
return projectionStore
.apply({
- id: EventId.make(`event:shell-item-counts:${suffix}:run`),
+ id: EventId.make(`event:shell-item-counts:${suffix}:run:${runWrites}`),
type: "run.created",
threadId,
runId,
@@ -5494,11 +6795,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
})
.pipe(Effect.as(runId));
};
- let ordinal = 0;
- const applyItem = (threadId: ThreadId, runId: RunId | null) => {
- ordinal += 1;
+ let items = 0;
+ let writes = 0;
+ // Passing `rewrite` writes that earlier item again, possibly under another run.
+ const applyItem = (threadId: ThreadId, runId: RunId | null, rewrite?: number) => {
+ const ordinal = rewrite ?? (items += 1);
+ writes += 1;
return projectionStore.apply({
- id: EventId.make(`event:shell-item-counts:item:${ordinal}`),
+ id: EventId.make(`event:shell-item-counts:write:${writes}`),
type: "turn-item.updated",
threadId,
...(runId === null ? {} : { runId }),
@@ -5551,6 +6855,28 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
);
assert.equal(shell?.itemCount, itemCount);
}
+
+ // Writing an item again keeps its count; moving it to another run moves the count.
+ const itemCountOf = (threadId: ThreadId) =>
+ projectionStore.getThreadShell(threadId).pipe(Effect.map((shell) => shell?.itemCount));
+ yield* applyItem(busyThreadId, completedRunId, 3);
+ assert.equal(yield* itemCountOf(busyThreadId), 4);
+ yield* applyItem(busyThreadId, rolledBackRunId, 3);
+ assert.equal(yield* itemCountOf(busyThreadId), 3);
+ yield* applyItem(busyThreadId, null, 3);
+ assert.equal(yield* itemCountOf(busyThreadId), 4);
+
+ // A run's rollback uncounts its items, and writing the run again counts them back.
+ yield* applyRun(busyThreadId, "busy-completed", 1, true);
+ assert.equal(yield* itemCountOf(busyThreadId), 3);
+ yield* applyRun(busyThreadId, "busy-completed", 1, false);
+ assert.equal(yield* itemCountOf(busyThreadId), 4);
+ // Items written before their run exist count once it does.
+ const lateRunId = RunId.make("run:shell-item-counts:late");
+ yield* applyItem(otherThreadId, lateRunId);
+ assert.equal(yield* itemCountOf(otherThreadId), 1);
+ yield* applyRun(otherThreadId, "late", 2, false);
+ assert.equal(yield* itemCountOf(otherThreadId), 2);
}),
);
@@ -8454,9 +9780,19 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
const boundedFork = yield* projectionStore
.getThreadSnapshotWindow(targetThreadId, { rowLimit: 2 })
.pipe(Effect.withTracer(tracer));
- assert.lengthOf(
- statements.filter((statement) => statement.includes("WITH RECURSIVE retained")),
- 1,
+ const retained = statements.filter((statement) =>
+ statement.includes("WITH RECURSIVE retained"),
+ );
+ assert.lengthOf(retained, 1);
+ // The settled-leaf index also starts with thread_id, and through it this lookup fetches
+ // every node of the thread to check its status.
+ const retainedPlan = (yield* sql.unsafe<{ readonly detail: string }>(
+ `EXPLAIN QUERY PLAN ${retained[0]}`,
+ )).map((row) => row.detail);
+ assert.match(
+ retainedPlan.find((detail) => detail.startsWith("SEARCH live ")) ?? "",
+ /nodes_live_idx/,
+ JSON.stringify(retainedPlan),
);
assert.lengthOf(
statements.filter((statement) => statement.includes("SELECT node.payload_json")),
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 797427ede42ee..22d8cfe25ddbb 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -90,6 +90,7 @@ import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
+import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";
import type * as Statement from "effect/sql/Statement";
@@ -98,12 +99,15 @@ import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp";
import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts";
import { readableSearchText } from "./messageSearchIndex.ts";
import {
+ isFoldableWorkItem,
isThreadHistoryUserTurn,
isConversationHistoryItem,
THREAD_HISTORY_MAX_RAW_TURNS,
missingInterruptRequests,
DEFERRED_WORK_ITEM_TYPES,
+ decodeThreadHistoryCursor,
deferWorkHistoryRows,
+ findCursorEndIndex,
selectDeferredWorkPage,
encodeThreadHistoryCursor,
olderHistoryPagePolicy,
@@ -112,6 +116,8 @@ import {
THREAD_HISTORY_PAGE_POLICY,
THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT,
THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT,
+ type SelectTimelinePageResult,
+ type ThreadHistoryPrefix,
} from "./threadHistoryPaging.ts";
export class ProjectionStoreApplyEventError extends Schema.TaggedError()(
@@ -372,6 +378,22 @@ export interface ProjectionRecordFilter {
* run is active or `except` names them.
*/
readonly omitSettledSubagents?: true | { readonly except: ReadonlyArray };
+ /**
+ * Also skips the provider thread of each subagent that `omitSettledSubagents: true` skips, and
+ * with `omitSettledLeafNodes` its node. A provider thread that shares a provider session with
+ * the thread stays.
+ */
+ readonly omitSettledSubagentRecords?: boolean;
+ /**
+ * For a compacted thread: reads attempts, nodes, messages and provider turns only for runs from
+ * the latest completed compaction's run on, runs that had not ended when that run was
+ * requested, and the runs named here. Runs and turn items are not narrowed.
+ */
+ readonly sinceLastCompaction?: {
+ readonly keepRunIds: ReadonlyArray;
+ /** Keeps the run that already holds this message, for a message that is sent again. */
+ readonly keepMessageId: MessageId;
+ };
}
// A fork inherits its source's history before it has messages of its own,
@@ -436,6 +458,8 @@ export interface ActiveShellIndexEntry extends Pick<
> {
/** False only when no run, request, work item, or watch can make the shell live. */
readonly mayBeLive: boolean;
+ /** Whether it delegated a task to a linked environment, whose clients nest that task under it. */
+ readonly hasRemoteTasks: boolean;
}
export interface OlderSettledThreadsQuery {
@@ -472,6 +496,11 @@ export interface ProjectionStoreV2Shape {
options: ProjectionTimelinePageOptions,
) => Effect.Effect;
readonly getMessageCount: (threadId: ThreadId) => Effect.Effect;
+ /** Bytes of tool output a transcript export is sure to hold, or 0 when it can fit in `limit`. */
+ readonly getTranscriptByteFloor: (
+ threadId: ThreadId,
+ limit: number,
+ ) => Effect.Effect;
readonly getSubagentCount: (threadId: ThreadId) => Effect.Effect;
/** Child threads of the thread's app-owned tasks, in task order, without decoding the tasks. */
readonly listAppOwnedChildThreadIds: (
@@ -716,6 +745,7 @@ export interface ProjectionStoreV2Shape {
},
ProjectionStoreV2Error
>;
+ /** Context handoffs come without `history` and `delivery`; no window reader uses them. */
readonly getThreadSnapshotWindow: (
threadId: ThreadId,
options: {
@@ -1107,7 +1137,7 @@ type ShellItemCountRow = {
const encodeTimelineSources = Schema.encodeEffect(
Schema.fromJsonString(
- Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.String })),
+ Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.NullOr(Schema.String) })),
),
);
const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
@@ -1116,6 +1146,15 @@ const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema
const matchesNothing = (...filters: ReadonlyArray | undefined>) =>
filters.some((values) => values?.length === 0);
+// A typed item read that names no tool type fits in migration 082's conversation index. A run or
+// node filter is left alone: it already reads through a narrower index.
+const readsConversationItemsOnly = (filter: ProjectionRecordFilter | undefined) =>
+ filter?.turnItemTypes !== undefined &&
+ filter.turnItemRunId === undefined &&
+ filter.turnItemRunIds === undefined &&
+ filter.turnItemNodeIds === undefined &&
+ filter.turnItemTypes.every((type) => DEFERRED_WORK_ITEM_TYPES.every((tool) => tool !== type));
+
const encodeThreadPayload = Schema.encodeEffect(
Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema),
);
@@ -2072,6 +2111,32 @@ export const layer: Layer.Layer =
`(${DEFERRED_WORK_ITEM_TYPES.map((type) => `'${type}'`).join(", ")})`,
);
+ // Other node indexes also start with thread_id. Without this hint SQLite reads a thread's live
+ // nodes through one of them and fetches every node of the thread to check its status. The
+ // query must filter on the same status list as the index of migration 060.
+ const LIVE_NODES_INDEX = sql.literal("INDEXED BY orchestration_v2_projection_nodes_live_idx");
+
+ // An ended task whose completion was acknowledged, delivered or disposed, outside an active run.
+ const settledSubagent = (row: Statement.Fragment) => sql`(
+ ${row}.status IN ('completed', 'failed', 'cancelled', 'interrupted')
+ AND COALESCE(${row}.completion_delivery_state, '') IN ('acknowledged', 'delivered', 'disposed')
+ AND NOT EXISTS (
+ SELECT 1 FROM orchestration_v2_projection_runs AS run
+ WHERE run.run_id = ${row}.run_id
+ AND run.status IN ('preparing', 'queued', 'starting', 'running', 'waiting')
+ )
+ )`;
+
+ // The settled index holds every column the check reads, so the settled tasks are dropped
+ // there and their rows, each with a full prompt, stay unread.
+ const unsettledSubagentRowids = (threadId: ThreadId) => sql`
+ SELECT candidate.rowid
+ FROM orchestration_v2_projection_subagents AS candidate
+ INDEXED BY orchestration_v2_projection_subagents_settled_idx
+ WHERE candidate.thread_id = ${threadId}
+ AND NOT ${settledSubagent(sql`candidate`)}
+ `;
+
// A todo list is never closed when its run ends, so a long thread holds an active one per
// old run. Counting those as live work drags each run's nodes and subagents into every window.
const todoListOfSettledRun = (row: Statement.Fragment) => sql`(
@@ -3391,6 +3456,49 @@ export const layer: Layer.Layer =
const threadColumn = (ids: ReadonlyArray | undefined, column = "thread_id") =>
sql.literal(ids === undefined ? column : `+${column}`);
+ /** The runs whose records a new message reads. Null when nothing was compacted. */
+ const runIdsSinceLastCompaction = Effect.fn("ProjectionStore.runIdsSinceLastCompaction")(
+ function* (
+ threadId: ThreadId,
+ keep: NonNullable,
+ ) {
+ // A held queue can run out of ordinal order, so a run that ended after the compaction's
+ // run was requested is recent too. Every run row is read anyway, so this scan reads no
+ // extra pages.
+ const recent = yield* sql<{ readonly run_id: RunId }>`
+ WITH compaction AS (
+ SELECT run.ordinal, run.requested_at
+ FROM orchestration_v2_projection_turn_items AS item
+ INDEXED BY orchestration_v2_projection_turn_items_conversation_idx
+ INNER JOIN orchestration_v2_projection_runs AS run ON run.run_id = item.run_id
+ WHERE item.thread_id = ${threadId}
+ AND item.type = 'compaction' AND item.status = 'completed'
+ AND item.type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES}
+ AND run.status <> 'rolled_back'
+ ORDER BY item.ordinal DESC
+ LIMIT 1
+ )
+ SELECT run.run_id
+ FROM compaction
+ INNER JOIN orchestration_v2_projection_runs AS run ON run.thread_id = ${threadId}
+ WHERE run.ordinal >= compaction.ordinal
+ OR run.completed_at IS NULL
+ OR run.completed_at >= compaction.requested_at
+ OR run.status IN ('queued', 'preparing', 'starting', 'running', 'waiting')
+ UNION
+ SELECT message.run_id
+ FROM compaction
+ INNER JOIN orchestration_v2_projection_messages AS message
+ ON message.message_id = ${keep.keepMessageId}
+ WHERE message.thread_id = ${threadId} AND message.run_id IS NOT NULL
+ `;
+ // The compaction's own run is always recent, so no rows means no compaction.
+ return recent.length === 0
+ ? null
+ : encodeIdList([...recent.map((row) => row.run_id), ...keep.keepRunIds]);
+ },
+ );
+
const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")(
function* (
threadId: ThreadId,
@@ -3408,6 +3516,19 @@ export const layer: Layer.Layer =
if (!threadRow) {
return yield* new ProjectionStoreThreadNotFoundError({ threadId });
}
+ const recentRunIds =
+ window === undefined && filter?.sinceLastCompaction !== undefined
+ ? yield* runIdsSinceLastCompaction(threadId, filter.sinceLastCompaction)
+ : null;
+ const inRecentRuns = (column: string) =>
+ recentRunIds === null
+ ? sql``
+ : sql`AND ${sql.literal(column)} IN (SELECT value FROM json_each(${recentRunIds}))`;
+ // Without the hint SQLite reads every row of the thread to find the recent runs' rows.
+ const recentRunIndex = (index: string) =>
+ recentRunIds === null
+ ? sql``
+ : sql`INDEXED BY ${sql.literal(`orchestration_v2_projection_${index}`)}`;
type WindowPayloadRow = PayloadRow & {
readonly work_last_id?: string | null;
@@ -3429,7 +3550,14 @@ export const layer: Layer.Layer =
? yield* sql`
SELECT payload_json
FROM orchestration_v2_projection_turn_items
- WHERE thread_id = ${threadId}
+ ${
+ // A type list of conversation items reads them from the conversation index.
+ // Any other plan fetches every tool call of the thread to check its type.
+ readsConversationItemsOnly(filter)
+ ? sql`INDEXED BY orchestration_v2_projection_turn_items_conversation_idx
+ WHERE thread_id = ${threadId} AND type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES}`
+ : sql`WHERE thread_id = ${threadId}`
+ }
${filter?.turnItemRequestIds === undefined ? sql`` : sql`AND json_extract(payload_json, '$.requestId') IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRequestIds)}))`}
${filter?.turnItemTypes === undefined ? sql`` : sql`AND type IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemTypes)}))`}
${filter?.turnItemStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemStatuses)}))`}
@@ -3505,6 +3633,7 @@ export const layer: Layer.Layer =
SELECT value FROM json_each(${cohortJson("nodeId")})
UNION
SELECT node_id FROM orchestration_v2_projection_nodes AS live
+ ${LIVE_NODES_INDEX}
WHERE thread_id = ${threadId} AND status IN ('pending','starting','running','waiting')
AND NOT ${todoListOfSettledRun(sql`live`)}
UNION
@@ -3678,6 +3807,7 @@ export const layer: Layer.Layer =
SELECT payload_json
FROM orchestration_v2_projection_run_attempts
WHERE thread_id = ${threadId}
+ ${inRecentRuns("run_id")}
ORDER BY run_id ASC, attempt_ordinal ASC
`
: sql`
@@ -3694,22 +3824,48 @@ export const layer: Layer.Layer =
? sql`
SELECT payload_json
FROM orchestration_v2_projection_nodes AS node
- WHERE ${threadColumn(filter?.nodeIds, "node.thread_id")} = ${threadId}
- ${filter?.nodeIds === undefined ? sql`` : sql`AND node.node_id IN (SELECT value FROM json_each(${encodeIdList(filter.nodeIds)}))`}
- ${
- filter?.omitSettledLeafNodes === true
- ? sql`AND NOT (
- node.kind IN ('tool_call', 'assistant_message', 'reasoning')
- AND node.status IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back')
- AND node.run_id IS NOT NULL
- AND NOT EXISTS (
- SELECT 1 FROM orchestration_v2_projection_runs AS run
- WHERE run.run_id = node.run_id
- AND run.status IN ('preparing', 'queued', 'starting', 'running', 'waiting')
+ WHERE ${
+ filter?.omitSettledLeafNodes === true
+ ? // Seeks the kept nodes. One filter over the thread reads every settled leaf to
+ // drop it. The first predicate must match migration 083's index expression. A
+ // node's run is always a run of its own thread, and an active run is recent.
+ sql`node.rowid IN (
+ SELECT rowid FROM orchestration_v2_projection_nodes
+ ${recentRunIndex("nodes_thread_run_idx")}
+ WHERE thread_id = ${threadId}
+ AND (kind IN ('tool_call', 'assistant_message', 'reasoning')
+ AND status IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back')
+ AND run_id IS NOT NULL) = 0
+ ${inRecentRuns("run_id")}
+ ${
+ filter.omitSettledSubagentRecords === true
+ ? // A subagent's node shares its id. Both indexes hold every column
+ // read here, so no settled subagent or node row is fetched.
+ sql`EXCEPT
+ SELECT settled_node.rowid
+ FROM orchestration_v2_projection_subagents AS settled
+ INDEXED BY orchestration_v2_projection_subagents_thread_idx
+ CROSS JOIN orchestration_v2_projection_nodes AS settled_node
+ ON settled_node.node_id = settled.subagent_id
+ WHERE settled.thread_id = ${threadId}
+ AND settled.rowid NOT IN (${unsettledSubagentRowids(threadId)})`
+ : sql``
+ }
+ UNION ALL
+ SELECT rowid FROM orchestration_v2_projection_nodes
+ WHERE thread_id = ${threadId}
+ AND run_id IN (
+ SELECT run_id FROM orchestration_v2_projection_runs
+ WHERE thread_id = ${threadId}
+ AND status IN ('preparing', 'queued', 'starting', 'running', 'waiting')
)
- )`
- : sql``
- }
+ AND kind IN ('tool_call', 'assistant_message', 'reasoning')
+ AND status IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back')
+ )`
+ : sql`${threadColumn(filter?.nodeIds, "node.thread_id")} = ${threadId}
+ ${inRecentRuns("node.run_id")}`
+ }
+ ${filter?.nodeIds === undefined ? sql`` : sql`AND node.node_id IN (SELECT value FROM json_each(${encodeIdList(filter.nodeIds)}))`}
ORDER BY COALESCE(node.started_at, ''), node.node_id ASC
`
: sql`
@@ -3737,22 +3893,14 @@ export const layer: Layer.Layer =
${filter?.subagentIds === undefined ? sql`` : sql`AND subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentIds)}))`}
${filter?.subagentRunIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentRunIds)}))`}
${
- filter?.omitSettledSubagents !== undefined
- ? sql`AND (${
- filter.omitSettledSubagents === true
- ? sql`0`
- : sql`subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.omitSettledSubagents.except)}))`
- } OR NOT (
- subagent.status IN ('completed', 'failed', 'cancelled', 'interrupted')
- AND COALESCE(subagent.completion_delivery_state, '')
- IN ('acknowledged', 'delivered', 'disposed')
- AND NOT EXISTS (
- SELECT 1 FROM orchestration_v2_projection_runs AS run
- WHERE run.run_id = subagent.run_id
- AND run.status IN ('preparing', 'queued', 'starting', 'running', 'waiting')
- )
- ))`
- : sql``
+ filter?.omitSettledSubagents === true
+ ? sql`AND subagent.rowid IN (${unsettledSubagentRowids(threadId)})`
+ : filter?.omitSettledSubagents !== undefined
+ ? sql`AND (
+ subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.omitSettledSubagents.except)}))
+ OR NOT ${settledSubagent(sql`subagent`)}
+ )`
+ : sql``
}
ORDER BY COALESCE(started_at, ''), subagent_id ASC
`
@@ -3796,12 +3944,35 @@ export const layer: Layer.Layer =
FROM orchestration_v2_projection_provider_threads
WHERE (thread_id = ${threadId}
OR owner_node_id IN (${nodesOwningProviderThreads(threadId)})
- OR provider_thread_id IN (
+ OR ${
+ filter?.omitSettledSubagentRecords === true
+ ? // A dispatcher thread holds thousands of settled tasks. Their provider
+ // threads belong to the child threads; only a session this thread also
+ // uses is probed for background work when the thread detaches it.
+ sql`provider_thread_id IN (
+ SELECT provider_thread_id
+ FROM orchestration_v2_projection_subagents
+ WHERE rowid IN (${unsettledSubagentRowids(threadId)})
+ AND provider_thread_id IS NOT NULL
+ )
+ OR (provider_session_id IN (
+ SELECT provider_session_id
+ FROM orchestration_v2_projection_provider_session_bindings
+ WHERE thread_id = ${threadId}
+ )
+ AND EXISTS (
+ SELECT 1 FROM orchestration_v2_projection_subagents AS owner
+ WHERE owner.provider_thread_id =
+ orchestration_v2_projection_provider_threads.provider_thread_id
+ AND owner.thread_id = ${threadId}
+ ))`
+ : sql`provider_thread_id IN (
SELECT provider_thread_id
FROM orchestration_v2_projection_subagents
WHERE thread_id = ${threadId}
AND provider_thread_id IS NOT NULL
- ))
+ )`
+ })
${filter?.providerThreadIds === undefined ? sql`` : sql`AND provider_thread_id IN (SELECT value FROM json_each(${encodeIdList(filter.providerThreadIds)}))`}
ORDER BY COALESCE(first_run_ordinal, 0), provider_thread_id ASC
`
@@ -3823,6 +3994,13 @@ export const layer: Layer.Layer =
SELECT payload_json
FROM orchestration_v2_projection_provider_turns
WHERE thread_id = ${threadId}
+ ${
+ recentRunIds === null
+ ? sql``
+ : sql`AND run_attempt_id IN (
+ SELECT attempt_id FROM orchestration_v2_projection_run_attempts
+ WHERE thread_id = ${threadId} ${inRecentRuns("run_id")})`
+ }
${filter?.providerTurnAttemptIds === undefined ? sql`` : sql`AND run_attempt_id IN (SELECT value FROM json_each(${encodeIdList(filter.providerTurnAttemptIds)}))`}
${filter?.providerTurnThreadIds === undefined ? sql`` : sql`AND provider_thread_id IN (SELECT value FROM json_each(${encodeIdList(filter.providerTurnThreadIds)}))`}
${
@@ -3868,7 +4046,9 @@ export const layer: Layer.Layer =
: window === undefined
? sql`
SELECT payload_json FROM orchestration_v2_projection_messages
- WHERE ${threadColumn(filter?.messageIds)} = ${threadId}
+ ${recentRunIndex("messages_run_idx")}
+ WHERE ${recentRunIds === null ? threadColumn(filter?.messageIds) : sql.literal("thread_id")} = ${threadId}
+ ${inRecentRuns("run_id")}
${filter?.messageIds === undefined ? sql`` : sql`AND message_id IN (SELECT value FROM json_each(${encodeIdList(filter.messageIds)}))`}
${filter?.messageRoles === undefined ? sql`` : sql`AND role IN (SELECT value FROM json_each(${encodeIdList(filter.messageRoles)}))`}
${filter?.monitorNotificationsOnly === true ? sql`AND json_extract(payload_json, '$.notification.source.kind') = 'monitor'` : sql``}
@@ -3967,7 +4147,9 @@ export const layer: Layer.Layer =
WHERE thread_id = ${threadId} ORDER BY rowid ASC
`
: sql`
- SELECT payload_json FROM orchestration_v2_projection_context_handoffs AS handoff
+ -- Megabytes on a long thread, and projectContextHandoffForWire drops both.
+ SELECT json_remove(payload_json, '$.history', '$.delivery') AS payload_json
+ FROM orchestration_v2_projection_context_handoffs AS handoff
WHERE handoff.thread_id = ${threadId}
AND (
handoff.status IN ('pending', 'ready')
@@ -3984,8 +4166,21 @@ export const layer: Layer.Layer =
? sql`
SELECT payload_json
FROM orchestration_v2_projection_context_transfers
- WHERE (source_thread_id = ${threadId} OR target_thread_id = ${threadId})
- ${filter?.contextTransferStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.contextTransferStatuses)}))`}
+ WHERE ${
+ filter?.contextTransferStatuses === undefined
+ ? sql`(source_thread_id = ${threadId} OR target_thread_id = ${threadId})`
+ : // Each side seeks its (thread, status) index. One OR over both sides checks the
+ // status on every row the thread ever sent or received.
+ sql`rowid IN (
+ SELECT rowid FROM orchestration_v2_projection_context_transfers
+ WHERE source_thread_id = ${threadId}
+ AND status IN (SELECT value FROM json_each(${encodeIdList(filter.contextTransferStatuses)}))
+ UNION
+ SELECT rowid FROM orchestration_v2_projection_context_transfers
+ WHERE target_thread_id = ${threadId}
+ AND status IN (SELECT value FROM json_each(${encodeIdList(filter.contextTransferStatuses)}))
+ )`
+ }
ORDER BY rowid ASC
`
: sql`
@@ -4031,7 +4226,24 @@ export const layer: Layer.Layer =
decodeRows(decodeContextTransferPayload, threadId)(contextTransferRows),
]);
const messageOrdinals = new Map();
- if (fields !== undefined && fields.includes("messages") && messages.length > 1) {
+ // The literal `type = 'user_message'` lets SQLite pick the partial user-message index.
+ const userOnly = messages.every((message) => message.role === "user");
+ // The loaded items already order the messages when they hold every item the lookup reads.
+ const loadsEveryItemOf = (type: OrchestrationV2TurnItem["type"]) =>
+ window === undefined &&
+ (fields === undefined || fields.includes("turnItems")) &&
+ filter?.turnItemRequestIds === undefined &&
+ filter?.turnItemStatuses === undefined &&
+ filter?.turnItemRunId === undefined &&
+ filter?.turnItemRunIds === undefined &&
+ filter?.turnItemNodeIds === undefined &&
+ (filter?.turnItemTypes === undefined || filter.turnItemTypes.includes(type));
+ if (
+ fields !== undefined &&
+ fields.includes("messages") &&
+ messages.length > 1 &&
+ !(loadsEveryItemOf("user_message") && (userOnly || loadsEveryItemOf("assistant_message")))
+ ) {
// Broad role reads keep the original query to avoid per-ID lookup overhead.
const loadedMessageIds =
filter?.messageIds === undefined &&
@@ -4040,8 +4252,6 @@ export const layer: Layer.Layer =
(filter.messageRoles.includes("user") && filter.messageRoles.includes("assistant")))
? undefined
: messages.map((message) => message.id);
- // The literal `type = 'user_message'` lets SQLite pick the partial user-message index.
- const userOnly = messages.every((message) => message.role === "user");
const rows = yield* sql<{ message_id: string; ordinal: number }>`
SELECT json_extract(payload_json, '$.messageId') AS message_id, MIN(ordinal) AS ordinal
FROM orchestration_v2_projection_turn_items
@@ -4572,10 +4782,10 @@ export const layer: Layer.Layer =
WHERE subagents.child_thread_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM orchestration_v2_projection_nodes AS node
+ ${LIVE_NODES_INDEX}
WHERE node.thread_id = subagents.child_thread_id
- -- Matches nodes_live_idx; +run_id keeps thread_run_idx from scanning settled nodes.
AND node.status IN ('pending', 'starting', 'running', 'waiting')
- AND +node.run_id IS NULL
+ AND node.run_id IS NULL
AND node.kind = 'root_turn'
)
UNION
@@ -4976,6 +5186,7 @@ export const layer: Layer.Layer =
`,
nodes: sql`
SELECT node.payload_json FROM orchestration_v2_projection_nodes AS node
+ ${LIVE_NODES_INDEX}
WHERE node.thread_id = ${threadId}
AND node.status IN ('pending', 'starting', 'running', 'waiting')
AND (
@@ -5851,6 +6062,41 @@ export const layer: Layer.Layer =
Effect.map((rows) => rows[0]?.count ?? 0),
Effect.mapError(controlReadError(threadId)),
);
+ // Tool output reaches the export unchanged, so the bytes of its text, also inside a structured
+ // result, are a floor for the export. Other stored bytes are not: decoding drops fields. A
+ // rolled back run is the only way the export hides a tool row, and rows a fork inherits only
+ // add to it. The output sum parses payloads, so it runs only when the tool rows exceed `limit`.
+ const getTranscriptByteFloor: ProjectionStoreV2Shape["getTranscriptByteFloor"] = (
+ threadId,
+ limit,
+ ) =>
+ sql<{ bytes: number }>`
+ WITH tool_rows AS (
+ SELECT payload_json FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId}
+ AND type IN ('command_execution', 'dynamic_tool')
+ AND (run_id IS NULL OR run_id NOT IN (
+ SELECT run_id FROM orchestration_v2_projection_runs
+ WHERE thread_id = ${threadId} AND status = 'rolled_back'
+ ))
+ )
+ SELECT CASE
+ WHEN (SELECT COALESCE(SUM(octet_length(payload_json)), 0) FROM tool_rows) <= ${limit}
+ THEN 0
+ ELSE (
+ -- json_valid is false past SQLite's nesting limit, where json_tree would fail.
+ SELECT COALESCE(SUM(CASE WHEN json_valid(payload_json) THEN (
+ SELECT COALESCE(SUM(octet_length(leaf.value)), 0)
+ FROM json_tree(tool_rows.payload_json, '$.output') AS leaf
+ WHERE leaf.type = 'text'
+ ) ELSE 0 END), 0)
+ FROM tool_rows
+ )
+ END AS bytes
+ `.pipe(
+ Effect.map((rows) => rows[0]?.bytes ?? 0),
+ Effect.mapError(controlReadError(threadId)),
+ );
const getSubagentCount: ProjectionStoreV2Shape["getSubagentCount"] = (threadId) =>
sql<{
count: number;
@@ -6040,11 +6286,11 @@ export const layer: Layer.Layer =
const readTimelineIndex: (
threadId: ThreadId,
seen: ReadonlySet,
- includePersistentOutput?: boolean,
+ includeAttempts?: boolean,
) => Effect.Effect = Effect.fn(
"ProjectionStore.readTimelineIndex",
)(
- function* (threadId: ThreadId, seen: ReadonlySet, includePersistentOutput = false) {
+ function* (threadId: ThreadId, seen: ReadonlySet, includeAttempts = false) {
const records = yield* getThreadRecords(threadId, ["runs", "attempts"]);
const rows = yield* sql<{
turn_item_id: string;
@@ -6057,21 +6303,17 @@ export const layer: Layer.Layer =
message_id: string | null;
status: OrchestrationV2TurnItem["status"];
ordinal: number;
- persistent_output: number;
attempt_id: string | null;
attempt_status: string | null;
}>`SELECT item.turn_item_id, item.run_id, item.node_id, item.parent_item_id, item.type, item.status, item.ordinal,
- ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id,
- ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status,
- ${
- includePersistentOutput ? sql`CASE WHEN ${inlineWorkOutput} THEN 1 ELSE 0 END` : sql`0`
- } AS persistent_output,
+ ${includeAttempts ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id,
+ ${includeAttempts ? sql`attempt.status` : sql`NULL`} AS attempt_status,
CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent,
CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by,
CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id
FROM orchestration_v2_projection_turn_items AS item
${
- includePersistentOutput
+ includeAttempts
? sql`LEFT JOIN orchestration_v2_projection_nodes AS node ON node.node_id = item.node_id
LEFT JOIN orchestration_v2_projection_run_attempts AS attempt ON attempt.attempt_id = (
SELECT attempt_id FROM orchestration_v2_projection_run_attempts
@@ -6095,7 +6337,6 @@ export const layer: Layer.Layer =
runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id),
attemptId: row.attempt_id,
attemptStatus: row.attempt_status,
- persistentOutput: row.persistent_output === 1,
...(row.input_intent === null ? {} : { inputIntent: row.input_intent }),
...(row.created_by === null ? {} : { createdBy: row.created_by }),
...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }),
@@ -6118,7 +6359,7 @@ export const layer: Layer.Layer =
const source = yield* readTimelineIndex(
fork.threadId,
new Set([...seen, threadId]),
- includePersistentOutput,
+ includeAttempts,
);
const sourceRun = source.records.runs.find((run) => run.id === fork.runId);
let inherited: Array = [];
@@ -6189,14 +6430,14 @@ export const layer: Layer.Layer =
Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })),
);
const readSnapshotTimelineIndex = Effect.fn("ProjectionStore.readSnapshotTimelineIndex")(
- function* (threadId: ThreadId, includePersistentOutput: boolean) {
- const key = `${includePersistentOutput ? "work" : "plain"}:${threadId}`;
+ function* (threadId: ThreadId, includeAttempts: boolean) {
+ const key = `${includeAttempts ? "work" : "plain"}:${threadId}`;
const cached = timelineIndexes.get(key);
const index =
cached !== undefined &&
(yield* latestThreadEventSequence(threadId, cached.index.threadIds)) === cached.sequence
? cached.index
- : yield* readTimelineIndex(threadId, new Set(), includePersistentOutput);
+ : yield* readTimelineIndex(threadId, new Set(), includeAttempts);
const sequence =
index === cached?.index
? cached.sequence
@@ -6214,6 +6455,459 @@ export const layer: Layer.Layer =
readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)),
);
+ /**
+ * Index columns of the thread's own turn items that `where` selects, in timeline order. The
+ * same columns as `readTimelineIndex`, which reads every row and stays as it is.
+ */
+ const readTimelineWindow = Effect.fn("ProjectionStore.readTimelineWindow")(function* (
+ threadId: ThreadId,
+ runs: ProjectionRecords<"runs">["runs"],
+ includePersistentOutput: boolean,
+ where: Statement.Fragment,
+ limit: Statement.Fragment = sql``,
+ ) {
+ const rows = yield* sql<{
+ turn_item_id: string;
+ run_id: string | null;
+ node_id: string | null;
+ parent_item_id: string | null;
+ type: OrchestrationV2TurnItem["type"];
+ input_intent: NonNullable | null;
+ created_by: "user" | "agent" | "system" | null;
+ message_id: string | null;
+ status: OrchestrationV2TurnItem["status"];
+ ordinal: number;
+ persistent_output: number;
+ attempt_id: string | null;
+ attempt_status: string | null;
+ }>`SELECT item.turn_item_id, item.run_id, item.node_id, item.parent_item_id, item.type, item.status, item.ordinal,
+ ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id,
+ ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status,
+ ${
+ includePersistentOutput ? sql`CASE WHEN ${inlineWorkOutput} THEN 1 ELSE 0 END` : sql`0`
+ } AS persistent_output,
+ CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent,
+ CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by,
+ CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id
+ FROM orchestration_v2_projection_turn_items AS item
+ ${
+ includePersistentOutput
+ ? sql`LEFT JOIN orchestration_v2_projection_nodes AS node ON node.node_id = item.node_id
+ LEFT JOIN orchestration_v2_projection_run_attempts AS attempt ON attempt.attempt_id = (
+ SELECT attempt_id FROM orchestration_v2_projection_run_attempts
+ WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id)
+ ORDER BY attempt_ordinal DESC LIMIT 1
+ )`
+ : sql``
+ }
+ WHERE item.thread_id = ${threadId} ${where}
+ ORDER BY item.ordinal ASC, item.turn_item_id ASC ${limit}`;
+ const runStatuses = new Map(runs.map((run) => [String(run.id), run.status]));
+ return rows.map((row): TimelineIndexItem => ({
+ id: TurnItemId.make(row.turn_item_id),
+ threadId,
+ runId: row.run_id === null ? null : RunId.make(row.run_id),
+ nodeId: row.node_id === null ? null : NodeId.make(row.node_id),
+ parentItemId: row.parent_item_id === null ? null : TurnItemId.make(row.parent_item_id),
+ type: row.type,
+ status: row.status,
+ ordinal: row.ordinal,
+ runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id),
+ attemptId: row.attempt_id,
+ attemptStatus: row.attempt_status,
+ persistentOutput: row.persistent_output === 1,
+ ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }),
+ ...(row.created_by === null ? {} : { createdBy: row.created_by }),
+ ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }),
+ }));
+ });
+ const localTimelineRow = (item: TimelineIndexItem): TimelineIndexRow => ({
+ sourceThreadId: item.threadId,
+ sourceItemId: item.id,
+ visibility: "local",
+ item,
+ });
+
+ /**
+ * Reads parts of a thread's own timeline without indexing all of it. Null when callers read
+ * the full index instead: the thread shows fork ancestry, so its rows come from other threads,
+ * or a full index of this snapshot is cached already.
+ */
+ const openLocalTimeline = Effect.fn("ProjectionStore.openLocalTimeline")(function* (
+ threadId: ThreadId,
+ includePersistentOutput = false,
+ ) {
+ // A read that only the full index can answer left it cached, and it answers faster than
+ // a window. The key is the one `readSnapshotTimelineIndex` uses.
+ const cached = timelineIndexes.get(
+ `${includePersistentOutput ? "work" : "plain"}:${threadId}`,
+ );
+ if (
+ cached !== undefined &&
+ (yield* latestThreadEventSequence(threadId, cached.index.threadIds)) === cached.sequence
+ )
+ return null;
+ // Checked in SQL, so a fork loads no runs and no thread here before the full index does.
+ const ownRowsOnly = yield* sql`
+ SELECT 1 FROM orchestration_v2_projection_threads
+ WHERE thread_id = ${threadId}
+ AND NOT (json_extract(payload_json, '$.forkedFrom.type') IS 'run'
+ AND json_extract(payload_json, '$.lineage.sideChat') IS NOT 1)`;
+ if (ownRowsOnly.length === 0) return null;
+ const records = yield* getThreadRecords(threadId, ["runs", "attempts"]);
+ if (inheritedRunFork(records.thread) !== null) return null;
+ const superseded = records.attempts.filter((attempt) => attempt.status === "superseded");
+ const stopRequests =
+ superseded.length === 0
+ ? []
+ : yield* sql<{ run_id: string | null }>`
+ SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId} AND type = 'run_interrupt_request'`;
+ const isVisible = createOrchestrationV2TurnItemVisibility({
+ runs: records.runs,
+ attempts: records.attempts,
+ items: stopRequests.map((row) => ({
+ type: "run_interrupt_request" as const,
+ runId: row.run_id === null ? null : RunId.make(row.run_id),
+ nodeId: null,
+ })),
+ });
+ // Only these runs can hide rows: every row of a rolled-back run, the queued turn of a
+ // cancelled run, and the stop result of a replaced attempt. So the hidden rows are found
+ // without reading the thread.
+ const stoppedRuns = new Set(stopRequests.map((row) => row.run_id));
+ const runsWithStatus = (status: "rolled_back" | "cancelled") =>
+ records.runs.flatMap((run) => (run.status === status ? [String(run.id)] : []));
+ const rolledBack = runsWithStatus("rolled_back");
+ const cancelled = runsWithStatus("cancelled");
+ const steered = [...new Set(superseded.map((attempt) => String(attempt.runId)))].filter(
+ (runId) => !stoppedRuns.has(runId) && !rolledBack.includes(runId),
+ );
+ type HiddenCandidate = {
+ turn_item_id: string;
+ run_id: string;
+ node_id: string | null;
+ type: OrchestrationV2TurnItem["type"];
+ ordinal: number;
+ input_intent: NonNullable | null;
+ };
+ const candidateColumns = sql`item.turn_item_id, item.run_id, item.node_id, item.type,
+ item.ordinal, CASE WHEN item.type = 'user_message'
+ THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent`;
+ const [rolledBackRows, conversationRows] = yield* Effect.all([
+ rolledBack.length === 0
+ ? Effect.succeed([])
+ : sql`
+ SELECT ${candidateColumns}
+ FROM json_each(${encodeIdList(rolledBack)}) AS wanted
+ CROSS JOIN orchestration_v2_projection_turn_items AS item
+ INDEXED BY orchestration_v2_projection_turn_items_thread_run_idx
+ ON item.thread_id = ${threadId} AND item.run_id = wanted.value`,
+ // The conversation index holds the type and run of these rows, so only the matches are
+ // fetched. Through the run index, every row of each cancelled run would be.
+ cancelled.length + steered.length === 0
+ ? Effect.succeed([])
+ : sql`
+ SELECT ${candidateColumns}
+ FROM orchestration_v2_projection_turn_items AS item
+ INDEXED BY orchestration_v2_projection_turn_items_conversation_idx
+ WHERE item.thread_id = ${threadId}
+ AND item.type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES}
+ AND ((item.type = 'user_message'
+ AND item.run_id IN (SELECT value FROM json_each(${encodeIdList(cancelled)})))
+ OR (item.type = 'run_interrupt_result'
+ AND item.run_id IN (SELECT value FROM json_each(${encodeIdList(steered)}))))`,
+ ]);
+ const hidden = [...rolledBackRows, ...conversationRows].filter(
+ (row) =>
+ !isVisible({
+ type: row.type,
+ runId: RunId.make(row.run_id),
+ nodeId: row.node_id === null ? null : NodeId.make(row.node_id),
+ ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }),
+ }),
+ );
+ /** Counts the visible rows that sort before the given ordinal and item id. */
+ const visibleBefore = Effect.fn("ProjectionStore.countVisibleTimelineRows")(function* (
+ ordinal: number,
+ itemId = "",
+ ) {
+ const rows = yield* sql<{ count: number }>`
+ SELECT COUNT(*) AS count FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId} AND (ordinal, turn_item_id) < (${ordinal}, ${itemId})`;
+ const hiddenBefore = hidden.filter(
+ (row) =>
+ row.ordinal < ordinal ||
+ (row.ordinal === ordinal &&
+ Buffer.compare(Buffer.from(row.turn_item_id), Buffer.from(itemId)) < 0),
+ );
+ return (rows[0]?.count ?? 0) - hiddenBefore.length;
+ });
+ return {
+ records,
+ isVisible,
+ hidden,
+ visibleBefore,
+ read: (where: Statement.Fragment, limit?: Statement.Fragment) =>
+ readTimelineWindow(threadId, records.runs, includePersistentOutput, where, limit),
+ };
+ });
+ type LocalTimeline = NonNullable>>;
+ type IndexedTimelineRow = TimelineIndexRow & {
+ readonly position: number;
+ readonly canonicalPosition: number;
+ };
+
+ /** True when a visible user turn starts before the ordinal. Reads the nearest user messages. */
+ const olderUserTurn = (local: LocalTimeline, ordinal: number) =>
+ sql<{
+ run_id: string | null;
+ node_id: string | null;
+ input_intent: NonNullable;
+ }>`
+ SELECT item.run_id, item.node_id,
+ json_extract(item.payload_json, '$.inputIntent') AS input_intent
+ FROM orchestration_v2_projection_turn_items AS item
+ INDEXED BY orchestration_v2_projection_turn_items_user_message_idx
+ WHERE item.thread_id = ${local.records.thread.id} AND item.type = 'user_message'
+ AND item.ordinal < ${ordinal}
+ AND json_extract(item.payload_json, '$.inputIntent') IN ('turn_start', 'queued_turn')
+ AND json_extract(item.payload_json, '$.createdBy') = 'user'
+ ORDER BY item.ordinal DESC, item.turn_item_id DESC
+ LIMIT ${local.hidden.length + 1}`.pipe(
+ Effect.map((rows) =>
+ rows.some((row) =>
+ local.isVisible({
+ type: "user_message",
+ runId: row.run_id === null ? null : RunId.make(row.run_id),
+ nodeId: row.node_id === null ? null : NodeId.make(row.node_id),
+ inputIntent: row.input_intent,
+ }),
+ ),
+ ),
+ );
+
+ /**
+ * A notification starts a turn only as its run's first input. For each run whose first input
+ * in `rows` is a notification, this finds whether an input row sits before the ordinal.
+ */
+ const runsStartedBefore = Effect.fn("ProjectionStore.runsStartedBefore")(function* (
+ local: LocalTimeline,
+ ordinal: number,
+ rows: ReadonlyArray,
+ ) {
+ const seen = new Set();
+ const unknown = new Set();
+ for (const { item } of rows) {
+ if (item.runId === null) continue;
+ if (item.type !== "user_message" && item.type !== "notification") continue;
+ if (item.type === "notification" && !seen.has(item.runId)) unknown.add(item.runId);
+ seen.add(item.runId);
+ }
+ if (unknown.size === 0) return new Set();
+ const counts = yield* sql<{ run_id: string; inputs: number }>`
+ SELECT wanted.value AS run_id, (
+ SELECT COUNT(*) FROM orchestration_v2_projection_turn_items AS input
+ INDEXED BY orchestration_v2_projection_turn_items_conversation_idx
+ WHERE input.thread_id = ${local.records.thread.id}
+ AND input.ordinal >= (
+ SELECT MIN(ordinal) FROM orchestration_v2_projection_turn_items
+ WHERE run_id = wanted.value
+ )
+ AND input.ordinal < ${ordinal}
+ AND input.type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES}
+ AND input.type IN ('user_message', 'notification')
+ AND input.run_id = wanted.value
+ ) AS inputs
+ FROM json_each(${encodeIdList([...unknown])}) AS wanted`;
+ const isHiddenInput = (row: LocalTimeline["hidden"][number], runId: string) =>
+ row.run_id === runId &&
+ row.ordinal < ordinal &&
+ (row.type === "user_message" || row.type === "notification");
+ return new Set(
+ counts.flatMap((row) =>
+ row.inputs > local.hidden.filter((hidden) => isHiddenInput(hidden, row.run_id)).length
+ ? [row.run_id]
+ : [],
+ ),
+ );
+ });
+
+ /**
+ * Selects an older history page from the rows near its cursor. The window grows until the
+ * page ends inside rows whose inputs are all known, so `select` gives the page it gives on
+ * the full index. Null when the cursor names no visible row, which needs the full index.
+ */
+ const selectHistoryPageInWindow = Effect.fn("ProjectionStore.selectHistoryPageInWindow")(
+ function* <
+ Selection extends {
+ readonly page: SelectTimelinePageResult;
+ readonly historyItems: ReadonlyArray;
+ },
+ >(
+ local: LocalTimeline,
+ input: {
+ readonly cursor: string;
+ readonly throughEntryId: string | undefined;
+ readonly workMode: "deferred" | "work" | undefined;
+ },
+ select: (
+ indexed: ReadonlyArray,
+ prefix?: ThreadHistoryPrefix,
+ ) => Effect.Effect,
+ ) {
+ const threadId = local.records.thread.id;
+ const decoded = Result.try(() => decodeThreadHistoryCursor(input.cursor));
+ if (Result.isFailure(decoded) || decoded.success.st !== threadId) return null;
+ const cursor = decoded.success;
+ // Looked up by key alone: with the thread in the query, SQLite scans the thread's index.
+ const ends = (yield* sql<{ turn_item_id: string; thread_id: string; ordinal: number }>`
+ SELECT turn_item_id, thread_id, ordinal FROM orchestration_v2_projection_turn_items
+ WHERE turn_item_id IN (${cursor.si}, ${input.throughEntryId ?? cursor.si})`).filter(
+ (row) => row.thread_id === threadId,
+ );
+ const upper = ends.find((row) => row.turn_item_id === cursor.si)?.ordinal;
+ if (upper === undefined) return null;
+ /* Runs for each window this operation tries; the operation owns the span. */
+ const indexFrom = Effect.fnUntraced(function* (
+ items: ReadonlyArray,
+ from: number | null,
+ ) {
+ const base = from === null ? 0 : yield* local.visibleBefore(from);
+ return items.filter(local.isVisible).map((item, offset): IndexedTimelineRow => ({
+ ...localTimelineRow(item),
+ position: base + offset,
+ canonicalPosition: base + offset,
+ }));
+ });
+ const isCursorRow = (row: TimelineIndexRow) => row.sourceItemId === cursor.si;
+ // The ordinals come from the stop request index, so this does not walk the thread.
+ const interruptRequests = local
+ .read(
+ sql`AND item.type = 'run_interrupt_request' AND item.ordinal IN (
+ SELECT ordinal FROM orchestration_v2_projection_turn_items
+ INDEXED BY orchestration_v2_projection_turn_items_stop_request_idx
+ WHERE thread_id = ${threadId} AND type = 'run_interrupt_request')`,
+ )
+ .pipe(Effect.flatMap((items) => indexFrom(items, null)));
+
+ if (input.workMode === "work") {
+ // A work group lies between its first row and the cursor, so that range is the read.
+ const lower = ends.find((row) => row.turn_item_id === input.throughEntryId)?.ordinal;
+ if (lower === undefined) return null;
+ const indexed = yield* indexFrom(
+ yield* local.read(sql`AND item.ordinal >= ${lower} AND item.ordinal <= ${upper}`),
+ lower,
+ );
+ if (!indexed.some(isCursorRow)) return null;
+ if (!indexed.some((row) => row.sourceItemId === input.throughEntryId)) return null;
+ return {
+ indexed,
+ selection: yield* select(indexed),
+ olderUserTurn: olderUserTurn(local, lower),
+ interruptRequests,
+ };
+ }
+
+ const rowsBelow = (ordinal: number, count: number) =>
+ sql<{ ordinal: number }>`
+ SELECT ordinal FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId} AND ordinal < ${ordinal}
+ ORDER BY ordinal DESC LIMIT 1 OFFSET ${count - 1}`.pipe(
+ Effect.map((rows) => rows[0]?.ordinal ?? null),
+ );
+ // `trusted` is the lowest ordinal the page may reach. `lower` can sit below it, because
+ // a run that folds work is read whole.
+ let step = THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT;
+ let trusted = yield* rowsBelow(upper, step);
+ let lower = trusted;
+ let higher = upper;
+ let items = yield* local.read(
+ lower === null
+ ? sql`AND item.ordinal <= ${upper}`
+ : sql`AND item.ordinal >= ${lower} AND item.ordinal <= ${upper}`,
+ );
+ const wholeRuns = new Set();
+ while (true) {
+ if (input.workMode === "deferred") {
+ // Folding needs every row of a run: its stop and error rows, and its group sizes.
+ const folding = new Set();
+ for (const item of items) {
+ if (item.runId === null || wholeRuns.has(item.runId)) continue;
+ if (item.ordinal > upper || (trusted !== null && item.ordinal < trusted)) continue;
+ if (isFoldableWorkItem(item) && local.isVisible(item)) folding.add(item.runId);
+ }
+ if (folding.size > 0) {
+ const runs = encodeIdList([...folding]);
+ const [extent] = yield* sql<{ lower: number; higher: number }>`
+ SELECT MIN(run.lower) AS lower, MAX(run.higher) AS higher FROM (
+ SELECT
+ (SELECT MIN(ordinal) FROM orchestration_v2_projection_turn_items
+ WHERE run_id = wanted.value) AS lower,
+ (SELECT MAX(ordinal) FROM orchestration_v2_projection_turn_items
+ WHERE run_id = wanted.value) AS higher
+ FROM json_each(${runs}) AS wanted
+ ) AS run`;
+ if (extent !== undefined && lower !== null && extent.lower < lower) {
+ const below = yield* local.read(
+ sql`AND item.ordinal >= ${extent.lower} AND item.ordinal < ${lower}`,
+ );
+ items = below.concat(items);
+ lower = extent.lower;
+ }
+ if (extent !== undefined && extent.higher > higher) {
+ const newer = yield* local.read(
+ sql`AND item.ordinal > ${higher} AND item.ordinal <= ${extent.higher}`,
+ );
+ items = items.concat(newer);
+ higher = extent.higher;
+ }
+ for (const runId of folding) wholeRuns.add(runId);
+ }
+ }
+ const indexed = yield* indexFrom(items, lower);
+ const end = indexed.findIndex(isCursorRow);
+ if (end < 0) return null;
+ const older = lower === null ? Effect.succeed(false) : olderUserTurn(local, lower);
+ const prefix: ThreadHistoryPrefix | undefined =
+ lower === null
+ ? undefined
+ : {
+ hasUserTurn: indexed
+ .slice(0, end)
+ .some((row) => isThreadHistoryUserTurn(row.item))
+ ? false
+ : yield* older,
+ startedRuns: yield* runsStartedBefore(local, lower, indexed),
+ };
+ const selection = yield* select(indexed, prefix);
+ // Without its row the cursor falls back to a position, which only the full index has.
+ if (!selection.historyItems.some(isCursorRow)) return null;
+ const oldest = selection.page.oldestScanned;
+ if (
+ lower === null ||
+ (trusted !== null &&
+ oldest !== undefined &&
+ oldest !== selection.historyItems[0] &&
+ oldest.item.ordinal >= trusted)
+ ) {
+ return { indexed, selection, olderUserTurn: older, interruptRequests };
+ }
+ step *= 2;
+ trusted = trusted === null ? null : yield* rowsBelow(trusted, step);
+ if (trusted === null || trusted < lower) {
+ const below = yield* local.read(
+ trusted === null
+ ? sql`AND item.ordinal < ${lower}`
+ : sql`AND item.ordinal >= ${trusted} AND item.ordinal < ${lower}`,
+ );
+ items = below.concat(items);
+ lower = trusted;
+ }
+ }
+ },
+ );
+
const getThreadHistoryPage: ProjectionStoreV2Shape["getThreadHistoryPage"] = (
threadId,
cursor,
@@ -6224,8 +6918,15 @@ export const layer: Layer.Layer =
sql
.withTransaction(
Effect.gen(function* () {
- const index = yield* readSnapshotTimelineIndex(threadId, workMode !== undefined);
- if (index.records.thread.deletedAt !== null)
+ const withWork = workMode !== undefined;
+ // A jump to a target entry selects from the whole index.
+ const local =
+ (workMode === "work") === (throughEntryId !== undefined)
+ ? yield* openLocalTimeline(threadId, withWork)
+ : null;
+ const full =
+ local === null ? yield* readSnapshotTimelineIndex(threadId, withWork) : null;
+ if ((local ?? full)?.records.thread.deletedAt !== null)
return yield* new ProjectionStoreThreadNotFoundError({ threadId });
const revisions = yield* sql<{
sequence: number;
@@ -6233,43 +6934,132 @@ export const layer: Layer.Layer =
FROM orchestration_events WHERE application_event_version = 2
AND aggregate_kind = 'thread' AND stream_id = ${threadId}`;
const snapshotSequence = revisions[0]?.sequence ?? 0;
- const indexed = index.visible.map((row, position) => ({
- ...row,
- position,
- canonicalPosition: position,
- }));
- const historyItems = workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed;
- const selection = yield* Effect.try({
- try: () => {
- const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId });
- const page =
- workMode === "work"
- ? selectDeferredWorkPage({
- items: indexed,
- cursor,
- throughEntryId,
- snapshotSequence,
- rowEncodedBytes: () => 0,
- })
- : selectHistoryPageFromCursor({
- items: historyItems,
- cursor,
- snapshotSequence,
- rowEncodedBytes: () => 0,
- policy,
- });
- return { policy, page };
- },
- catch: (cause) => new ProjectionStoreReadError({ threadId, cause }),
+ const select = (
+ indexed: ReadonlyArray,
+ prefix?: ThreadHistoryPrefix,
+ ) =>
+ Effect.try({
+ try: () => {
+ const historyItems =
+ workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed;
+ const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId });
+ const page =
+ workMode === "work"
+ ? selectDeferredWorkPage({
+ items: indexed,
+ cursor,
+ throughEntryId,
+ snapshotSequence,
+ rowEncodedBytes: () => 0,
+ })
+ : selectHistoryPageFromCursor({
+ items: historyItems,
+ cursor,
+ snapshotSequence,
+ rowEncodedBytes: () => 0,
+ policy,
+ prefix,
+ });
+ return { policy, page, historyItems };
+ },
+ catch: (cause) => new ProjectionStoreReadError({ threadId, cause }),
+ });
+ const window =
+ local === null
+ ? null
+ : yield* selectHistoryPageInWindow(
+ local,
+ { cursor, throughEntryId, workMode },
+ select,
+ );
+ const whole = Effect.gen(function* () {
+ const index = full ?? (yield* readSnapshotTimelineIndex(threadId, withWork));
+ const indexed = index.visible.map((row, position): IndexedTimelineRow => ({
+ ...row,
+ position,
+ canonicalPosition: position,
+ }));
+ let selection = yield* select(indexed);
+ // The full index does not say which rows hold inline output: finding it scans
+ // payloads, which costs a whole long thread several seconds. Scan only the runs
+ // the page spans, then select again with what they hold.
+ const scannedRuns = new Set();
+ const persistentOutputs = new Set();
+ // Rows hidden in a work group move the page, so the scan always reaches the
+ // cursor's own row, or the row its stale position falls back to.
+ const cursorPosition =
+ workMode === undefined
+ ? -1
+ : Math.min(
+ findCursorEndIndex(indexed, decodeThreadHistoryCursor(cursor)),
+ indexed.length - 1,
+ );
+ while (workMode !== undefined) {
+ const { page } = selection;
+ const span = [...page.items.slice(0, 1), ...page.items.slice(-1)].map(
+ (row) => row.canonicalPosition,
+ );
+ if (cursorPosition >= 0) span.push(cursorPosition);
+ if (span.length === 0) break;
+ const runs = new Map(
+ // A work page holds rows of one run, which can be the rows that have no run.
+ (workMode === "work"
+ ? page.items.slice(0, 1)
+ : indexed.slice(Math.min(...span), Math.max(...span) + 1)
+ ).flatMap(({ sourceThreadId, item }) =>
+ (workMode === "work" ||
+ (item.runId !== null &&
+ (item.runStatus === "completed" || item.attemptStatus === "superseded"))) &&
+ !scannedRuns.has(`${sourceThreadId}:${item.runId}`)
+ ? [
+ [
+ `${sourceThreadId}:${item.runId}`,
+ { threadId: sourceThreadId, id: item.runId },
+ ] as const,
+ ]
+ : [],
+ ),
+ );
+ if (runs.size === 0) break;
+ for (const run of runs.keys()) scannedRuns.add(run);
+ const found = yield* sql<{ thread_id: string; turn_item_id: string }>`
+ SELECT item.thread_id, item.turn_item_id
+ FROM json_each(${yield* encodeTimelineSources([...runs.values()])}) AS wanted
+ CROSS JOIN orchestration_v2_projection_turn_items AS item
+ ON item.thread_id = json_extract(wanted.value, '$.threadId')
+ AND item.run_id IS json_extract(wanted.value, '$.id')
+ WHERE item.status = 'completed' AND ${inlineWorkOutput}`;
+ if (found.length === 0) break;
+ for (const row of found)
+ persistentOutputs.add(`${row.thread_id}:${row.turn_item_id}`);
+ selection = yield* select(
+ indexed.map((row) =>
+ persistentOutputs.has(`${row.sourceThreadId}:${row.sourceItemId}`)
+ ? { ...row, item: { ...row.item, persistentOutput: true } }
+ : row,
+ ),
+ );
+ }
+ return {
+ indexed,
+ selection,
+ olderUserTurn: Effect.succeed(false),
+ interruptRequests: Effect.succeed(indexed),
+ };
});
+ const { indexed, selection, olderUserTurn, interruptRequests } =
+ window ?? (yield* whole);
const { page } = selection;
const policy = workMode === "work" ? THREAD_HISTORY_PAGE_POLICY : selection.policy;
const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1;
- const hasUserTurns = index.visible
- .slice(0, end)
- .some((row) => isThreadHistoryUserTurn(row.item));
+ const hasUserTurns =
+ conversationOnly === true &&
+ (indexed.some(
+ (row) => row.canonicalPosition < end && isThreadHistoryUserTurn(row.item),
+ ) ||
+ (yield* olderUserTurn));
const materialize = Effect.fn("ProjectionStore.materializeHistoryRows")(function* (
- rows: ReadonlyArray<(typeof indexed)[number]>,
+ rows: ReadonlyArray,
) {
if (rows.length === 0) return [];
const sources = yield* encodeTimelineSources(
@@ -6312,7 +7102,7 @@ export const layer: Layer.Layer =
);
});
const items = yield* materialize(
- conversationOnly && hasUserTurns
+ hasUserTurns
? page.items.filter((row) => isConversationHistoryItem(row.item))
: page.items,
);
@@ -6320,7 +7110,14 @@ export const layer: Layer.Layer =
const requests =
workMode === "work"
? []
- : yield* materialize(missingInterruptRequests(indexed, bounded.items));
+ : yield* materialize(
+ missingInterruptRequests(
+ bounded.items.some((row) => row.item.type === "run_interrupt_result")
+ ? yield* interruptRequests
+ : [],
+ bounded.items,
+ ),
+ );
const pageItems = [...requests, ...bounded.items].map((row, position) => ({
...row,
position,
@@ -6337,39 +7134,118 @@ export const layer: Layer.Layer =
)
.pipe(Effect.mapError(controlReadError(threadId)));
+ /**
+ * The rows `getTimelinePage` matches, cut after the first `limit + 1`, with their positions
+ * in the full visible index. Reads only the rows from the start position to the page end.
+ */
+ const matchLocalTimelineRows = Effect.fn("ProjectionStore.matchLocalTimelineRows")(function* (
+ local: LocalTimeline,
+ options: ProjectionTimelinePageOptions,
+ ) {
+ const threadId = local.records.thread.id;
+ const totals = yield* sql<{ count: number }>`
+ SELECT COUNT(*) AS count FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId}`;
+ const totalItems = (totals[0]?.count ?? 0) - local.hidden.length;
+ if (options.itemId !== undefined) {
+ const found = (yield* local.read(sql`AND item.turn_item_id = ${options.itemId}`)).filter(
+ local.isVisible,
+ );
+ const matching = yield* Effect.forEach(found, (item) =>
+ local
+ .visibleBefore(item.ordinal, item.id)
+ .pipe(Effect.map((position) => ({ ...localTimelineRow(item), position }))),
+ );
+ return { matching, totalItems };
+ }
+ const afterPosition = options.afterPosition ?? -1;
+ const firstPosition = Math.max(afterPosition + 1, 0);
+ // The row at this raw offset sorts at or before the first wanted row: hidden rows only
+ // push visible rows to later offsets.
+ const starts = yield* sql<{ ordinal: number }>`
+ SELECT ordinal FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId}
+ ORDER BY ordinal ASC, turn_item_id ASC LIMIT 1 OFFSET ${firstPosition}`;
+ const start = starts[0]?.ordinal;
+ if (start === undefined) return { matching: [], totalItems };
+ const base = yield* local.visibleBefore(start);
+ const hiddenAfter = local.hidden.filter((row) => row.ordinal >= start).length;
+ const messagesOnly = options.view !== "activity";
+ const candidates = (yield* local.read(
+ messagesOnly
+ ? sql`AND item.ordinal >= ${start}
+ AND item.type IN ('user_message', 'assistant_message', 'proposed_plan')`
+ : sql`AND item.ordinal >= ${start}`,
+ sql`LIMIT ${firstPosition - base + Math.min(options.limit, totalItems) + 1 + hiddenAfter}`,
+ )).filter(local.isVisible);
+ const positions = new Map();
+ const last = candidates.at(-1);
+ if (messagesOnly && last !== undefined) {
+ const hiddenIds = new Set(local.hidden.map((row) => row.turn_item_id));
+ const span = yield* sql<{ turn_item_id: string }>`
+ SELECT turn_item_id FROM orchestration_v2_projection_turn_items
+ WHERE thread_id = ${threadId} AND ordinal >= ${start} AND ordinal <= ${last.ordinal}
+ ORDER BY ordinal ASC, turn_item_id ASC`;
+ for (const row of span) {
+ if (!hiddenIds.has(row.turn_item_id))
+ positions.set(row.turn_item_id, base + positions.size);
+ }
+ }
+ const matching = candidates
+ .map((item, offset) => ({
+ ...localTimelineRow(item),
+ position: messagesOnly ? (positions.get(item.id) ?? -1) : base + offset,
+ }))
+ .filter((row) => row.position > afterPosition);
+ return { matching, totalItems };
+ });
+
const getTimelinePage: ProjectionStoreV2Shape["getTimelinePage"] = (threadId, options) =>
sql
.withTransaction(
Effect.gen(function* () {
- const index = yield* readSnapshotTimelineIndex(threadId, false);
- const attachmentOwners =
- options.attachmentId === undefined
- ? undefined
- : new Set(
- (yield* sql<{ thread_id: string; turn_item_id: string }>`
+ const local =
+ options.attachmentId === undefined &&
+ Number.isSafeInteger(options.limit) &&
+ options.limit >= 0 &&
+ Number.isSafeInteger(options.afterPosition ?? -1)
+ ? yield* openLocalTimeline(threadId)
+ : null;
+ const whole = Effect.gen(function* () {
+ const index = yield* readSnapshotTimelineIndex(threadId, false);
+ const attachmentOwners =
+ options.attachmentId === undefined
+ ? undefined
+ : new Set(
+ (yield* sql<{ thread_id: string; turn_item_id: string }>`
SELECT item.thread_id, item.turn_item_id
FROM orchestration_v2_projection_turn_items AS item,
json_each(item.payload_json, '$.attachments') AS attachment
WHERE item.thread_id IN ${sql.in([...new Set(index.visible.map((row) => row.sourceThreadId))])}
AND item.type = 'user_message'
AND json_extract(attachment.value, '$.id') = ${options.attachmentId}`).map(
- (row) => `${row.thread_id}:${row.turn_item_id}`,
- ),
- );
- const matching = index.visible
- .map((row, position) => ({ ...row, position }))
- .filter((row) =>
- options.itemId === undefined
- ? row.position > (options.afterPosition ?? -1) &&
- (options.view === "activity" ||
- ["user_message", "assistant_message", "proposed_plan"].includes(
- row.item.type,
- ))
- : row.sourceItemId === options.itemId,
- )
- .filter(
- (row) => attachmentOwners?.has(`${row.sourceThreadId}:${row.sourceItemId}`) ?? true,
- );
+ (row) => `${row.thread_id}:${row.turn_item_id}`,
+ ),
+ );
+ const matching = index.visible
+ .map((row, position) => ({ ...row, position }))
+ .filter((row) =>
+ options.itemId === undefined
+ ? row.position > (options.afterPosition ?? -1) &&
+ (options.view === "activity" ||
+ ["user_message", "assistant_message", "proposed_plan"].includes(
+ row.item.type,
+ ))
+ : row.sourceItemId === options.itemId,
+ )
+ .filter(
+ (row) =>
+ attachmentOwners?.has(`${row.sourceThreadId}:${row.sourceItemId}`) ?? true,
+ );
+ return { matching, totalItems: index.visible.length };
+ });
+ const { matching, totalItems } =
+ local === null ? yield* whole : yield* matchLocalTimelineRows(local, options);
const page = matching.slice(0, options.limit);
const sources = yield* encodeTimelineSources(
page
@@ -6419,11 +7295,7 @@ export const layer: Layer.Layer =
};
}),
);
- return {
- items,
- totalItems: index.visible.length,
- hasMore: matching.length > page.length,
- };
+ return { items, totalItems, hasMore: matching.length > page.length };
}),
)
.pipe(
@@ -6620,7 +7492,10 @@ export const layer: Layer.Layer =
active.status AS active_run_status,
(
SELECT r.status
+ -- Read active runs by status. Walking the ordinal index to skip this sort
+ -- reads every run of a thread with none active.
FROM orchestration_v2_projection_runs r
+ INDEXED BY orchestration_v2_projection_runs_thread_status_idx
WHERE r.thread_id = t.thread_id
AND r.status IN ('preparing', 'starting', 'running', 'waiting')
AND NOT ${isSilentShellRun("r")}
@@ -6635,6 +7510,7 @@ export const layer: Layer.Layer =
r.requested_at
)
FROM orchestration_v2_projection_runs r
+ INDEXED BY orchestration_v2_projection_runs_thread_status_idx
WHERE r.thread_id = t.thread_id
AND r.status IN ('preparing', 'starting', 'running', 'waiting')
AND NOT ${isSilentShellRun("r")}
@@ -6697,6 +7573,7 @@ export const layer: Layer.Layer =
SELECT secret.payload_json
-- Keep runs outermost so completed history is never scanned for a pending secret.
FROM orchestration_v2_projection_runs r
+ INDEXED BY orchestration_v2_projection_runs_thread_status_idx
CROSS JOIN orchestration_v2_projection_turn_items secret
INDEXED BY orchestration_v2_projection_turn_items_thread_run_idx
WHERE r.thread_id = t.thread_id
@@ -6745,6 +7622,7 @@ export const layer: Layer.Layer =
LEFT JOIN orchestration_v2_projection_runs active ON active.run_id = (
SELECT r.run_id
FROM orchestration_v2_projection_runs r
+ INDEXED BY orchestration_v2_projection_runs_thread_status_idx
WHERE r.thread_id = t.thread_id
AND r.status IN ('preparing', 'starting', 'running')
AND NOT ${isSilentShellRun("r")}
@@ -6816,27 +7694,21 @@ export const layer: Layer.Layer =
const selectShellRunItemCounts = (threadIds: ReadonlyArray) =>
sql`
- SELECT thread_id, run_id, COUNT(*) AS item_count
- FROM orchestration_v2_projection_turn_items
- WHERE run_id IS NOT NULL AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))
- GROUP BY thread_id, run_id
+ SELECT thread_id, run_key AS run_id, item_count
+ FROM orchestration_v2_projection_turn_item_counts
+ WHERE run_key <> '' AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))
`;
- // Count all selected threads in one index walk; fork sources still need per-run counts.
const selectShellItemCounts = (threadIds: ReadonlyArray) =>
sql