diff --git a/.github/readme/hero.webp b/.github/readme/hero.webp new file mode 100644 index 0000000000000..8cc713bf541d5 Binary files /dev/null and b/.github/readme/hero.webp differ diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 09b25131e4c48..304b56d57e400 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -3,6 +3,7 @@ const path = require("node:path"); const ARTIFACT_NAME = "thread-transfer-results"; const RESULT_FILE = "thread-transfer-result.json"; +const SCENARIOS_FILE = "thread-transfer-scenarios.json"; const COMMENT_MARKER = ""; const PROVIDERS = ["codex", "claudeAgent"]; const OBSERVED_KEYS = [ @@ -29,6 +30,12 @@ const SCENARIO_KEYS = [ "measuredMcpResultBytes", ]; +const SCENARIO_ROW_KEYS = ["phase", "metric", "unit", "observed", "ceiling"]; +// These labels come from the PR and reach a comment the bot posts: no markup, links or mentions. +// GitHub still links plain text such as `GH-26`, so they are rendered as code spans. +const SCENARIO_LABEL = /^[A-Za-z0-9][A-Za-z0-9 ,-]{0,79}$/; +const MAX_SCENARIO_ROWS = 100; + function resultShaMarker(sha) { return ``; } @@ -85,15 +92,63 @@ function validateResult(value) { return value; } -function readResult(directory) { +function validateScenarios(value) { + assertExactKeys(value, ["schemaVersion", "providers"], "scenarios"); + if (value.schemaVersion !== 1) { + throw new Error("scenarios.schemaVersion must be 1"); + } + assertExactKeys(value.providers, PROVIDERS, "scenarios.providers"); + for (const provider of PROVIDERS) { + const rows = value.providers[provider]; + if (!Array.isArray(rows) || rows.length > MAX_SCENARIO_ROWS) { + throw new Error( + `scenarios.providers.${provider} must list at most ${MAX_SCENARIO_ROWS} rows`, + ); + } + const seen = new Set(); + for (const [index, row] of rows.entries()) { + const label = `scenarios.providers.${provider}[${index}]`; + assertExactKeys(row, SCENARIO_ROW_KEYS, label); + for (const key of ["phase", "metric"]) { + if (typeof row[key] !== "string" || !SCENARIO_LABEL.test(row[key])) { + throw new Error(`${label}.${key} must be a short plain label`); + } + } + if (row.unit !== "bytes" && row.unit !== "count") { + throw new Error(`${label}.unit must be bytes or count`); + } + assertMetric(row.observed, `${label}.observed`); + assertMetric(row.ceiling, `${label}.ceiling`); + const key = scenarioKey(row); + if (seen.has(key)) throw new Error(`${label} repeats an earlier row`); + seen.add(key); + } + } + return value; +} + +function scenarioKey(row) { + return `${row.phase}|${row.metric}`; +} + +function readArtifactFile(directory, name, validate) { if (!directory) return undefined; - const file = path.join(directory, RESULT_FILE); + const file = path.join(directory, name); if (!fs.existsSync(file)) return undefined; const stat = fs.lstatSync(file); if (!stat.isFile() || stat.size > 64 * 1_024) { - throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + throw new Error(`${name} must be a regular file smaller than 64 KiB`); } - return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); + return validate(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function readResult(directory) { + return readArtifactFile(directory, RESULT_FILE, validateResult); +} + +// Older runs upload only the result file, so a run without scenarios is still a valid run. +function readScenarios(directory) { + return readArtifactFile(directory, SCENARIOS_FILE, validateScenarios); } function formatBytes(bytes) { @@ -103,7 +158,7 @@ function formatBytes(bytes) { } function formatValue(value, kind) { - return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); + return kind === "bytes" ? formatBytes(value) : value.toLocaleString("en-US"); } function formatImpact(current, baseline, kind) { @@ -136,6 +191,77 @@ const METRICS = [ { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, ]; +function providerLabel(provider) { + return provider === "codex" ? "Codex" : "Claude"; +} + +/** + * The scenario rows of a run against the same rows on `main`. Rows that fail or differ from + * `main` are listed in the open; the full table is collapsed below them. + */ +function renderScenarios(current, baseline, ceilingChanges) { + if (!current) return { lines: [], failed: false }; + const header = [ + "| Provider | Scenario | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | --- |", + ]; + const all = []; + const notable = []; + let failed = false; + let withoutBaseline = 0; + + for (const provider of PROVIDERS) { + const baselineRows = new Map( + (baseline?.providers[provider] ?? []).map((row) => [scenarioKey(row), row]), + ); + for (const row of current.providers[provider]) { + const before = baselineRows.get(scenarioKey(row)); + const beforeObserved = before?.unit === row.unit ? before.observed : undefined; + const pass = row.observed <= row.ceiling; + failed ||= !pass; + if (beforeObserved === undefined) withoutBaseline += 1; + const line = `| ${providerLabel(provider)} | \`${row.phase}\` | \`${row.metric}\` | ${beforeObserved === undefined ? "—" : formatValue(beforeObserved, row.unit)} | ${formatValue(row.observed, row.unit)} | ${formatImpact(row.observed, beforeObserved, row.unit)} | ${formatValue(row.ceiling, row.unit)} | ${pass ? "✅" : "❌"} |`; + all.push(line); + if (!pass || (beforeObserved !== undefined && beforeObserved !== row.observed)) { + notable.push(line); + } + if (beforeObserved !== undefined && before.ceiling !== row.ceiling) { + ceilingChanges.push( + `- ${providerLabel(provider)} \`${row.phase}\` \`${row.metric}\`: ${formatValue(before.ceiling, row.unit)} → ${formatValue(row.ceiling, row.unit)}`, + ); + } + } + } + + const compared = all.length - withoutBaseline; + const summary = !baseline + ? `No \`main\` baseline exists yet for these ${all.length} scenario measurements.` + : [ + notable.length > 0 + ? `${notable.length} of ${all.length} scenario measurements changed or failed.` + : `${compared === all.length ? "All " : ""}${compared} scenario measurements match \`main\`.`, + ...(withoutBaseline > 0 ? [`${withoutBaseline} have no \`main\` baseline.`] : []), + ].join(" "); + return { + failed, + lines: [ + "### Other scenarios", + "", + summary, + ...(notable.length > 0 ? ["", ...header, ...notable] : []), + "", + "
", + `All ${all.length} scenario measurements`, + "", + ...header, + ...all, + "", + "
", + "", + ], + }; +} + function renderComment(input) { const current = input.current; const baseline = input.baseline; @@ -154,16 +280,22 @@ function renderComment(input) { const pass = observed <= ceiling; failed ||= !pass; rows.push( - `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + `| ${providerLabel(provider)} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( - `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + `- ${providerLabel(provider)} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); } } } + const scenarios = renderScenarios( + input.currentScenarios, + input.baselineScenarios, + ceilingChanges, + ); + failed ||= scenarios.failed; const baselineLink = input.baselineRun ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` @@ -203,6 +335,7 @@ function renderComment(input) { "| --- | --- | ---: | ---: | ---: | ---: | --- |", ...rows, "", + ...scenarios.lines, `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, "", "
", @@ -212,7 +345,7 @@ function renderComment(input) { "", ...PROVIDERS.map( (provider) => - `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + `- ${providerLabel(provider)} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, ), "", "
", @@ -437,7 +570,14 @@ async function publish({ github, context, core }) { url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, } : undefined; - const body = renderComment({ current, baseline, currentRun, baselineRun }); + const body = renderComment({ + current, + baseline, + currentScenarios: readScenarios(process.env.PR_RESULT_DIR), + baselineScenarios: readScenarios(process.env.BASELINE_RESULT_DIR), + currentRun, + baselineRun, + }); const published = await upsertCommentForCurrentHead( github, context, @@ -458,4 +598,5 @@ module.exports = { resolve, upsertCommentForCurrentHead, validateResult, + validateScenarios, }; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 8c4595ccf0c6a..3a953a8bfc16b 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -2,9 +2,11 @@ const assert = require("node:assert/strict"); const test = require("node:test"); const { + renderComment, resolve, upsertCommentForCurrentHead, validateResult, + validateScenarios, } = require("./thread-transfer-report.cjs"); function result(overrides = {}) { @@ -40,6 +42,29 @@ function result(overrides = {}) { }; } +function scenarios(overrides = {}) { + const rows = [ + { + phase: "long turn finished, cold open", + metric: "HTTP decoded", + unit: "bytes", + observed: 390_000, + ceiling: 420_000, + }, + { + phase: "idle thread resume", + metric: "WebSocket messages", + unit: "count", + observed: 1, + ceiling: 1, + }, + ]; + return { + schemaVersion: 1, + providers: { codex: [{ ...rows[0], ...overrides }, rows[1]], claudeAgent: rows }, + }; +} + test("validates the fixed artifact schema", () => { assert.equal(validateResult(result()).schemaVersion, 1); assert.throws( @@ -50,6 +75,60 @@ test("validates the fixed artifact schema", () => { () => validateResult(result({ totalWireBytes: "lots" })), /non-negative safe integer/, ); + + assert.equal(validateScenarios(scenarios()).schemaVersion, 1); + for (const phase of ["@everyone", "[link](https://example.com)", "a | b", "", ""]) { + assert.throws(() => validateScenarios(scenarios({ phase })), /short plain label/); + } + assert.throws(() => validateScenarios(scenarios({ note: "extra" })), /unexpected fields/); + assert.throws(() => validateScenarios(scenarios({ ceiling: null })), /non-negative safe integer/); + assert.throws( + () => + validateScenarios(scenarios({ phase: "idle thread resume", metric: "WebSocket messages" })), + /repeats an earlier row/, + ); +}); + +test("lists scenario rows that changed or failed above the collapsed table", () => { + const run = { sha: "abcdef1234", url: "https://example.com/run", conclusion: "success" }; + const render = (currentScenarios, baselineScenarios) => + renderComment({ + current: result(), + baseline: result(), + currentScenarios, + baselineScenarios, + currentRun: run, + baselineRun: { ...run, matchesBase: true }, + }); + const open = (body) => body.slice(0, body.indexOf("
")); + + 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 logo + +

-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. +

+ + T3 Code on desktop with a finished Claude thread, next to the phone app asking to approve a command from Codex + +

-## 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` SELECT counts.thread_id, - SUM(CASE WHEN counts.run_id IS NULL OR r.status <> 'rolled_back' + SUM(CASE WHEN counts.run_key = '' OR r.status <> 'rolled_back' THEN counts.item_count ELSE 0 END) AS item_count, - SUM(CASE WHEN counts.run_id IS NULL THEN counts.item_count ELSE 0 END) + SUM(CASE WHEN counts.run_key = '' THEN counts.item_count ELSE 0 END) AS runless_item_count - FROM ( - SELECT thread_id, run_id, COUNT(*) AS item_count - FROM orchestration_v2_projection_turn_items - WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) - GROUP BY thread_id, run_id - ) counts - LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_id + FROM orchestration_v2_projection_turn_item_counts counts + LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_key + WHERE counts.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY counts.thread_id `; @@ -7638,6 +8510,7 @@ export const layer: Layer.Layer = readonly pinned_at: string | null; readonly snoozed_until: string | null; readonly may_be_live: number; + readonly has_remote_tasks: number; }>` SELECT t.thread_id, @@ -7683,7 +8556,15 @@ export const layer: Layer.Layer = SELECT 1 FROM json_each(t.payload_json, '$.pullRequests') link WHERE json_type(link.value, '$.watch') IS NOT NULL ) - ) AS may_be_live + ) AS may_be_live, + -- One list for the whole read, found through the child_thread_id index. A lookup per + -- thread would parse the payload of every subagent row. + t.thread_id IN ( + SELECT subagent.thread_id FROM orchestration_v2_projection_subagents subagent + WHERE subagent.origin = 'app_owned' + AND subagent.child_thread_id IS NULL + AND json_type(subagent.payload_json, '$.remoteChild') = 'object' + ) AS has_remote_tasks FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL AND json_extract(t.payload_json, '$.archivedAt') IS NULL @@ -7710,6 +8591,7 @@ export const layer: Layer.Layer = pinnedAt: row.pinned_at === null ? null : DateTime.makeUnsafe(row.pinned_at), snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), mayBeLive: row.may_be_live === 1, + hasRemoteTasks: row.has_remote_tasks === 1, })); }, Effect.mapError(shellSnapshotReadError)); @@ -7931,6 +8813,7 @@ export const layer: Layer.Layer = hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), isNodeStored: readOnReader(isNodeStored), getMessageCount: readOnReader(getMessageCount), + getTranscriptByteFloor: readOnReader(getTranscriptByteFloor), getSubagentCount: readOnReader(getSubagentCount), listAppOwnedChildThreadIds: readOnReader(listAppOwnedChildThreadIds), hasPendingDelegatedCompletion: readOnReader(hasPendingDelegatedCompletion), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index a8bc76977a8ac..34ff5a7631105 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -239,6 +239,7 @@ it.effect( getThreadHistoryPage: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getTranscriptByteFloor: () => Effect.die("unused transcript byte floor"), hasPendingDelegatedCompletion: () => Effect.die("unused hasPendingDelegatedCompletion"), getSubagentCount: () => Effect.die("unused subagent count"), listAppOwnedChildThreadIds: () => Effect.die("unused listAppOwnedChildThreadIds"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 23e7689504876..ac2692907cbbc 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -201,7 +201,7 @@ function startWithMissingWorktree( const start = Effect.gen(function* () { return yield* (yield* ProviderTurnStart.ProviderTurnStartServiceV2) - .start({ threadId, runId }) + .start({ threadId, runId, willRetry: true }) .pipe(Effect.flip); }).pipe(Effect.provide(layer), Effect.runPromise); return { @@ -288,6 +288,7 @@ function makeLocalCommandHarness(input: { readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; readonly failReadsAfterRunning?: boolean; + readonly recoveryReadFailure?: string; readonly previousTurn?: { readonly runStatus: "failed" | "completed"; readonly turnStatus: "running" | "completed" | "interrupted"; @@ -924,14 +925,21 @@ function makeLocalCommandHarness(input: { const getRuntimeRecoveryProjection = vi.fn< ProjectionStore.ProjectionStoreV2Shape["getRuntimeRecoveryProjection"] >(() => - Effect.as(failReadIfRunning, { - ...projection, - hasConversation: projection.messages.some( - (m) => - m.role === "user" && - (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), - ), - }), + input.recoveryReadFailure !== undefined + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: input.recoveryReadFailure, + }), + ) + : Effect.as(failReadIfRunning, { + ...projection, + hasConversation: projection.messages.some( + (m) => + m.role === "user" && + (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), + ), + }), ); const layer = ProviderTurnStart.layer.pipe( Layer.provide( @@ -1660,6 +1668,25 @@ effectIt.effect("starts another provider in the same checkout while a turn is pe }), ); +effectIt.effect("fails a starting run when its recovery state cannot be read", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + recoveryReadFailure: "stored turn item no longer decodes", + }); + + yield* harness.start.pipe(Effect.ignore); + + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + const projection = harness.projection(); + expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null }); + expect(projection.turnItems).toMatchObject([{ type: "error", status: "failed" }]); + }), +); + effectIt.effect("terminalizes a starting run when its provider session cannot open", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index cf52173f45c6a..2de9fcbc70d9d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -702,9 +702,19 @@ export const layer: Layer.Layer< runs: current.runs, turnItems: current.turnItems, }); - const inheritedBackgroundTurnItems = yield* projectionStore - .getRuntimeRecoveryProjection(projection.thread.id) - .pipe(Effect.map(selectInheritedBackgroundItems)); + const recoveryResult = yield* Effect.result( + projectionStore.getRuntimeRecoveryProjection(projection.thread.id), + ); + if (recoveryResult._tag === "Failure") { + if (input.willRetry === true) return yield* recoveryResult.failure; + yield* settleStartFailure({ + signal: "runtime-recovery-read-failure", + title: "Couldn't read thread state", + error: recoveryResult.failure, + }); + return; + } + const inheritedBackgroundTurnItems = selectInheritedBackgroundItems(recoveryResult.success); const providerSessionId = providerThread.providerSessionId; const runControls = makeRunControls({ threadId: projection.thread.id, diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 6fdea57bf9e1e..511b65c3b2aa1 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -38,6 +38,7 @@ import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { + combinePullRequestWatchWakes, evaluatePullRequestWatch, PULL_REQUEST_WATCH_NO_CHECKS_MS, pullRequestWatchMessage, @@ -98,6 +99,22 @@ interface ReadPlan { readonly activity: boolean; } +interface Wake { + readonly text: string; + readonly notification: OrchestrationV2Notification; + readonly includesRemarks?: boolean; +} + +/** A wake a pass holds back so each thread gets one for everything the pass found. */ +interface HeldWake { + readonly groupKey: string; + readonly target: WatchTarget; + readonly next: ThreadPullRequestWatch | null; + readonly endedWatch?: ThreadPullRequestWatch; + readonly wake: Wake; + readonly reason?: WatchEndReason; +} + const watchKey = ({ thread, watch }: WatchTarget) => `${thread.id} ${watch.startedAt}`; type WatchEndReason = @@ -284,11 +301,7 @@ export const make = Effect.gen(function* () { const record = Effect.fn("PullRequestWatchReactor.record")(function* ( target: WatchTarget, next: ThreadPullRequestWatch | null, - wake?: { - readonly text: string; - readonly notification: OrchestrationV2Notification; - readonly includesRemarks?: boolean; - }, + wake?: Wake, endedWatch?: ThreadPullRequestWatch, ) { const uuid = yield* random.uuidV4(); @@ -411,18 +424,102 @@ export const make = Effect.gen(function* () { ), ); - const closed = (target: WatchTarget) => - record(target, null, { - text: `Pull request #${target.link.number} (${target.link.url}) was closed, so T3 Code stopped watching it. Call watch_pull_request if it reopens.`, - notification: { - source: { kind: "monitor" }, - outcome: "updated", - summary: `#${target.link.number}: closed, stopped watching`, + /** Wakes the current pass found, sent by `sendHeld` once every read of the pass is done. */ + let held: Array = []; + const hold = (wake: HeldWake) => Effect.sync(() => void held.push(wake)); + + const sendOne = (item: HeldWake) => + record(item.target, item.next, item.wake, item.endedWatch).pipe( + Effect.tap(() => woke(item.target)), + Effect.tap(() => (item.reason === undefined ? Effect.void : ended(item.target, item.reason))), + Effect.catchCause((cause) => { + lastReads.delete(item.groupKey); + return logFailure("pull request watch update failed", { + threadId: item.target.thread.id, + pullRequest: item.groupKey, + })(cause); + }), + ); + + const sendTogether = Effect.fn("PullRequestWatchReactor.sendTogether")(function* ( + items: readonly [HeldWake, ...ReadonlyArray], + ) { + const [first, ...rest] = items; + const uuid = yield* random.uuidV4(); + yield* engine.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`server:pr-watch:${first.target.thread.id}:${uuid}`), + threadId: first.target.thread.id, + ...identityOf(first.target.link), + startedAt: first.target.watch.startedAt, + watch: first.next, + ...(first.endedWatch === undefined ? {} : { endedWatch: first.endedWatch }), + others: rest.map((item) => ({ + ...identityOf(item.target.link), + startedAt: item.target.watch.startedAt, + watch: item.next, + ...(item.endedWatch === undefined ? {} : { endedWatch: item.endedWatch }), + ...(item.wake.includesRemarks === true ? { includesRemarks: true } : {}), + })), + wake: { + ...combinePullRequestWatchWakes([first.wake, ...rest.map((item) => item.wake)]), + ...(first.wake.includesRemarks === true ? { includesRemarks: true } : {}), + sections: items.map(({ wake }) => ({ + text: wake.text, + outcome: wake.notification.outcome, + summary: wake.notification.summary, + })), + messageId: MessageId.make(`message:pr-watch:${uuid}`), }, - }).pipe( - Effect.tap(() => woke(target)), - Effect.tap(() => ended(target, "closed")), + }); + yield* Effect.forEach( + items, + (item) => + woke(item.target).pipe( + Effect.andThen(item.reason === undefined ? Effect.void : ended(item.target, item.reason)), + ), + { discard: true }, ); + }); + + const sendHeld = Effect.suspend(() => { + const byThread = new Map>(); + for (const item of held) { + byThread.set(item.target.thread.id, [...(byThread.get(item.target.thread.id) ?? []), item]); + } + held = []; + return Effect.forEach( + byThread.values(), + ([first, ...rest]) => + rest.length === 0 + ? sendOne(first!) + : // A watch that ended or moved during the pass fails the whole command; each then + // goes on its own, as the orchestrator decides for it alone. + sendTogether([first!, ...rest]).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => Effect.forEach([first!, ...rest], sendOne, { discard: true }), + ), + ), + { discard: true }, + ); + }); + + const closed = (groupKey: string, target: WatchTarget) => + hold({ + groupKey, + target, + next: null, + wake: { + text: `Pull request #${target.link.number} (${target.link.url}) was closed, so T3 Code stopped watching it. Call watch_pull_request if it reopens.`, + notification: { + source: { kind: "monitor" }, + outcome: "updated", + summary: `#${target.link.number}: closed, stopped watching`, + }, + }, + reason: "closed", + }); const endsWithoutRead = ({ thread, link }: WatchTarget): WatchEndReason | undefined => link.snapshot?.state === "merged" @@ -545,7 +642,7 @@ export const make = Effect.gen(function* () { lastReads.delete(group.key); return yield* eachTarget(group, (target) => detail.state === "closed" - ? closed(target) + ? closed(group.key, target) : record(target, null).pipe(Effect.tap(() => ended(target, "merged"))), ); } @@ -588,13 +685,17 @@ export const make = Effect.gen(function* () { includesRemarks: report.changes.some((change) => change.kind === "remarks"), }; // An ending watch keeps what this pass saw, so watching again starts from it. - return ( + return hold( report.exhausted - ? record(target, null, wake, report.next) - : record(target, report.next, wake) - ).pipe( - Effect.tap(() => woke(target)), - Effect.tap(() => (report.exhausted ? ended(target, "comment-limit") : Effect.void)), + ? { + groupKey: group.key, + target, + next: null, + endedWatch: report.next, + wake, + reason: "comment-limit", + } + : { groupKey: group.key, target, next: report.next, wake }, ); } return watchesEqual(report.next, target.watch) ? Effect.void : record(target, report.next); @@ -604,6 +705,7 @@ export const make = Effect.gen(function* () { /** Every watch, or with `changed`, only that pull request's after its host reported a change. */ // The sweep and change reads each name their own span. const pass = Effect.fnUntraced(function* (changed: string | undefined) { + held = []; const threads = yield* projections.getThreadsWithPullRequests(); const targets = threads.flatMap((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => @@ -697,6 +799,7 @@ export const make = Effect.gen(function* () { ), { concurrency: "unbounded", discard: true }, ); + yield* sendHeld; }, passes.withPermit); const sweep = pass(undefined).pipe( diff --git a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts index 4bb8c103f6df7..f8363bef98f9a 100644 --- a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts +++ b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts @@ -222,6 +222,10 @@ it.layer(layerTest)("a delegated task in a linked environment", (it) => { assert.deepEqual(item?.type === "subagent" ? item.remoteChild : undefined, remoteChild); // No thread was created here for the child. assert.equal(yield* orchestrator.getThreadShell(remoteChild.threadId), null); + const other = yield* seedLiveParent("records-no-task"); + const index = yield* (yield* ProjectionStore.ProjectionStoreV2).listActiveShellIndex(); + assert.isTrue(index.find((entry) => entry.id === parent.threadId)?.hasRemoteTasks); + assert.isFalse(index.find((entry) => entry.id === other.threadId)?.hasRemoteTasks); }), ); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 0fa5251a7ef73..9a2b3c9a949f5 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -19,6 +19,8 @@ import { OrchestrationV2ThreadShell as ThreadShellSchema, } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - a synchronous digest of a few project rows. +import * as NodeCrypto from "node:crypto"; import type * as Arr from "effect/Array"; import type * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -31,11 +33,113 @@ import * as Schema from "effect/Schema"; import type * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; +import type * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import { bufferLatestLiveStream } from "./LiveStreamBudget.ts"; import type { ActiveShellIndexEntry } from "./ProjectionStore.ts"; type ShellDelta = Extract; +// Replaying each intervening event costs a shell refetch; past this gap a +// single O(active-threads) snapshot is cheaper and bounded. +const SHELL_RESUME_MAX_GAP = 1_000; +// Row counts do not bound large tool payloads; sum stored JSON bytes in SQL and snapshot above budget. +const SHELL_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; + +/** + * Whether the events after a shell cursor can be replayed. The socket resume and the HTTP + * snapshot's `resumeFrom` ask the same question, so a client the route tells to resume is not + * refused by the socket. + */ +export const canResumeShellFrom = Effect.fnUntraced(function* ( + events: Pick< + OrchestrationEventStore.OrchestrationEventStore["Service"], + "prunedThroughSequence" | "getReplayStats" + >, + afterSequence: number, + headSequence: number, +) { + // A cursor at the head missed nothing, even where pruning has passed the head. + if (afterSequence === headSequence) return true; + const replayGap = headSequence - afterSequence; + if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) return false; + if (afterSequence < (yield* events.prunedThroughSequence)) return false; + const stats = yield* events.getReplayStats({ afterSequence, throughSequence: headSequence }); + if (stats.rawPayloadBytes > SHELL_REPLAY_PAYLOAD_BUDGET_BYTES) { + yield* Effect.logDebug("orchestration replay replaced by snapshot", { + afterSequence, + headSequence, + replayGap, + eventCount: stats.eventCount, + payloadBytes: stats.rawPayloadBytes, + payloadBudgetBytes: SHELL_REPLAY_PAYLOAD_BUDGET_BYTES, + }); + return false; + } + return true; +}); + +/** + * Answers a client that asks for the shell with the cursor of the rows it holds: `resumable` + * when the socket can replay from that cursor, so the client keeps its rows, else the snapshot. + */ +export const loadShellUnlessResumable = Effect.fnUntraced(function* (input: { + readonly events: Pick< + OrchestrationEventStore.OrchestrationEventStore["Service"], + "prunedThroughSequence" | "getReplayStats" | "latestApplicationSequence" + >; + readonly resumeFrom: number | undefined; + readonly loadSnapshot: Effect.Effect; +}) { + if ( + input.resumeFrom !== undefined && + (yield* canResumeShellFrom( + input.events, + input.resumeFrom, + yield* input.events.latestApplicationSequence, + )) + ) { + return { resumable: true as const }; + } + return yield* input.loadSnapshot; +}); + +/** + * Names the repository identities a list of project shells carries. A client sends back the one + * its HTTP snapshot had, and a resume that computes the same one skips its marked snapshot. + */ +export function projectIdentitiesDigest( + projects: ReadonlyArray>, +): string { + const hash = NodeCrypto.createHash("sha256"); + for (const project of projects) { + hash.update(JSON.stringify([project.id, project.repositoryIdentity ?? null])); + } + return hash.digest("base64url"); +} + +/** + * The resolved roots a resume sends. A client that names the current identities holds them + * already, but it keeps a cached identity over an absent one, so a root resolved to no identity + * still goes out. + */ +export function resumeIdentityRoots(input: { + readonly projects: ReadonlyArray< + Pick + >; + readonly resolvedRepositoryIdentityRoots: ReadonlyArray; + readonly knownProjectIdentities: string | undefined; +}): ReadonlyArray { + if (input.knownProjectIdentities !== projectIdentitiesDigest(input.projects)) { + return input.resolvedRepositoryIdentityRoots; + } + const withoutIdentity = new Set( + input.projects.flatMap((project) => + project.repositoryIdentity == null ? [project.workspaceRoot] : [], + ), + ); + return input.resolvedRepositoryIdentityRoots.filter((root) => withoutIdentity.has(root)); +} + /** Snapshots, synchronization, and enrichment markers must bypass full-state coalescing. */ export const bufferShellLiveStream = ( source: Stream.Stream, @@ -75,7 +179,7 @@ function buildActiveShellSnapshot(input: { type ShellTrimRow = Pick< OrchestrationV2ThreadShell, "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" ->; +> & { readonly hasRemoteTasks?: boolean }; /** * What a subscriber's trims read across every active row. Computed from full shells or from the @@ -84,6 +188,8 @@ type ShellTrimRow = Pick< interface ShellTrimFacts { readonly subagentParentIds: ReadonlySet; readonly subagentThreadIds: Set; + /** Only the store's index knows these; shells alone leave it empty. */ + readonly remoteTaskParentIds: ReadonlySet; /** Undefined when no thread is settled, so none is older. */ readonly olderSettledBeforeMs: number | undefined; readonly olderSettledCounts: ReadonlyArray; @@ -94,8 +200,10 @@ const NEWEST_SETTLED_KEPT = 25; function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { const subagentParentIds = new Set(); const subagentThreadIds = new Set(); + const remoteTaskParentIds = new Set(); const settledTimes: Array = []; for (const row of rows) { + if (row.hasRemoteTasks === true) remoteTaskParentIds.add(row.id); if (row.lineage.relationshipToParent === "subagent") { subagentThreadIds.add(row.id); if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); @@ -121,6 +229,7 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { return { subagentParentIds, subagentThreadIds, + remoteTaskParentIds, olderSettledBeforeMs, olderSettledCounts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), }; @@ -140,13 +249,14 @@ function hiddenChildAncestorIds( function omitIdleSubagentShellsWith( threads: ReadonlyArray, - facts: Pick, + facts: Pick, ): ReadonlyArray { return threads.filter( (thread) => thread.lineage.relationshipToParent !== "subagent" || isLiveSubagentShell(thread) || - facts.subagentParentIds.has(thread.id), + facts.subagentParentIds.has(thread.id) || + facts.remoteTaskParentIds.has(thread.id), ); } @@ -156,7 +266,10 @@ function omitOlderSettledShellsWith( readonly snapshotSequence: number; readonly now: DateTime.Utc; }, - facts: Pick, + facts: Pick< + ShellTrimFacts, + "olderSettledBeforeMs" | "olderSettledCounts" | "remoteTaskParentIds" + >, ): { readonly threads: ReadonlyArray; readonly olderSettled: OrchestrationV2OlderSettledThreads; @@ -170,7 +283,8 @@ function omitOlderSettledShellsWith( thread.pinnedAt != null || (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || isLiveSubagentShell(thread) || - ancestorIds.has(thread.id), + ancestorIds.has(thread.id) || + facts.remoteTaskParentIds.has(thread.id), ); return { threads, @@ -185,7 +299,8 @@ function omitOlderSettledShellsWith( /** * Idle subagent children have no sidebar row, so clients that opt in read them from their thread * family instead. A child stays while it is live or the parent of another child, so the request - * and working walks over shell rows never meet a gap in a nested chain. + * and working walks over shell rows never meet a gap in a nested chain. It also stays as the + * parent of a task in a linked environment, whose clients nest that task under it. */ export function omitIdleSubagentShells( threads: ReadonlyArray, @@ -214,8 +329,8 @@ export function activeShellRowsForSubscriber( /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while - * pinned, snoozed, live, or above a kept subagent or side chat, so walks over shell rows still find - * them. + * pinned, snoozed, live, above a kept subagent or side chat, or the parent of a task in a linked + * environment, so walks over shell rows and other environments' tasks still find them. */ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; @@ -261,7 +376,10 @@ function shellSubscriberThreadIds( const subagents = index.filter( (entry) => entry.lineage.relationshipToParent === "subagent" && - (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), + (!options.omitIdleSubagents || + entry.mayBeLive || + entry.hasRemoteTasks || + facts.subagentParentIds.has(entry.id)), ); const ancestorIds = hiddenChildAncestorIds([ ...subagents, @@ -280,6 +398,7 @@ function shellSubscriberThreadIds( entry.pinnedAt != null || entry.snoozedUntil != null || entry.mayBeLive || + entry.hasRemoteTasks || ancestorIds.has(entry.id) ? [entry.id] : [], diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 8d784a91f59cb..ad0c179d783fe 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1635,6 +1635,8 @@ const restartCompletionWake = Effect.fn("restartCompletionWake")(function* ( "events" | "started" | "startFirstTurn" >, siblingFinishesFirst: boolean, + userRunBeforeWake = false, + intentNamesTarget = false, ) { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; @@ -1736,6 +1738,24 @@ const restartCompletionWake = Effect.fn("restartCompletionWake")(function* ( })), }); yield* finishTurn(origin); + // The wake queues behind a user run that started after the origin run finished. + if (userRunBeforeWake) { + const userRunning = yield* watchTurnRunning(2); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:user-run"), + threadId, + messageId: MessageId.make("message:user-run"), + text: "unrelated question", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(userRunning); + } const completeTask = (taskId: NodeId) => orchestrator.dispatch({ @@ -1769,19 +1789,55 @@ const restartCompletionWake = Effect.fn("restartCompletionWake")(function* ( createdBy: "agent", creationSource: "server", }); - const wakeRunning = yield* watchTurnRunning(2); + const wakeOrdinal = userRunBeforeWake ? 3 : 2; + const wakeRunning = yield* watchTurnRunning(wakeOrdinal); + if (userRunBeforeWake) yield* finishTurn(harness.started[1]!); yield* orchestrator.resumeQueuedRuns; yield* worker.drain(); yield* Fiber.join(wakeRunning); - const wake = harness.started[1]!; + const wake = harness.started[wakeOrdinal - 1]!; assert.equal(wake.message.messageId, firstDelivery.messageId); + if (userRunBeforeWake) { + // The wake's run holds a compaction, so the origin run's records are older than the window. + const compactedAt = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("event:wake-compaction"), + type: "turn-item.updated" as const, + threadId, + runId: wake.runId, + nodeId: wake.rootNodeId, + occurredAt: compactedAt, + payload: { + id: TurnItemId.make("item:wake-compaction"), + threadId, + runId: wake.runId, + nodeId: wake.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1_000, + status: "completed" as const, + title: null, + startedAt: compactedAt, + completedAt: compactedAt, + updatedAt: compactedAt, + type: "compaction" as const, + driver, + }, + }, + ], + }); + } if (siblingFinishesFirst) { yield* completeTask(siblingTaskId); } // The provider cannot steer natively, so the steer restarts the wake run. - const steeredRunning = yield* watchTurnRunning(3); + const steeredRunning = yield* watchTurnRunning(wakeOrdinal + 1); yield* orchestrator.dispatch({ type: "message.dispatch", commandId: CommandId.make("command:steer-wake"), @@ -1790,13 +1846,15 @@ const restartCompletionWake = Effect.fn("restartCompletionWake")(function* ( text: "also check the logs", attachments: [], modelSelection: runSelection, - dispatchMode: { type: "steer_active", targetRunId: wake.runId }, + ...(intentNamesTarget + ? { dispatchMode: { type: "start_immediately" as const }, deliveryIntent: "restart" as const } + : { dispatchMode: { type: "steer_active" as const, targetRunId: wake.runId } }), createdBy: "user", creationSource: "web", }); yield* worker.drain(); yield* Fiber.join(steeredRunning); - const steered = harness.started[2]!; + const steered = harness.started[wakeOrdinal]!; assert.equal(steered.runId, wake.runId); assert.equal( (yield* orchestrator.getThreadProjection(threadId)).attempts.find( @@ -1883,6 +1941,35 @@ it.effect("wakes the parent for a sibling result after a steer restarts the comp ), ); +it.effect.each([false, true])( + "settles a steered completion wake whose parent run is before the last compaction (restart by intent: %s)", + (intentNamesTarget) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness( + `steered-wake-after-compaction-${intentNamesTarget}`, + true, + false, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, steered, firstTaskId, finishTurn } = yield* restartCompletionWake( + harness, + false, + true, + intentNamesTarget, + ); + yield* finishTurn(steered); + const tasks = (yield* orchestrator.getThreadProjection(threadId)).subagents; + assert.equal( + tasks.find((task) => task.id === firstTaskId)?.completionDelivery?.state, + "delivered", + ); + }).pipe(Effect.provide(harness.layer)); + }), + ), +); + it.effect( "offers the next completion wake when a steer restarts a wake a sibling finished behind", () => diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index c3464eecea6f9..7ea61c01f9872 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -372,6 +372,28 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("skips the empty-response fallback the timeline hides beside an HTML render", () => + Effect.gen(function* () { + const projection = yield* setup; + const shownRunId = RunId.make("run:shown"); + yield* commit([run(threadId, shownRunId, 2)]); + yield* putItems([ + { + ...item("render", 0, ""), + type: "dynamic_tool", + toolName: "html_render", + input: {}, + output: { htmlRender: { attachmentId: "visual", title: "Result", height: 120 } }, + }, + item("hidden", 1, ""), + { ...item("shown", 2, ""), runId: shownRunId }, + ]); + const result = yield* projection.searchThread({ threadId, query: "empty" }); + assert.equal(result.totalMatches, 1); + assert.equal(result.match?.entryId, "shown"); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("searches displayed skill labels and invalidates parsed text when labels change", () => Effect.gen(function* () { const projection = yield* setup; diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index 44099f4ebbc10..84e29eb29e76f 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText"; import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -34,6 +35,7 @@ interface FindPayload extends FindRow { readonly entryId: string; readonly runId: OrchestrationV2TurnItem["runId"]; readonly type: OrchestrationV2TurnItem["type"]; + readonly emptyText: boolean; } interface FindDocument extends FindRow { readonly entryId: string; @@ -177,7 +179,7 @@ function resultForSelection( export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( readIndex: (threadId: ThreadId) => Effect.Effect< readonly (Omit & { - readonly item: Pick; + readonly item: Pick; })[], ProjectionStoreV2Error >, @@ -216,11 +218,13 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( entry_id: string; run_id: OrchestrationV2TurnItem["runId"]; item_type: OrchestrationV2TurnItem["type"]; + empty_text: number | null; }>` SELECT item.thread_id, item.turn_item_id, item.payload_json, COALESCE(json_extract(item.payload_json, '$.messageId'), item.turn_item_id) AS entry_id, json_extract(item.payload_json, '$.runId') AS run_id, - json_extract(item.payload_json, '$.type') AS item_type + json_extract(item.payload_json, '$.type') AS item_type, + json_extract(item.payload_json, '$.text') = '' AS empty_text FROM orchestration_v2_projection_turn_items AS item JOIN json_each(${sources}) AS wanted ON item.thread_id = json_extract(wanted.value, '$.threadId') @@ -242,6 +246,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( entryId: payload.entry_id, runId: payload.run_id, type: payload.item_type, + emptyText: payload.empty_text === 1, }); }); }); @@ -315,11 +320,33 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( row.item.type, ), ); - const payloads: FindPayload[] = []; - for (let start = 0; start < candidates.length; start += 128) { - payloads.push(...(yield* load(threadId, candidates.slice(start, start + 128)))); - } - return { payloads, cwd, sequence, cacheKey, sourceThreadIds }; + const loadAll = Effect.fnUntraced(function* (rows: readonly FindRow[]) { + const loaded: FindPayload[] = []; + for (let start = 0; start < rows.length; start += 128) { + loaded.push(...(yield* load(threadId, rows.slice(start, start + 128)))); + } + return loaded; + }); + const payloads = yield* loadAll(candidates); + // Only runs with an empty reply can hide its fallback, so other tool payloads stay unread. + const emptyReplyRunIds = new Set( + payloads.flatMap((row) => + row.type === "assistant_message" && row.emptyText && row.runId !== null + ? [row.runId] + : [], + ), + ); + const toolPayloads = yield* loadAll( + index + .map((row, position) => ({ ...row, position })) + .filter( + (row) => + row.item.type === "dynamic_tool" && + row.item.runId !== null && + emptyReplyRunIds.has(row.item.runId), + ), + ); + return { payloads, toolPayloads, cwd, sequence, cacheKey, sourceThreadIds }; }, (effect) => sql.withTransaction(effect).pipe(NodeSqliteClient.readOnly), (effect, input) => @@ -338,7 +365,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( // Release SQLite before decoding and parsing. Both frames describe one snapshot. const data = yield* snapshot(input, scans); if (data.result) return Stream.succeed(data.result); - const { payloads, cwd, sequence, cacheKey, sourceThreadIds } = data; + const { payloads, toolPayloads, cwd, sequence, cacheKey, sourceThreadIds } = data; const decode = (row: FindPayload) => decodeItem(row.payload).pipe( Effect.map((item) => ({ ...row, item })), @@ -354,6 +381,17 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( if (item.type === "user_input_request" && item.questionAnswer) foldedAnswerRunIds.set(`async-answer:${item.questionAnswer.requestId}`, item.runId); } + // The timeline hides an empty reply's fallback when its run shows an HTML render or MCP app. + const embeddedResultRunIds = new Set(); + for (const payload of toolPayloads) { + const { item } = yield* decode(payload); + if ( + item.type === "dynamic_tool" && + ((item.status === "completed" && htmlRenderFromToolItem(item)) || + mcpAppFromToolItem(item)) + ) + embeddedResultRunIds.add(item.runId); + } const rows = payloads.filter( (row) => row.type !== "user_input_request" && @@ -364,6 +402,15 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( const cached = documents.get(row.position); if (cached) return cached; const decoded = yield* decode(row); + if ( + decoded.item.type === "assistant_message" && + decoded.item.text === "" && + embeddedResultRunIds.has(decoded.item.runId) + ) { + const document = documentFor(decoded, 0); + documents.set(row.position, document); + return document; + } const key = textKey(decoded.item, cwd, input.skills ?? []); const segments = yield* Cache.get( key.text.length <= 32_768 ? textCache.text : textCache.largeText, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 4613d9d1f4f76..ab846252c9e58 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -890,6 +890,7 @@ it.effect( Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ + getTranscriptByteFloor: () => Effect.succeed(0), getThreadSnapshot: (threadId) => Queue.offer(started, threadId).pipe( Effect.andThen(Deferred.await(gates.get(threadId)!)), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 8a0d2d5df6001..e1a1abdc1819b 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -687,7 +687,15 @@ const make = Effect.gen(function* () { const transcriptExportPermits = yield* Semaphore.make(2); const getThreadTranscript = Effect.fn("orchestrationV2.threadManagement.getThreadTranscript")( function* (threadId: ThreadId) { - const { projection } = yield* getThreadSnapshot(threadId); + yield* ensureProjectionTranscript(threadId); + const byteFloor = yield* orchestrator.getTranscriptByteFloor( + threadId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + if (byteFloor > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + return yield* new ThreadTranscriptTooLargeError({ threadId }); + } + const { projection } = yield* orchestrator.getThreadSnapshot(threadId); const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const transcript = { threadId: projection.thread.id, diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index 985815bd73eb4..3992d46e6bf9a 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -12,7 +12,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; -const MAX_OVERDUE_ARM_MS = 24 * 60 * 60 * 1000; +export const MAX_OVERDUE_ARM_MS = 24 * 60 * 60 * 1000; export function limitRecoveryCommand( thread: ProjectionStore.ProjectionLimitRecoveryCandidate, diff --git a/apps/server/src/orchestration-v2/http.test.ts b/apps/server/src/orchestration-v2/http.test.ts index 570d34468d37a..07385bf134d08 100644 --- a/apps/server/src/orchestration-v2/http.test.ts +++ b/apps/server/src/orchestration-v2/http.test.ts @@ -9,6 +9,7 @@ import { ORCHESTRATION_PROTOCOL_HEADER, ORCHESTRATION_PROTOCOL_VERSION_TEXT, OrchestrationV2AppThread, + OrchestrationV2Run, OrchestrationV2TurnItem, OrchestrationV2ThreadTranscript, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -21,6 +22,8 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import * as HttpRouter from "effect/http/HttpRouter"; import * as HttpPlatform from "effect/http/HttpPlatform"; import * as Etag from "effect/http/Etag"; @@ -96,6 +99,24 @@ const items = Schema.decodeUnknownSync(Schema.Array(OrchestrationV2TurnItem))( })), ); +const rolledBackRun = Schema.decodeSync(OrchestrationV2Run)({ + id: "run:rolled-back", + threadId: "source-thread", + ordinal: 1, + providerInstanceId: "codex", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + providerThreadId: null, + userMessageId: "message:rolled-back", + rootNodeId: null, + activeAttemptId: null, + status: "rolled_back", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, +}); + // Serves the orchestration HTTP routes over real stores; `allowed` gates the read scope. const serveOrchestrationHttp = (allowed: () => boolean) => Effect.gen(function* () { @@ -173,6 +194,18 @@ it.effect("exports full history with size, scope, and missing-thread checks", () ), ); + const readTranscriptError = Context.get( + services, + ThreadManagementService.ThreadManagementService, + ) + .getThreadTranscript(thread.id) + .pipe( + Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }), + Effect.flip, + ); + const emptyResponse = yield* read(thread.id); expect(emptyResponse.status).toBe(200); expect( @@ -227,23 +260,80 @@ it.effect("exports full history with size, scope, and missing-thread checks", () }); const response = yield* read(thread.id); expect(response.status).toBe(expectedStatus); - if (expectedStatus === 400) { - const error = yield* Context.get(services, ThreadManagementService.ThreadManagementService) - .getThreadTranscript(thread.id) - .pipe( - Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed(environmentId), - }), - Effect.flip, - ); - expect(error).toBeInstanceOf(ThreadManagementService.ThreadTranscriptTooLargeError); - expect(yield* Effect.promise(() => response.json())).toMatchObject({ - _tag: "EnvironmentRequestInvalidError", - reason: "thread_transcript_too_large", - }); - } + if (expectedStatus === 200) continue; + expect(yield* readTranscriptError).toBeInstanceOf( + ThreadManagementService.ThreadTranscriptTooLargeError, + ); + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + _tag: "EnvironmentRequestInvalidError", + reason: "thread_transcript_too_large", + }); } + const sql = Context.get(services, SqlClient.SqlClient); + const commandItemId = items.find((item) => item.type === "command_execution")!.id; + const setCommandPayload = (payload: Statement.Fragment) => sql` + UPDATE orchestration_v2_projection_turn_items SET payload_json = ${payload} + WHERE turn_item_id = ${commandItemId} + `; + const fill = (bytes: number) => sql`printf('%.*c', ${bytes}, 'x')`; + // Stored bytes the export drops do not count, and neither does a row SQLite cannot parse: + // only what the export keeps can reject it. + yield* setCommandPayload(sql`json_set( + payload_json, '$.output', 'x', + '$.droppedLegacyField', ${fill(PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1)} + )`); + expect((yield* read(thread.id)).status).toBe(200); + const deepField = sql`'"droppedDeepField":' || printf('%.*c', 1001, '[') || printf('%.*c', 1001, ']') || ','`; + yield* setCommandPayload( + sql`replace(payload_json, '"droppedLegacyField":', ${deepField} || '"droppedLegacyField":')`, + ); + expect((yield* read(thread.id)).status).toBe(200); + // Tool output over the limit rejects the export, here as the content array a dynamic tool + // stores. Its only read is the size. + yield* sql` + UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool' + WHERE turn_item_id = ${commandItemId} + `; + yield* setCommandPayload(sql`json_set( + replace(payload_json, ${deepField}, ''), '$.type', 'dynamic_tool', '$.toolName', NULL, + '$.output', + json_array(json_object('type', 'text', 'text', ${fill(PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1)})) + )`); + const reads: Array = []; + expect( + yield* readTranscriptError.pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + reads.push(statement.compile()[0]); + return Effect.succeed(statement); + }), + ), + ).toBeInstanceOf(ThreadManagementService.ThreadTranscriptTooLargeError); + expect(reads).toEqual([expect.stringContaining("octet_length(payload_json)")]); + expect((yield* read(thread.id)).status).toBe(400); + // The export leaves a rolled back run out, so its output cannot reject it. + yield* eventSink.write({ + events: [ + { + id: EventId.make("create-rolled-back-run"), + type: "run.created", + threadId: thread.id, + occurredAt: now, + payload: rolledBackRun, + }, + ], + }); + yield* sql` + UPDATE orchestration_v2_projection_turn_items SET run_id = ${rolledBackRun.id} + WHERE turn_item_id = ${commandItemId} + `; + yield* setCommandPayload(sql`json_set(payload_json, '$.runId', ${rolledBackRun.id})`); + const withoutRolledBackRun = yield* read(thread.id); + expect(withoutRolledBackRun.status).toBe(200); + expect( + (yield* decodeTranscript(yield* Effect.promise(() => withoutRolledBackRun.json()))).items, + ).toHaveLength(199); + expect((yield* read(ThreadId.make("missing"))).status).toBe(404); allowed = false; expect((yield* read(thread.id)).status).toBe(403); @@ -279,6 +369,8 @@ it.effect("leaves older settled threads out of the HTTP shell only when asked", }), ); return (await response.json()) as { + readonly snapshotSequence: number; + readonly projectIdentities?: string; readonly threads: ReadonlyArray<{ readonly id: string }>; readonly olderSettled?: { readonly counts: ReadonlyArray }; }; @@ -292,5 +384,13 @@ it.effect("leaves older settled threads out of the HTTP shell only when asked", [thread.id, ...Array.from({ length: 25 }, (_, index) => `settled-${index + 1}`)].toSorted(), ); expect(cut.olderSettled?.counts).toEqual([{ projectId: thread.projectId, count: 1 }]); + + // A client that holds rows the socket can replay from keeps them; any other cursor reloads. + expect(full.projectIdentities).toBeTypeOf("string"); + expect(yield* shell(`?resumeFrom=${full.snapshotSequence}`)).toEqual({ resumable: true }); + expect(yield* shell(`?resumeFrom=${full.snapshotSequence - 1}`)).toEqual({ resumable: true }); + for (const cursor of [full.snapshotSequence + 1, "x"]) { + expect((yield* shell(`?omitOlderSettled=1&resumeFrom=${cursor}`)).threads).toHaveLength(26); + } }), ); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 3bba05f7db63a..b58ce1b143119 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -31,7 +31,11 @@ import { } from "./threadHistoryPaging.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import { loadSubscriberShellSnapshot } from "./ShellStream.ts"; +import { + loadShellUnlessResumable, + loadSubscriberShellSnapshot, + projectIdentitiesDigest, +} from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -94,10 +98,27 @@ export const layer = HttpApiBuilder.group( options, }); const projects = yield* enrichProjectShells(snapshot.projects); - return { ...snapshot, projects }; + return { ...snapshot, projects, projectIdentities: projectIdentitiesDigest(projects) }; }, ); + // A client that holds rows names their cursor; a small answer tells it to keep them. + const answerShellSnapshot = Effect.fn("http.orchestration.answerShellSnapshot")((query: { + readonly omitIdleSubagents?: string; + readonly omitOlderSettled?: string; + readonly resumeFrom?: string; + }) => { + const resumeFrom = query.resumeFrom === undefined ? Number.NaN : Number(query.resumeFrom); + return loadShellUnlessResumable({ + events: applicationEvents, + resumeFrom: Number.isSafeInteger(resumeFrom) && resumeFrom >= 0 ? resumeFrom : undefined, + loadSnapshot: loadShellSnapshot({ + omitIdleSubagents: query.omitIdleSubagents === "1", + omitOlderSettled: query.omitOlderSettled === "1", + }), + }); + }); + const loadThreadSnapshot = Effect.fn("http.orchestration.loadThreadSnapshot")(function* ( threadId: Parameters[0], failureReason: @@ -166,10 +187,7 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* loadShellSnapshot({ - omitIdleSubagents: args.query.omitIdleSubagents === "1", - omitOlderSettled: args.query.omitOlderSettled === "1", - }).pipe( + return yield* answerShellSnapshot(args.query).pipe( traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 046c48e1858bc..05b08df73fb4a 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -279,6 +279,43 @@ const SUMMARY: Record = { conflicting: "merge conflict", }; +const WATCHING_CLOSING = + "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox."; +const WATCHING_CLOSING_PLURAL = + "Look into each item and act on it as your task requires. Each PR T3 Code still watches stays linked and watched, so do not link, watch, or list them again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request for each first so the thread returns to their inbox."; + +export interface PullRequestWatchWake { + readonly text: string; + readonly notification: OrchestrationV2Notification; +} + +/** One wake for news on several pull requests, with the closing instructions said once. */ +export function combinePullRequestWatchWakes( + wakes: readonly [PullRequestWatchWake, ...ReadonlyArray], +): PullRequestWatchWake { + if (wakes.length === 1) return wakes[0]; + const closing = `\n\n${WATCHING_CLOSING}`; + const watching = wakes.some((wake) => wake.text.endsWith(closing)); + const outcomes = wakes.map((wake) => wake.notification.outcome); + return { + text: [ + ...wakes.map((wake) => + wake.text.endsWith(closing) ? wake.text.slice(0, -closing.length) : wake.text, + ), + ...(watching ? [WATCHING_CLOSING_PLURAL] : []), + ].join("\n\n"), + notification: { + source: { kind: "monitor" }, + outcome: outcomes.includes("failed") + ? "failed" + : outcomes.every((outcome) => outcome === "completed") + ? "completed" + : "updated", + summary: wakes.map((wake) => wake.notification.summary).join("; "), + }, + }; +} + export function pullRequestWatchMessage(input: { readonly number: number; readonly url: string; @@ -297,7 +334,7 @@ export function pullRequestWatchMessage(input: { "", exhausted ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` - : "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", + : WATCHING_CLOSING, ].join("\n"); const failed = changes.some( (change) => change.kind === "checks-failed" || change.kind === "conflicting", diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 954f72e2b5e95..1557e411af93f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4094,6 +4094,135 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + ["an idle", false], + ["a busy", true], + ] as const)( + "wakes %s thread once for a pass that changed several of its pull requests", + ([, busy]) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make(`runtime-pull-request-watch-pass-${busy}`); + const projectId = ProjectId.make(`pr-watch-pass-project-${busy}`); + yield* seedProject({ + projectId, + title: "Watch pass", + workspaceRoot: "/workspace/watch-pass", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-pass-create-${busy}`), + threadId, + projectId, + title: "Watch pass", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const numbers = [12, 13, 14]; + for (const number of numbers) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-pass-start-${busy}-${number}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number, + watching: true, + link: { url: `https://github.com/pingdotgg/t3code/pull/${number}`, source: "agent" }, + }); + } + if (busy) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-pass-busy"), + threadId, + messageId: MessageId.make("pr-watch-pass-busy"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + } + const host = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: (input) => + Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: input.number, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: `head${input.number}`, + checks: [ + { name: "test", status: "failure", description: null, url: null, required: true }, + ], + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + const wakes = messages.filter((message) => message.notification !== undefined); + const stamps = wakes.map(({ notification }) => + notification?.source.kind === "monitor" + ? [notification.source.pullRequest, ...(notification.source.otherPullRequests ?? [])] + .map((stamp) => stamp?.number) + .toSorted() + : [], + ); + assert.deepEqual(stamps, [numbers]); + for (const number of numbers) assert.include(wakes[0]!.text, `#${number}`); + if (!busy) return; + + // A pull request unwatched while the wake waited leaves it; the others still start. + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-pass-stop-12"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 12, + watching: false, + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("pr-watch-pass-interrupt"), + threadId, + runId: active.id, + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const woken = after.messages.find((message) => message.id === wakes[0]!.id)!; + assert.notInclude(woken.text, "#12"); + for (const number of [13, 14]) assert.include(woken.text, `#${number}`); + assert.notEqual( + after.runs.find((run) => run.userMessageId === woken.id)?.status, + "cancelled", + ); + }), + ); + it.effect.each(["pending", "action-required"] as const)("recovers %s watches", (initialStatus) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 306935cdd0975..4ca516284a3b9 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2896,6 +2896,131 @@ describe("orchestration v2 provider switching", () => { ), ); + // regression: #16000 thread setting changes must not end a Claude session's background work + it.live.each([ + ["runtime mode", "runtime-mode"], + ["worktree", "worktree"], + ["provider instance", "instance"], + ] as const)("keeps background work alive when the %s changes on an idle thread", ([, change]) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`settings-background-${change}`); + const nextCwd = yield* checkpointWorkspace(`settings-background-${change}-next`); + const capturedTurns = yield* Ref.make>([]); + const backgroundWork = yield* Ref.make(false); + const closedSessions = yield* Ref.make>([]); + const secondSelection: ModelSelection = { + ...CLAUDE_MODEL_SELECTION, + instanceId: ProviderInstanceId.make("claude-second-account"), + }; + const threadId = ThreadId.make(`thread:settings-background-${change}`); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:settings-background-${change}:create`), + threadId, + projectId: ProjectId.make(`project:settings-background-${change}`), + title: "Settings change with background work", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:settings-background-${change}:send`), + threadId, + messageId: MessageId.make(`message:settings-background-${change}:send`), + text: "Start background work", + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + const afterFirst = yield* waitForIdle(threadId); + const session = afterFirst.providerSessions[0]; + assert.equal(session?.status, "ready"); + // The turn ended but a background subagent keeps running in the CLI process. + yield* Ref.set(backgroundWork, true); + + const commandId = CommandId.make(`command:settings-background-${change}:change`); + yield* orchestrator + .dispatch( + change === "runtime-mode" + ? { + type: "thread.runtime-mode.set", + commandId, + threadId, + runtimeMode: "auto-accept-edits", + } + : change === "worktree" + ? { type: "thread.metadata.update", commandId, threadId, worktreePath: nextCwd } + : { + type: "thread.model-selection.set", + commandId, + threadId, + modelSelection: secondSelection, + }, + ) + .pipe(Effect.ignore); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + // Refusing or deferring are both fine; closing the busy process is not. + assert.deepEqual(yield* Ref.get(closedSessions), []); + assert.deepEqual( + projection.providerSessions.map((candidate) => [candidate.id, candidate.status]), + [[session!.id, "ready"]], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: `settings-background-${change}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 1: "Background work started" }, + capturedTurns, + backgroundWork, + closedSessions, + }), + makeTestAdapter({ + instanceId: secondSelection.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: secondSelection, + responseByRunOrdinal: {}, + capturedTurns, + }), + ]), + ), + ), + ); + }), + ), + ); + it.live.each(["immediately", "behind a running turn"] as const)( "starts a fresh provider session %s and never hands earlier runs to a later provider", (variant) => diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson index e91a821916d24..8dbd1758215aa 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/codex_model_reroute/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295480414}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295480414}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295480656}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295480656}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295480666}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295480718}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295480,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295480718}} {"type":"emit_inbound","label":"model/rerouted","frame":{"method":"model/rerouted","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","fromModel":"gpt-6-luna","toModel":"gpt-6-astra","reason":"highRiskCyberActivity"}}} @@ -28,9 +30,11 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","tokenUsage":{"total":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295485211}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"idle"}},"emittedAtMs":1790295485220}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295480,"completedAt":1790295485,"durationMs":4550}},"emittedAtMs":1790295485220}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295485265}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295485286}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295485,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295485286}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ed-2bdf-7093-9e82-b961af1fe132","clientId":null,"content":[{"type":"text","text":"Respond with exactly: second fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","startedAtMs":1790295485409},"emittedAtMs":1790295485409}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson index baa2079c92c5e..5278ce59a6c74 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/codex_transcript.ndjson @@ -5,7 +5,9 @@ {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"cwd":"","model":"gpt-6-luna"}}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1791001627148}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a10004-0a27-7421-852d-0f7f99ace2d6","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a10004-0a27-7421-852d-0f7f99ace2d6","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"cpa","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1791001627,"updatedAt":1791001627,"recencyAt":1791001627,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/10/03/rollout-2026-10-03T00-27-07-01a10004-0a27-7421-852d-0f7f99ace2d6.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.157.0","originator":"T3 Code","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"cpa","serviceTier":"default","disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"readOnly","networkAccess":false},"activePermissionProfile":{"id":":read-only","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact shell command in the foreground and wait for it to finish: `for i in 1 2 3 4; do printf '\\033[32mstream line %s\\033[0m\\n' $i; sleep 1; done; printf 'progress 50%%\\rprogress 100%%\\n'`. Then respond exactly: command output fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact shell command in the foreground and wait for it to finish: `for i in 1 2 3 4; do printf '\\033[32mstream line %s\\033[0m\\n' $i; sleep 1; done; printf 'progress 50%%\\rprogress 100%%\\n'`. Then respond exactly: command output fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a10004-0a27-7421-852d-0f7f99ace2d6","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a10004-0a27-7421-852d-0f7f99ace2d6","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"cpa","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1791001627,"updatedAt":1791001627,"recencyAt":1791001627,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/10/03/rollout-2026-10-03T00-27-07-01a10004-0a27-7421-852d-0f7f99ace2d6.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.157.0","originator":"T3 Code","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1791001627410}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","name":"cua_repl","status":"starting","error":null,"failureReason":null},"emittedAtMs":1791001627411}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1791001627411}} @@ -13,7 +15,7 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","name":"paper","status":"starting","error":null,"failureReason":null},"emittedAtMs":1791001627411}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","name":"node_repl","status":"starting","error":null,"failureReason":null},"emittedAtMs":1791001627411}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"cpa","serviceTier":"default","effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1791001627437}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a10004-0b1b-78f0-a41c-b253bded078e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a10004-0b1b-78f0-a41c-b253bded078e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1791001627550}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1791001627553}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a10004-0a27-7421-852d-0f7f99ace2d6","turn":{"id":"01a10004-0b1b-78f0-a41c-b253bded078e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1791001627,"completedAt":null,"durationMs":null}},"emittedAtMs":1791001627553}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson index f1e0d1ab3e7fb..6ba29577937a6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson @@ -4,16 +4,20 @@ {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} {"type":"expect_outbound","label":"parent.thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} {"type":"emit_inbound","label":"parent.thread/start","frame":{"id":2,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000001","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739467,"updatedAt":1776739467,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-parent.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"parent.turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Keep this parent turn active while orchestration tools are tested.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000001","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"parent.turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Keep this parent turn active while orchestration tools are tested.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000001","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"parent.thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019f1000-0000-7000-8000-000000000001","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739467,"updatedAt":1776739467,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-parent.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} -{"type":"emit_inbound","label":"parent.turn/start","frame":{"id":3,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"parent.turn/start","frame":{"id":4,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"parent.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000001","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"parent.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000001","turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":1776739467,"completedAt":null,"durationMs":null}}}} -{"type":"expect_outbound","label":"child.thread/start","frame":{"id":4,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} -{"type":"emit_inbound","label":"child.thread/start","frame":{"id":4,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"child.original.turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Inspect the delegated API boundary and return the result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"expect_outbound","label":"child.thread/start","frame":{"id":5,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} +{"type":"emit_inbound","label":"child.thread/start","frame":{"id":5,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":6,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":6,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"child.original.turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Inspect the delegated API boundary and return the result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} -{"type":"emit_inbound","label":"child.original.turn/start","frame":{"id":5,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"child.original.turn/start","frame":{"id":7,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.original.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.original.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"inProgress","error":null,"startedAt":1776739468,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.original.item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_delegated_task_status_original","text":"","phase":"final_answer","memoryCitation":null},"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000004"}}} @@ -21,16 +25,20 @@ {"type":"emit_inbound","label":"child.original.item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_delegated_task_status_original","text":"Delegated API boundary inspected.","phase":"final_answer","memoryCitation":null},"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000004"}}} {"type":"emit_inbound","label":"child.original.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"child.original.turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"completed","error":null,"startedAt":1776739468,"completedAt":1776739469,"durationMs":1000}}}} -{"type":"expect_outbound","label":"child.running.turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Remain active until the parent cancels this delegated task.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} -{"type":"emit_inbound","label":"child.running.turn/start","frame":{"id":6,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":8,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":8,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"child.running.turn/start","frame":{"id":9,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Remain active until the parent cancels this delegated task.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"emit_inbound","label":"child.running.turn/start","frame":{"id":9,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.running.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.running.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":1776739470,"completedAt":null,"durationMs":null}}}} -{"type":"expect_outbound","label":"child.running.turn/interrupt","frame":{"id":7,"method":"turn/interrupt","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000005"}}} -{"type":"emit_inbound","label":"child.running.turn/interrupt","frame":{"id":7,"result":{}}} +{"type":"expect_outbound","label":"child.running.turn/interrupt","frame":{"id":10,"method":"turn/interrupt","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000005"}}} +{"type":"emit_inbound","label":"child.running.turn/interrupt","frame":{"id":10,"result":{}}} {"type":"emit_inbound","label":"child.running.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"child.running.turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"interrupted","error":null,"startedAt":1776739470,"completedAt":1776739471,"durationMs":1000}}}} -{"type":"expect_outbound","label":"child.queued.turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Complete the queued follow-up and return the final result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} -{"type":"emit_inbound","label":"child.queued.turn/start","frame":{"id":8,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":11,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":11,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"child.queued.turn/start","frame":{"id":12,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Complete the queued follow-up and return the final result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"emit_inbound","label":"child.queued.turn/start","frame":{"id":12,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.queued.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.queued.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":1776739472,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.queued.item/started","frame":{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_delegated_task_status_queued","text":"","phase":"final_answer","memoryCitation":null},"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000006"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson index e96c81c5444cb..516e93a236bca 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson @@ -6,15 +6,17 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295500250}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295500250}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295500,"updatedAt":1790295500,"recencyAt":1790295500,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-20-01a0d5ed-65ea-7d42-b8e9-d06f045cfa86.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: steering fixture initial response","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: steering fixture initial response","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295500,"updatedAt":1790295500,"recencyAt":1790295500,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-20-01a0d5ed-65ea-7d42-b8e9-d06f045cfa86.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295500547}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295500547}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295500567}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-670e-7032-9a87-707c736491a6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-670e-7032-9a87-707c736491a6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295500626}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","turn":{"id":"01a0d5ed-670e-7032-9a87-707c736491a6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295500,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295500626}} -{"type":"expect_outbound","label":"turn/steer","frame":{"id":4,"method":"turn/steer","params":{"expectedTurnId":"01a0d5ed-670e-7032-9a87-707c736491a6","input":[{"text":"Actually, respond with exactly: steering fixture observed","type":"text"}],"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86"}}} -{"type":"emit_inbound","label":"turn/steer","frame":{"id":4,"result":{"turnId":"01a0d5ed-670e-7032-9a87-707c736491a6"}}} +{"type":"expect_outbound","label":"turn/steer","frame":{"id":5,"method":"turn/steer","params":{"expectedTurnId":"01a0d5ed-670e-7032-9a87-707c736491a6","input":[{"text":"Actually, respond with exactly: steering fixture observed","type":"text"}],"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86"}}} +{"type":"emit_inbound","label":"turn/steer","frame":{"id":5,"result":{"turnId":"01a0d5ed-670e-7032-9a87-707c736491a6"}}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295500936}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295501151}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-65ea-7d42-b8e9-d06f045cfa86","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295501388}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson index 709bfd752344d..01fd4315f53ea 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295480414}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295480414}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295480,"updatedAt":1790295480,"recencyAt":1790295480,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-00-01a0d5ed-187d-70c2-b4b6-b6881a68de09.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295480656}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295480656}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295480666}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295480718}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295480,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295480718}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295481015}} @@ -27,9 +29,11 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-1957-7a22-8399-d0172af342fd","tokenUsage":{"total":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":6912,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295485211}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"idle"}},"emittedAtMs":1790295485220}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-1957-7a22-8399-d0172af342fd","items":[{"type":"agentMessage","id":"msg_06900ecb02a780a6016ab5bdbcd76887d0ba52ca8fee9da2e1","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295480,"completedAt":1790295485,"durationMs":4550}},"emittedAtMs":1790295485220}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295485265}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295485286}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turn":{"id":"01a0d5ed-2b29-7353-a6d5-7745731173b7","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295485,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295485286}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ed-2bdf-7093-9e82-b961af1fe132","clientId":null,"content":[{"type":"text","text":"Respond with exactly: second fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-187d-70c2-b4b6-b6881a68de09","turnId":"01a0d5ed-2b29-7353-a6d5-7745731173b7","startedAtMs":1790295485409},"emittedAtMs":1790295485409}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson index 66d81847bb334..938155bb3f7e6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295620180}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295620180}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295620,"updatedAt":1790295620,"recencyAt":1790295620,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-20-01a0d5ef-3a6a-75d0-9942-61b29ab82328.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use request_user_input to ask one multiple-choice clarifying question about whether this fixture should prefer strict schemas or UI flexibility. After receiving the answer, respond exactly: plan questions fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"auto","threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295620428}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295620,"updatedAt":1790295620,"recencyAt":1790295620,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-20-01a0d5ef-3a6a-75d0-9942-61b29ab82328.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295620428}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":"medium","summary":"detailed","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna","reasoning_effort":"medium","developer_instructions":"You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in and ."}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295620457}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ef-3b5a-7bb2-9cd2-da112b6b158f","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-3b5a-7bb2-9cd2-da112b6b158f","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295620520}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-3a6a-75d0-9942-61b29ab82328","turn":{"id":"01a0d5ef-3b5a-7bb2-9cd2-da112b6b158f","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295620,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295620520}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295620774}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson index 42893646004d6..0894cf067e26f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295608195}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295608195}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295608,"updatedAt":1790295608,"recencyAt":1790295608,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-08-01a0d5ef-0ba3-7552-b090-7cfbb328e278.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create a short implementation plan for adding deterministic replay fixtures. Do not ask questions. Present the final plan in a proposed plan block.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create a short implementation plan for adding deterministic replay fixtures. Do not ask questions. Present the final plan in a proposed plan block.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"auto","threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295608443}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295608,"updatedAt":1790295608,"recencyAt":1790295608,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-08-01a0d5ef-0ba3-7552-b090-7cfbb328e278.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295608443}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":"medium","summary":"detailed","collaborationMode":{"mode":"plan","settings":{"model":"gpt-6-luna","reasoning_effort":"medium","developer_instructions":"You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in and ."}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295608464}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ef-0c85-7f41-ad02-13753158be02","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-0c85-7f41-ad02-13753158be02","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295608547}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-0ba3-7552-b090-7cfbb328e278","turn":{"id":"01a0d5ef-0c85-7f41-ad02-13753158be02","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295608,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295608548}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295608936}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson index bcfd4dc5434b4..9acdc4e0a05ca 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295630509}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295630509}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295630,"updatedAt":1790295630,"recencyAt":1790295630,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-30-01a0d5ef-62bd-7c20-861b-0528ff7d86bd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: provider thread resume fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: provider thread resume fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295630711}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295630,"updatedAt":1790295630,"recencyAt":1790295630,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-30-01a0d5ef-62bd-7c20-861b-0528ff7d86bd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295630711}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295630725}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295630781}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turn":{"id":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295630,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295630781}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295631109}} @@ -41,11 +43,13 @@ {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295635910}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"idle"}},"emittedAtMs":1790295635918}} {"type":"emit_inbound","label":"thread/resume","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: provider thread resume fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295630,"updatedAt":1790295635,"recencyAt":1790295630,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-30-01a0d5ef-62bd-7c20-861b-0528ff7d86bd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","initialTurnsPage":null,"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-62bd-7c20-861b-0528ff7d86bd\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-62bd-7c20-861b-0528ff7d86bd\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Using the conversation history available in this resumed thread, first repeat the exact final answer you gave in the previous turn. Then on a new line write exactly: provider thread resume fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Using the conversation history available in this resumed thread, first repeat the exact final answer you gave in the previous turn. Then on a new line write exactly: provider thread resume fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turnId":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","tokenUsage":{"total":{"totalTokens":21432,"inputTokens":21421,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":11,"reasoningOutputTokens":0},"last":{"totalTokens":21432,"inputTokens":21421,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":11,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295635930}} {"type":"emit_inbound","label":"thread/goal/cleared","frame":{"method":"thread/goal/cleared","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"},"emittedAtMs":1790295635930}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295635949}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ef-77e2-7e22-a198-4864f687b8b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-77e2-7e22-a198-4864f687b8b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295635967}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turn":{"id":"01a0d5ef-77e2-7e22-a198-4864f687b8b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295635,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295635967}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295636362}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson index c16a87d6bb702..3f8dab41f6466 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295489278}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295489278}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295489,"updatedAt":1790295489,"recencyAt":1790295489,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-09-01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: first fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295489,"updatedAt":1790295489,"recencyAt":1790295489,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-09-01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295489585}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295489585}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295489600}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295489653}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turn":{"id":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295489,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295489653}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295490048}} @@ -28,8 +30,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turnId":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","tokenUsage":{"total":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":11008,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":21426,"inputTokens":21418,"cachedInputTokens":11008,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295494469}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","status":{"type":"idle"}},"emittedAtMs":1790295494492}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turn":{"id":"01a0d5ed-3c38-77a3-ab7d-7b1af28c714e","items":[{"type":"agentMessage","id":"msg_0e736caa8e522655016ab5bdc6199487d097782bddce2cbb65","text":"first fixture turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295489,"completedAt":1790295494,"durationMs":4885}},"emittedAtMs":1790295494492}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-4f65-7182-8985-cb9fba86acad","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: second fixture turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d5ed-4f65-7182-8985-cb9fba86acad","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295494545}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turn":{"id":"01a0d5ed-4f65-7182-8985-cb9fba86acad","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295494,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295494545}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ed-5011-74c1-aa40-36a1a7a1a79c","clientId":null,"content":[{"type":"text","text":"Respond with exactly: second fixture turn complete","text_elements":[]}]},"threadId":"01a0d5ed-3b1d-7b03-a1a3-075d3fa4bc67","turnId":"01a0d5ed-4f65-7182-8985-cb9fba86acad","startedAtMs":1790295494685},"emittedAtMs":1790295494685}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson index 957f8d9536d98..95e194eb1ac60 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson @@ -7,12 +7,14 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295403441}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295404347}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295404,"updatedAt":1790295404,"recencyAt":1790295404,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-16-44-01a0d5eb-f0c0-7481-a417-5e8871805cbc.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with the following text: fixture simple ok","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with the following text: fixture simple ok","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295404,"updatedAt":1790295404,"recencyAt":1790295404,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-16-44-01a0d5eb-f0c0-7481-a417-5e8871805cbc.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295404904}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295404904}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295404904}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295404923}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5eb-f176-71d0-8e1c-a459700443de","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5eb-f176-71d0-8e1c-a459700443de","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295404965}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","turn":{"id":"01a0d5eb-f176-71d0-8e1c-a459700443de","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295404,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295404965}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5eb-f0c0-7481-a417-5e8871805cbc","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295405685}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson index 195f4e58a5f02..81904eb6928e7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson @@ -7,11 +7,13 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790298236268}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790298236941}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d617-2920-75e0-8957-ee5a48afefc4","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d617-2920-75e0-8957-ee5a48afefc4","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790298237,"updatedAt":1790298237,"recencyAt":1790298237,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T18-03-57-01a0d617-2920-75e0-8957-ee5a48afefc4.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn 2 subagents, one to read package.json and one to read tsconfig.json","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn 2 subagents, one to read package.json and one to read tsconfig.json","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"auto","threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790298237401}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d617-2920-75e0-8957-ee5a48afefc4","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d617-2920-75e0-8957-ee5a48afefc4","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790298237,"updatedAt":1790298237,"recencyAt":1790298237,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T18-03-57-01a0d617-2920-75e0-8957-ee5a48afefc4.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790298237402}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"on-request","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790298237414}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d617-29e0-73d3-a4fd-856238566501","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d617-29e0-73d3-a4fd-856238566501","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790298237472}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","turn":{"id":"01a0d617-29e0-73d3-a4fd-856238566501","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790298237,"completedAt":null,"durationMs":null}},"emittedAtMs":1790298237472}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d617-2920-75e0-8957-ee5a48afefc4","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790298237772}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson index a603cfb168ddd..6e05d1d8639c2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790296707143}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296707143}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790296707,"updatedAt":1790296707,"recencyAt":1790296707,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-27-01a0d5ff-d059-77b3-a2cc-81be5c40d448.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and have it reply exactly: initial subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and have it reply exactly: initial subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-luna","reasoningEffort":null,"createdAt":1790296707,"updatedAt":1790296707,"recencyAt":1790296707,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-27-01a0d5ff-d059-77b3-a2cc-81be5c40d448.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296707328}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296707328}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-5.6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296707337}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ff-d107-72b0-b80e-eee523c86b42","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ff-d107-72b0-b80e-eee523c86b42","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790296707369}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turn":{"id":"01a0d5ff-d107-72b0-b80e-eee523c86b42","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790296707,"completedAt":null,"durationMs":null}},"emittedAtMs":1790296707369}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790296707761}} @@ -76,8 +78,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turnId":"01a0d5ff-d107-72b0-b80e-eee523c86b42","tokenUsage":{"total":{"totalTokens":88196,"inputTokens":87953,"cachedInputTokens":61440,"cacheWriteInputTokens":0,"outputTokens":243,"reasoningOutputTokens":74},"last":{"totalTokens":22955,"inputTokens":22918,"cachedInputTokens":22272,"cacheWriteInputTokens":0,"outputTokens":37,"reasoningOutputTokens":27},"modelContextWindow":258400}},"emittedAtMs":1790296721553}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","status":{"type":"idle"}},"emittedAtMs":1790296721558}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turn":{"id":"01a0d5ff-d107-72b0-b80e-eee523c86b42","items":[{"type":"agentMessage","id":"msg_0cd47e60427f33b8016ab5c29140b487d0abe357be8230ade9","text":"initial subagent response","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790296707,"completedAt":1790296721,"durationMs":14219}},"emittedAtMs":1790296721558}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Have the same subagent you spawned earlier reply exactly: continued subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d600-0899-7c10-9106-0eac1b8d18f9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Have the same subagent you spawned earlier reply exactly: continued subagent response","type":"text"}],"model":"gpt-5.6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-luna"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d600-0899-7c10-9106-0eac1b8d18f9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790296721577}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turn":{"id":"01a0d600-0899-7c10-9106-0eac1b8d18f9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790296721,"completedAt":null,"durationMs":null}},"emittedAtMs":1790296721577}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d600-0931-74b0-9bed-eacf1497bdf5","clientId":null,"content":[{"type":"text","text":"Have the same subagent you spawned earlier reply exactly: continued subagent response","text_elements":[]}]},"threadId":"01a0d5ff-d059-77b3-a2cc-81be5c40d448","turnId":"01a0d600-0899-7c10-9106-0eac1b8d18f9","startedAtMs":1790296721714},"emittedAtMs":1790296721715}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson index 424321668e824..c9e1b84dc34cc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson @@ -7,12 +7,14 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296671810}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790296672562}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296672,"updatedAt":1790296672,"recencyAt":1790296672,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-37-52-01a0d5ff-49e8-7741-9ca7-b12aa375019a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296672,"updatedAt":1790296672,"recencyAt":1790296672,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-37-52-01a0d5ff-49e8-7741-9ca7-b12aa375019a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296673000}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296673000}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296673000}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296673032}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ff-4b00-7641-bba1-06faa0ef23f1","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ff-4b00-7641-bba1-06faa0ef23f1","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790296673081}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","turn":{"id":"01a0d5ff-4b00-7641-bba1-06faa0ef23f1","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790296673,"completedAt":null,"durationMs":null}},"emittedAtMs":1790296673081}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-49e8-7741-9ca7-b12aa375019a","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790296673765}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson index d29289295de5a..bb0e341fed4f0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson @@ -7,11 +7,13 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790368128814}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790368129787}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0da41-a28d-7271-b6f6-59123b5bb248","environments":[{"environmentId":"local","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/tmp/codex-subagent-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da41-a28d-7271-b6f6-59123b5bb248","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790368129,"updatedAt":1790368129,"recencyAt":1790368129,"status":{"type":"idle"},"path":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T13-28-49-01a0da41-a28d-7271-b6f6-59123b5bb248.jsonl","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/tmp/codex-subagent-approval-rec/repo"],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"detailed","threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"auto","threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0da41-a28d-7271-b6f6-59123b5bb248","environments":[{"environmentId":"local","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/tmp/codex-subagent-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da41-a28d-7271-b6f6-59123b5bb248","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790368129,"updatedAt":1790368129,"recencyAt":1790368129,"status":{"type":"idle"},"path":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T13-28-49-01a0da41-a28d-7271-b6f6-59123b5bb248.jsonl","cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790368129812}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790368129813}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/tmp/codex-subagent-approval-rec/repo","approvalPolicy":"untrusted","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790368129830}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0da41-a31f-7741-bda3-ebc5ad36c38f","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0da41-a31f-7741-bda3-ebc5ad36c38f","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790368129893}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","turn":{"id":"01a0da41-a31f-7741-bda3-ebc5ad36c38f","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790368129,"completedAt":null,"durationMs":null}},"emittedAtMs":1790368129894}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0da41-a28d-7271-b6f6-59123b5bb248","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790368131004}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson index 3bc383b9ace0b..badb0b5b0aef8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790296685424}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296685424}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296685,"updatedAt":1790296685,"recencyAt":1790296685,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-05-01a0d5ff-7b98-7e50-a216-1581ca5ebd3a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and tell it to spawn its own subagent, which must in turn spawn one more subagent whose only task is to reply with exactly: Hello. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Spawn one subagent and tell it to spawn its own subagent, which must in turn spawn one more subagent whose only task is to reply with exactly: Hello. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790296685,"updatedAt":1790296685,"recencyAt":1790296685,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-38-05-01a0d5ff-7b98-7e50-a216-1581ca5ebd3a.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296685651}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296685651}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296685666}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ff-7c5b-7bf1-a92a-39f93af77a6c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ff-7c5b-7bf1-a92a-39f93af77a6c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790296685723}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ff-7b98-7e50-a216-1581ca5ebd3a","turn":{"id":"01a0d5ff-7c5b-7bf1-a92a-39f93af77a6c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790296685,"completedAt":null,"durationMs":null}},"emittedAtMs":1790296685723}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790296686001}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson index 30c2141459714..d4a8488cb6eae 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790374254500}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790374254500}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0da9f-1b19-7301-961c-a4f5e1b31832","environments":[{"environmentId":"local","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/.cache/codex-nested-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790374255,"updatedAt":1790374255,"recencyAt":1790374255,"status":{"type":"idle"},"path":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T15-10-55-01a0da9f-1b19-7301-961c-a4f5e1b31832.jsonl","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/.cache/codex-nested-approval-rec/repo"],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent and tell it not to run any commands itself but to spawn its own subagent, whose only task is to run this exact shell command: printf 'nested approval fixture' > nested-approval.txt and then reply with exactly: Written. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"detailed","threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"untrusted","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not run any commands yourself. Spawn one subagent and tell it not to run any commands itself but to spawn its own subagent, whose only task is to run this exact shell command: printf 'nested approval fixture' > nested-approval.txt and then reply with exactly: Written. Each agent waits for its child and replies with exactly what the child said. Wait for your subagent, then reply with exactly what it said.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"readOnly"},"summary":"auto","threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0da9f-1b19-7301-961c-a4f5e1b31832","environments":[{"environmentId":"local","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","runtimeWorkspaceRoots":["/home/replay-user/.cache/codex-nested-approval-rec/repo"]}],"extra":null,"sessionId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790374255,"updatedAt":1790374255,"recencyAt":1790374255,"status":{"type":"idle"},"path":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home/sessions/2026/09/25/rollout-2026-09-25T15-10-55-01a0da9f-1b19-7301-961c-a4f5e1b31832.jsonl","cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790374255463}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790374255463}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/.cache/codex-nested-approval-rec/repo","approvalPolicy":"untrusted","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790374255475}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0da9f-1b6f-7dd3-90da-e6c8797c1da6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0da9f-1b6f-7dd3-90da-e6c8797c1da6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790374255518}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0da9f-1b19-7301-961c-a4f5e1b31832","turn":{"id":"01a0da9f-1b6f-7dd3-90da-e6c8797c1da6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790374255,"completedAt":null,"durationMs":null}},"emittedAtMs":1790374255518}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790374255710}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson index 10f3f6954111a..3dd01acb73f7e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson @@ -5,13 +5,15 @@ {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1777424017,"updatedAt":1777424017,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-37-019dd6ba-2681-7bf0-b051-141b0cbcbb27.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/home/replay-user/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.git"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1777424017,"updatedAt":1777424017,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-37-019dd6ba-2681-7bf0-b051-141b0cbcbb27.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"warning","frame":{"method":"warning","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","message":"Under-development features enabled: apply_patch_freeform. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in /home/replay-user/.codex/config.toml."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"codex_apps","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"starting","error":null}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[],"status":"inProgress","error":null,"startedAt":1777424017,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"ready","error":null}}} @@ -31,8 +33,10 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":61,"windowDurationMins":300,"resetsAt":1777429830},"secondary":{"usedPercent":23,"windowDurationMins":10080,"resetsAt":1777959590},"credits":null,"planType":"unknown","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta"}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"inProgress","error":null,"startedAt":1777424023,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"e8550ffa-7e59-48b3-8fd2-7bbca932ab95","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta","text_elements":[]}]},"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turnId":"019dd6ba-416d-7902-95cf-d4179c3f4738"}}} @@ -48,16 +52,18 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":61,"windowDurationMins":300,"resetsAt":1777429830},"secondary":{"usedPercent":23,"windowDurationMins":10080,"resetsAt":1777959590},"credits":null,"planType":"unknown","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"completed","error":null,"startedAt":1777424023,"completedAt":1777424025,"durationMs":1596}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":5,"method":"thread/fork","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","excludeTurns":true,"lastTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":7,"method":"thread/fork","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","excludeTurns":true,"lastTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"warning","frame":{"method":"warning","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","message":"Under-development features enabled: apply_patch_freeform. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in /home/replay-user/.codex/config.toml."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"codex_apps","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"ready","error":null}}} -{"type":"emit_inbound","label":"thread/fork","frame":{"id":5,"result":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/home/replay-user/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.git"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"thread/fork","frame":{"id":7,"result":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/home/replay-user/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/home/replay-user/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.git"},"access":"read"},{"path":{"type":"path","path":"/home/replay-user/t3code/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":8,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":8,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":9,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","approvalsReviewer":"user","summary":"auto","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":9,"result":{"turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":1777424025,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"ready","error":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson index d5443c56aa127..44ffac5c8c54e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297773374}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297773374}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d610-155f-7391-843e-120bf1475357","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-155f-7391-843e-120bf1475357","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297773,"updatedAt":1790297773,"recencyAt":1790297773,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-13-01a0d610-155f-7391-843e-120bf1475357.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d610-155f-7391-843e-120bf1475357","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-155f-7391-843e-120bf1475357","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297773,"updatedAt":1790297773,"recencyAt":1790297773,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-13-01a0d610-155f-7391-843e-120bf1475357.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297773588}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297773588}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297773608}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297773650}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297773,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297773650}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790297773993}} @@ -27,17 +29,19 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","tokenUsage":{"total":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297778003}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"idle"}},"emittedAtMs":1790297778011}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}},"emittedAtMs":1790297778011}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"cwd":"","excludeTurns":true,"lastTurnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","model":"gpt-6-sol","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":5,"method":"thread/fork","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"cwd":"","excludeTurns":true,"lastTurnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","model":"gpt-6-sol","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`.","details":null},"emittedAtMs":1790297778016}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297778263}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297778313}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297778313}} -{"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297778,"updatedAt":1790297778,"recencyAt":1790297778,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-18-01a0d610-2789-7bb3-a492-ad7fa8a7db9c.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"userMessage","id":"01a0d610-1f48-7281-871b-c5d12ab0a228","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-first-6V2J. Respond with exactly: first merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"emit_inbound","label":"thread/fork","frame":{"id":5,"result":{"thread":{"id":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297778,"updatedAt":1790297778,"recencyAt":1790297778,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-18-01a0d610-2789-7bb3-a492-ad7fa8a7db9c.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"userMessage","id":"01a0d610-1f48-7281-871b-c5d12ab0a228","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":6,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":6,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-first-6V2J. Respond with exactly: first merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","turnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","tokenUsage":{"total":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297778338}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297778,"updatedAt":1790297778,"recencyAt":1790297778,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-18-01a0d610-2789-7bb3-a492-ad7fa8a7db9c.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297778338}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297778364}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":5,"result":{"turn":{"id":"01a0d610-28ad-7071-82a1-f55ea8178ae8","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0d610-28ad-7071-82a1-f55ea8178ae8","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297778383}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","turn":{"id":"01a0d610-28ad-7071-82a1-f55ea8178ae8","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297778,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297778383}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297779053}} @@ -52,16 +56,18 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","turnId":"01a0d610-28ad-7071-82a1-f55ea8178ae8","tokenUsage":{"total":{"totalTokens":39723,"inputTokens":39707,"cachedInputTokens":26624,"cacheWriteInputTokens":0,"outputTokens":16,"reasoningOutputTokens":0},"last":{"totalTokens":21971,"inputTokens":21963,"cachedInputTokens":11776,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297782125}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","status":{"type":"idle"}},"emittedAtMs":1790297782161}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","turn":{"id":"01a0d610-28ad-7071-82a1-f55ea8178ae8","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b5b44c87d08e7e5612c5312499","text":"first merge sibling stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297778,"completedAt":1790297782,"durationMs":3783}},"emittedAtMs":1790297782161}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":6,"method":"thread/fork","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"cwd":"","excludeTurns":true,"lastTurnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","model":"gpt-6-sol","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":8,"method":"thread/fork","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"cwd":"","excludeTurns":true,"lastTurnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","model":"gpt-6-sol","threadId":"01a0d610-155f-7391-843e-120bf1475357"}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`.","details":null},"emittedAtMs":1790297782170}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297782559}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297782559}} -{"type":"emit_inbound","label":"thread/fork","frame":{"id":6,"result":{"thread":{"id":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297782,"updatedAt":1790297782,"recencyAt":1790297782,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-22-01a0d610-37d1-7ba1-918f-136cfdbd9bfd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"userMessage","id":"01a0d610-1f48-7281-871b-c5d12ab0a228","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-second-9X5B. Respond with exactly: second merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"emit_inbound","label":"thread/fork","frame":{"id":8,"result":{"thread":{"id":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297782,"updatedAt":1790297782,"recencyAt":1790297782,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-22-01a0d610-37d1-7ba1-918f-136cfdbd9bfd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[{"id":"01a0d610-1619-7d91-bae2-ec4a7b899193","items":[{"type":"userMessage","id":"01a0d610-1f48-7281-871b-c5d12ab0a228","clientId":null,"content":[{"type":"text","text":"Remember the opaque marker merge-sibling-source-3C7K for later in this conversation. Respond with exactly: merge sibling source stored","text_elements":[]}]},{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b1b0fc87d0a540ea4be4112eb0","text":"merge sibling source stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"full","status":"completed","error":null,"startedAt":1790297773,"completedAt":1790297778,"durationMs":4400}]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":9,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":9,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":10,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Remember the fork-local marker merge-sibling-second-9X5B. Respond with exactly: second merge sibling stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turnId":"01a0d610-1619-7d91-bae2-ec4a7b899193","tokenUsage":{"total":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"last":{"totalTokens":17752,"inputTokens":17744,"cachedInputTokens":14848,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297782630}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","forkedFromId":"01a0d610-155f-7391-843e-120bf1475357","parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790297782,"updatedAt":1790297782,"recencyAt":1790297782,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-56-22-01a0d610-37d1-7ba1-918f-136cfdbd9bfd.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297782630}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297782690}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":10,"result":{"turn":{"id":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297782728}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turn":{"id":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297782,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297782728}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d610-2789-7bb3-a492-ad7fa8a7db9c","name":"xcodebuildmcp","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790297782926}} @@ -77,8 +83,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turnId":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","tokenUsage":{"total":{"totalTokens":39723,"inputTokens":39707,"cachedInputTokens":32384,"cacheWriteInputTokens":0,"outputTokens":16,"reasoningOutputTokens":0},"last":{"totalTokens":21971,"inputTokens":21963,"cachedInputTokens":17536,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297786356}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","status":{"type":"idle"}},"emittedAtMs":1790297786381}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-37d1-7ba1-918f-136cfdbd9bfd","turn":{"id":"01a0d610-398b-7ea0-b252-1ba58fc7d14d","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6b9e23887d0aead55548c08d807","text":"second merge sibling stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297782,"completedAt":1790297786,"durationMs":3683}},"emittedAtMs":1790297786381}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from first forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-first-6V2J.\n- Assistant confirmed: first merge sibling stored\n\nUser message:\nRetain the first transferred marker for later. Respond with exactly: first merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":8,"result":{"turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":11,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":11,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":12,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from first forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-first-6V2J.\n- Assistant confirmed: first merge sibling stored\n\nUser message:\nRetain the first transferred marker for later. Respond with exactly: first merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":12,"result":{"turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297786444}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297786,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297786444}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d610-48e6-7791-90d7-390fdc407d60","clientId":null,"content":[{"type":"text","text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from first forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-first-6V2J.\n- Assistant confirmed: first merge sibling stored\n\nUser message:\nRetain the first transferred marker for later. Respond with exactly: first merge delta stored","text_elements":[]}]},"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-481e-7522-ab04-234cac3907b5","startedAtMs":1790297786608},"emittedAtMs":1790297786608}} @@ -97,8 +105,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-481e-7522-ab04-234cac3907b5","tokenUsage":{"total":{"totalTokens":39782,"inputTokens":39751,"cachedInputTokens":32384,"cacheWriteInputTokens":0,"outputTokens":31,"reasoningOutputTokens":13},"last":{"totalTokens":22030,"inputTokens":22007,"cachedInputTokens":17536,"cacheWriteInputTokens":0,"outputTokens":23,"reasoningOutputTokens":13},"modelContextWindow":258400}},"emittedAtMs":1790297790394}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"idle"}},"emittedAtMs":1790297790409}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-481e-7522-ab04-234cac3907b5","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6be15bc87d0b5c0c3a9a6045bbb","text":"first merge delta stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297786,"completedAt":1790297790,"durationMs":3979}},"emittedAtMs":1790297790409}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":9,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from second forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-second-9X5B.\n- Assistant confirmed: second merge sibling stored\n\nUser message:\nRetain the second transferred marker for later. Respond with exactly: second merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":9,"result":{"turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":13,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":13,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":14,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from second forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-second-9X5B.\n- Assistant confirmed: second merge sibling stored\n\nUser message:\nRetain the second transferred marker for later. Respond with exactly: second merge delta stored","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":14,"result":{"turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297790438}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297790,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297790438}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d610-5897-7993-b78c-2e5977b826e8","clientId":null,"content":[{"type":"text","text":"Context handoff (merge_back / fork_delta_summary):\nMerge-back context from second forked conversation.\n\nFork delta:\n- User introduced opaque marker merge-sibling-second-9X5B.\n- Assistant confirmed: second merge sibling stored\n\nUser message:\nRetain the second transferred marker for later. Respond with exactly: second merge delta stored","text_elements":[]}]},"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-57d2-7671-858a-d9e868574664","startedAtMs":1790297790625},"emittedAtMs":1790297790625}} @@ -112,8 +122,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-57d2-7671-858a-d9e868574664","tokenUsage":{"total":{"totalTokens":61893,"inputTokens":61854,"cachedInputTokens":54272,"cacheWriteInputTokens":0,"outputTokens":39,"reasoningOutputTokens":13},"last":{"totalTokens":22111,"inputTokens":22103,"cachedInputTokens":21888,"cacheWriteInputTokens":0,"outputTokens":8,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790297793240}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"idle"}},"emittedAtMs":1790297793249}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-57d2-7671-858a-d9e868574664","items":[{"type":"agentMessage","id":"msg_0c4d0b070a58ed52016ab5c6c071ec87d0ad6a38add1f18e1a","text":"second merge delta stored","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790297790,"completedAt":1790297793,"durationMs":2814}},"emittedAtMs":1790297793249}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":10,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the source marker followed by both transferred fork markers in merge order, separated by single | characters. Respond with only the markers and separators.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":10,"result":{"turn":{"id":"01a0d610-62e7-7610-9b44-1d654367e4bf","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":15,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":15,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":16,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Return the source marker followed by both transferred fork markers in merge order, separated by single | characters. Respond with only the markers and separators.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d610-155f-7391-843e-120bf1475357","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":16,"result":{"turn":{"id":"01a0d610-62e7-7610-9b44-1d654367e4bf","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297793308}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d610-155f-7391-843e-120bf1475357","turn":{"id":"01a0d610-62e7-7610-9b44-1d654367e4bf","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297793,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297793308}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d610-63a5-7aa0-bdd9-6d0a56bf21e7","clientId":null,"content":[{"type":"text","text":"Return the source marker followed by both transferred fork markers in merge order, separated by single | characters. Respond with only the markers and separators.","text_elements":[]}]},"threadId":"01a0d610-155f-7391-843e-120bf1475357","turnId":"01a0d610-62e7-7610-9b44-1d654367e4bf","startedAtMs":1790297793453},"emittedAtMs":1790297793453}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson index 1526a453abdfd..85cf1ead68bb6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295644807}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295644807}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295644,"updatedAt":1790295644,"recencyAt":1790295644,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-44-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295644,"updatedAt":1790295644,"recencyAt":1790295644,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-44-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295645009}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295645009}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295645023}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295645070}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295645,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295645070}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295645465}} @@ -28,8 +30,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turnId":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","tokenUsage":{"total":{"totalTokens":21428,"inputTokens":21419,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"last":{"totalTokens":21428,"inputTokens":21419,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295649339}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"idle"}},"emittedAtMs":1790295649352}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-9b58-7980-9e7a-2463dd068a8e","items":[{"type":"agentMessage","id":"msg_044082e5a0068297016ab5be60f20c87d0aed9858a5d1f00b2","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295645,"completedAt":1790295649,"durationMs":4326}},"emittedAtMs":1790295649352}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295649424}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295649,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295649424}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ef-ad0e-78d0-af5c-7145f750bd81","clientId":null,"content":[{"type":"text","text":"Respond with exactly: rollback fixture second turn complete","text_elements":[]}]},"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turnId":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","startedAtMs":1790295649555},"emittedAtMs":1790295649555}} @@ -45,19 +49,21 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turnId":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","tokenUsage":{"total":{"totalTokens":47217,"inputTokens":47199,"cachedInputTokens":42496,"cacheWriteInputTokens":0,"outputTokens":18,"reasoningOutputTokens":0},"last":{"totalTokens":25789,"inputTokens":25780,"cachedInputTokens":21248,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790295651330}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"idle"}},"emittedAtMs":1790295651335}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[{"type":"agentMessage","id":"msg_044082e5a0068297016ab5be62fefc87d0bbf3fdbed6561ac8","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295649,"completedAt":1790295651,"durationMs":1931}},"emittedAtMs":1790295651335}} -{"type":"expect_outbound","label":"thread/read","frame":{"id":5,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} -{"type":"emit_inbound","label":"thread/read","frame":{"id":5,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295644,"updatedAt":1790295651,"recencyAt":1790295649,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-44-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}}}} -{"type":"expect_outbound","label":"thread/turns/list","frame":{"id":6,"method":"thread/turns/list","params":{"cursor":null,"itemsView":"summary","limit":1,"sortDirection":"desc","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} -{"type":"emit_inbound","label":"thread/turns/list","frame":{"id":6,"result":{"data":[{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[{"type":"userMessage","id":"01a0d5ef-ad0e-78d0-af5c-7145f750bd81","clientId":null,"content":[{"type":"text","text":"Respond with exactly: rollback fixture second turn complete","text_elements":[]}]},{"type":"agentMessage","id":"msg_044082e5a0068297016ab5be62fefc87d0bbf3fdbed6561ac8","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295649,"completedAt":1790295651,"durationMs":1931}],"nextCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":16,\"includeAnchor\":false,\"scope\":{\"kind\":\"turns\"}}","backwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":16,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}"}}} -{"type":"expect_outbound","label":"thread/revert","frame":{"id":7,"method":"thread/revert","params":{"beforeTurnId":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} +{"type":"expect_outbound","label":"thread/read","frame":{"id":7,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} +{"type":"emit_inbound","label":"thread/read","frame":{"id":7,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295644,"updatedAt":1790295651,"recencyAt":1790295649,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-44-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]}}}} +{"type":"expect_outbound","label":"thread/turns/list","frame":{"id":8,"method":"thread/turns/list","params":{"cursor":null,"itemsView":"summary","limit":1,"sortDirection":"desc","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} +{"type":"emit_inbound","label":"thread/turns/list","frame":{"id":8,"result":{"data":[{"id":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","items":[{"type":"userMessage","id":"01a0d5ef-ad0e-78d0-af5c-7145f750bd81","clientId":null,"content":[{"type":"text","text":"Respond with exactly: rollback fixture second turn complete","text_elements":[]}]},{"type":"agentMessage","id":"msg_044082e5a0068297016ab5be62fefc87d0bbf3fdbed6561ac8","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295649,"completedAt":1790295651,"durationMs":1931}],"nextCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":16,\"includeAnchor\":false,\"scope\":{\"kind\":\"turns\"}}","backwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":16,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}"}}} +{"type":"expect_outbound","label":"thread/revert","frame":{"id":9,"method":"thread/revert","params":{"beforeTurnId":"01a0d5ef-ac4e-7471-a4a8-6776e39e6d29","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"notLoaded"}},"emittedAtMs":1790295651358}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295651544}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295651544}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"idle"}},"emittedAtMs":1790295651551}} -{"type":"emit_inbound","label":"thread/revert","frame":{"id":7,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295651,"updatedAt":1790295651,"recencyAt":1790295649,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-51-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff_01a0d5ef-b424-72f2-9853-9b112f028a71.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"thread/revert","frame":{"id":9,"result":{"thread":{"id":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295651,"updatedAt":1790295651,"recencyAt":1790295649,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-20-51-01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff_01a0d5ef-b424-72f2-9853-9b112f028a71.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"1563a64b41d5e7e2e640b46f52cee724913f8851","branch":"v2/codex-gpt6-luna-rerecord","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":10,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":10,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":11,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/reverted","frame":{"method":"thread/reverted","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff"},"emittedAtMs":1790295651568}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":8,"result":{"turn":{"id":"01a0d5ef-b4f4-7aa2-b4c2-f5f494881401","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":11,"result":{"turn":{"id":"01a0d5ef-b4f4-7aa2-b4c2-f5f494881401","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295651594}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","turn":{"id":"01a0d5ef-b4f4-7aa2-b4c2-f5f494881401","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295651,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295651594}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ef-9a99-7bf2-a24c-197fc5cc32ff","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295652127}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson index d5dfddc6328f6..be3555213299a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790404926353}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790404926353}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790404926,"updatedAt":1790404926,"recencyAt":1790404926,"status":{"type":"idle"},"path":"/home/replay-user/tmp/bughunt-codex/codex-home/sessions/2026/09/25/rollout-2026-09-25T23-42-06-01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"],"instructionSources":["/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790404926,"updatedAt":1790404926,"recencyAt":1790404926,"status":{"type":"idle"},"path":"/home/replay-user/tmp/bughunt-codex/codex-home/sessions/2026/09/25/rollout-2026-09-25T23-42-06-01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790404926549}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790404926550}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790404926569}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790404926645}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790404926,"completedAt":null,"durationMs":null}},"emittedAtMs":1790404926645}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790404926921}} @@ -28,8 +30,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turnId":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","tokenUsage":{"total":{"totalTokens":19805,"inputTokens":19796,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"last":{"totalTokens":19805,"inputTokens":19796,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790404930976}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"idle"}},"emittedAtMs":1790404931000}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-1c60-70a3-9a3c-d54c0020efa2","items":[{"type":"agentMessage","id":"msg_0f3ce1c685f16088016ab7694298d087d0a53f8de6070d33ad","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790404926,"completedAt":1790404930,"durationMs":4426}},"emittedAtMs":1790404931000}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790404931059}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790404931,"completedAt":null,"durationMs":null}},"emittedAtMs":1790404931059}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0dc73-2e86-7131-a632-7a2b7e8186fb","clientId":null,"content":[{"type":"text","text":"Respond with exactly: rollback fixture second turn complete","text_elements":[]}]},"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turnId":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","startedAtMs":1790404931208},"emittedAtMs":1790404931208}} @@ -69,10 +73,12 @@ {"type":"expect_outbound","label":"thread/resume","frame":{"id":6,"method":"thread/resume","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"cwd":"","excludeTurns":true,"model":"gpt-6-luna","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} {"type":"emit_inbound","label":"thread/reverted","frame":{"method":"thread/reverted","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"},"emittedAtMs":1790404935694}} {"type":"emit_inbound","label":"thread/resume","frame":{"id":6,"result":{"thread":{"id":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"]}],"extra":null,"sessionId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790404935,"updatedAt":1790404935,"recencyAt":1790404931,"status":{"type":"idle"},"path":"/home/replay-user/tmp/bughunt-codex/codex-home/sessions/2026/09/25/rollout-2026-09-25T23-42-15-01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a_01a0dc73-3f89-7660-9a2a-81865e6273eb.jsonl","cwd":"/home/replay-user/t3code/apps/server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"70629cdddbe2c29749e8dce588b1828580b2777b","branch":"fix/codex-rollback-resume","originUrl":"https://github.com/pingdotgg/t3code.git"},"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":"default","disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","runtimeWorkspaceRoots":["/home/replay-user/t3code/apps/server"],"instructionSources":["/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"never","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","initialTurnsPage":null,"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a\",\"rolloutOrdinal\":1,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a\",\"rolloutOrdinal\":10,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":7,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":7,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/goal/cleared","frame":{"method":"thread/goal/cleared","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"},"emittedAtMs":1790404935729}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/apps/server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":"default","effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790404935753}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0dc73-4034-7201-88e4-13d0d27303a3","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":8,"result":{"turn":{"id":"01a0dc73-4034-7201-88e4-13d0d27303a3","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790404935798}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-4034-7201-88e4-13d0d27303a3","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790404935,"completedAt":null,"durationMs":null}},"emittedAtMs":1790404935798}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790404935820}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson index 6a0e9143bebb4..b382b070c180e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790459891821}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790459891821}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459892,"updatedAt":1790459892,"recencyAt":1790459892,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-12-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture first turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459892,"updatedAt":1790459892,"recencyAt":1790459892,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-12-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790459892845}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790459892846}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790459892862}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0dfb9-d479-70c0-9d88-912b52f7f613","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0dfb9-d479-70c0-9d88-912b52f7f613","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790459892915}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-d479-70c0-9d88-912b52f7f613","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790459892,"completedAt":null,"durationMs":null}},"emittedAtMs":1790459892915}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"failed","error":"MCP client for `codex_apps` failed to start: MCP startup failed: handshaking with MCP server failed: Send message error Transport [codex_rmcp_client::event_notification_transport::EventNotificationTransport>>] error: unexpected server response: HTTP 401: {\n \"error\": {\n \"message\": \"Encountered invalidated oauth token for user, failing request\",\n \"type\": null,\n \"code\": \"token_revoked\",\n \"param\": null\n },\n \"status\": 401\n}, when send initialize request: Send message error Transport [codex_rmcp_client::event_notification_transport::EventNotificationTransport>>] error: unexpected server response: HTTP 401: {\n \"error\": {\n \"message\": \"Encountered invalidated oauth token for user, failing request\",\n \"type\": null,\n \"code\": \"token_revoked\",\n \"param\": null\n },\n \"status\": 401\n}, when send initialize request","failureReason":null},"emittedAtMs":1790459893144}} @@ -26,8 +28,10 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-d479-70c0-9d88-912b52f7f613","tokenUsage":{"total":{"totalTokens":15291,"inputTokens":15282,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"last":{"totalTokens":15291,"inputTokens":15282,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790459897074}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459897082}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-d479-70c0-9d88-912b52f7f613","items":[{"type":"agentMessage","id":"msg_0508263b0487774d016ab83ff8973c87d0afab5ac113234803","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790459892,"completedAt":1790459897,"durationMs":4216}},"emittedAtMs":1790459897082}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":5,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":5,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790459897121}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790459897,"completedAt":null,"durationMs":null}},"emittedAtMs":1790459897121}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0dfb9-e8f3-7011-a214-7479457edb56","clientId":null,"content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","text_elements":[]}]},"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","startedAtMs":1790459898101},"emittedAtMs":1790459898101}} @@ -54,17 +58,19 @@ {"type":"emit_inbound","label":"item/agentMessage/delta","frame":{"method":"item/agentMessage/delta","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","itemId":"msg_0508263b0487774d016ab83ffcc71887d0874b12a3fb65dd41","delta":"."},"emittedAtMs":1790459902812}} {"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_0508263b0487774d016ab83ffcc71887d0874b12a3fb65dd41","text":"I’ll run the command exactly as given and wait for it to exit.","phase":"commentary","memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","completedAtMs":1790459903007},"emittedAtMs":1790459903009}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"commandExecution","id":"exec-6964412e-1d91-47b5-8a37-7a669b30c03e","pluginId":null,"scriptPath":null,"command":"/bin/bash -lc \"node -e \\\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\\"\"","cwd":"/home/replay-user/t3code","processId":"26675","source":"unifiedExecStartup","status":"inProgress","commandActions":[{"type":"unknown","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\""}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","startedAtMs":1790459903878},"emittedAtMs":1790459903884}} -{"type":"expect_outbound","label":"turn/interrupt","frame":{"id":5,"method":"turn/interrupt","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9"}}} +{"type":"expect_outbound","label":"turn/interrupt","frame":{"id":7,"method":"turn/interrupt","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9"}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","tokenUsage":{"total":{"totalTokens":30736,"inputTokens":30626,"cachedInputTokens":15104,"cacheWriteInputTokens":0,"outputTokens":110,"reasoningOutputTokens":15},"last":{"totalTokens":15445,"inputTokens":15344,"cachedInputTokens":15104,"cacheWriteInputTokens":0,"outputTokens":101,"reasoningOutputTokens":15},"modelContextWindow":258400}},"emittedAtMs":1790459903893}} -{"type":"emit_inbound","label":"turn/interrupt","frame":{"id":5,"result":{}}} -{"type":"expect_outbound","label":"thread/backgroundTerminals/terminate","frame":{"id":6,"method":"thread/backgroundTerminals/terminate","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","processId":"26675"}}} +{"type":"emit_inbound","label":"turn/interrupt","frame":{"id":7,"result":{}}} +{"type":"expect_outbound","label":"thread/backgroundTerminals/terminate","frame":{"id":8,"method":"thread/backgroundTerminals/terminate","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","processId":"26675"}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459903899}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-e509-7001-ba72-fe324f527fb9","items":[],"itemsView":"notLoaded","status":"interrupted","error":null,"startedAt":1790459897,"completedAt":1790459903,"durationMs":6789}},"emittedAtMs":1790459903899}} -{"type":"emit_inbound","label":"thread/backgroundTerminals/terminate","frame":{"id":6,"result":{"terminated":true}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"thread/backgroundTerminals/terminate","frame":{"id":8,"result":{"terminated":true}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":9,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":9,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":10,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Respond with exactly: rollback fixture second turn complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790459903907}} {"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"commandExecution","id":"exec-6964412e-1d91-47b5-8a37-7a669b30c03e","pluginId":null,"scriptPath":null,"command":"/bin/bash -lc \"node -e \\\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\\"\"","cwd":"/home/replay-user/t3code","processId":"26675","source":"unifiedExecStartup","status":"failed","commandActions":[{"type":"unknown","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\""}],"aggregatedOutput":null,"exitCode":-1,"durationMs":22},"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-e509-7001-ba72-fe324f527fb9","completedAtMs":1790459903901},"emittedAtMs":1790459903912}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":10,"result":{"turn":{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790459903925}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790459903,"completedAt":null,"durationMs":null}},"emittedAtMs":1790459903925}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"failed","error":"MCP client for `codex_apps` failed to start: MCP startup failed: handshaking with MCP server failed: Send message error Transport [codex_rmcp_client::event_notification_transport::EventNotificationTransport>>] error: unexpected server response: HTTP 401: {\n \"error\": {\n \"message\": \"Encountered invalidated oauth token for user, failing request\",\n \"type\": null,\n \"code\": \"token_revoked\",\n \"param\": null\n },\n \"status\": 401\n}, when send initialize request: Send message error Transport [codex_rmcp_client::event_notification_transport::EventNotificationTransport>>] error: unexpected server response: HTTP 401: {\n \"error\": {\n \"message\": \"Encountered invalidated oauth token for user, failing request\",\n \"type\": null,\n \"code\": \"token_revoked\",\n \"param\": null\n },\n \"status\": 401\n}, when send initialize request","failureReason":null},"emittedAtMs":1790459904186}} @@ -80,18 +86,20 @@ {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turnId":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","tokenUsage":{"total":{"totalTokens":46272,"inputTokens":46153,"cachedInputTokens":30208,"cacheWriteInputTokens":0,"outputTokens":119,"reasoningOutputTokens":15},"last":{"totalTokens":15536,"inputTokens":15527,"cachedInputTokens":15104,"cacheWriteInputTokens":0,"outputTokens":9,"reasoningOutputTokens":0},"modelContextWindow":258400}},"emittedAtMs":1790459906339}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459906344}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[{"type":"agentMessage","id":"msg_0508263b0487774d016ab840020e1087d08e559660413ebcd6","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790459903,"completedAt":1790459906,"durationMs":2423}},"emittedAtMs":1790459906344}} -{"type":"expect_outbound","label":"thread/read","frame":{"id":8,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} -{"type":"emit_inbound","label":"thread/read","frame":{"id":8,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459892,"updatedAt":1790459906,"recencyAt":1790459903,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-12-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"147b1509eac4291b323371299d0925a44035680c","branch":"master","originUrl":null},"name":null,"daybreakEnabled":null,"turns":[]}}}} -{"type":"expect_outbound","label":"thread/turns/list","frame":{"id":9,"method":"thread/turns/list","params":{"cursor":null,"itemsView":"summary","limit":1,"sortDirection":"desc","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} -{"type":"emit_inbound","label":"thread/turns/list","frame":{"id":9,"result":{"data":[{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[{"type":"userMessage","id":"01a0dfba-0390-7aa0-a945-e25ca01302e2","clientId":null,"content":[{"type":"text","text":"Respond with exactly: rollback fixture second turn complete","text_elements":[]}]},{"type":"agentMessage","id":"msg_0508263b0487774d016ab840020e1087d08e559660413ebcd6","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790459903,"completedAt":1790459906,"durationMs":2423}],"nextCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":32,\"includeAnchor\":false,\"scope\":{\"kind\":\"turns\"}}","backwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":32,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}"}}} -{"type":"expect_outbound","label":"thread/revert","frame":{"id":10,"method":"thread/revert","params":{"beforeTurnId":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"expect_outbound","label":"thread/read","frame":{"id":11,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"emit_inbound","label":"thread/read","frame":{"id":11,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459892,"updatedAt":1790459906,"recencyAt":1790459903,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-12-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"147b1509eac4291b323371299d0925a44035680c","branch":"master","originUrl":null},"name":null,"daybreakEnabled":null,"turns":[]}}}} +{"type":"expect_outbound","label":"thread/turns/list","frame":{"id":12,"method":"thread/turns/list","params":{"cursor":null,"itemsView":"summary","limit":1,"sortDirection":"desc","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} +{"type":"emit_inbound","label":"thread/turns/list","frame":{"id":12,"result":{"data":[{"id":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","items":[{"type":"userMessage","id":"01a0dfba-0390-7aa0-a945-e25ca01302e2","clientId":null,"content":[{"type":"text","text":"Respond with exactly: rollback fixture second turn complete","text_elements":[]}]},{"type":"agentMessage","id":"msg_0508263b0487774d016ab840020e1087d08e559660413ebcd6","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790459903,"completedAt":1790459906,"durationMs":2423}],"nextCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":32,\"includeAnchor\":false,\"scope\":{\"kind\":\"turns\"}}","backwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":32,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}"}}} +{"type":"expect_outbound","label":"thread/revert","frame":{"id":13,"method":"thread/revert","params":{"beforeTurnId":"01a0dfb9-ff9f-7902-a370-d05cc4ec5959","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"notLoaded"}},"emittedAtMs":1790459906373}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790459906471}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"idle"}},"emittedAtMs":1790459906478}} -{"type":"emit_inbound","label":"thread/revert","frame":{"id":10,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459906,"updatedAt":1790459906,"recencyAt":1790459903,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-26-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88_01a0dfba-094c-77d1-8e0b-8bf690a73635.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"147b1509eac4291b323371299d0925a44035680c","branch":"master","originUrl":null},"name":null,"daybreakEnabled":null,"turns":[]},"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":16,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":30,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":11,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"emit_inbound","label":"thread/revert","frame":{"id":13,"result":{"thread":{"id":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code","runtimeWorkspaceRoots":["/home/replay-user/t3code"]}],"extra":null,"sessionId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","forkedFromId":null,"parentThreadId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790459906,"updatedAt":1790459906,"recencyAt":1790459903,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/26/rollout-2026-09-26T14-58-26-01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88_01a0dfba-094c-77d1-8e0b-8bf690a73635.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":{"sha":"147b1509eac4291b323371299d0925a44035680c","branch":"master","originUrl":null},"name":null,"daybreakEnabled":null,"turns":[]},"turnsBackwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":16,\"includeAnchor\":true,\"scope\":{\"kind\":\"turns\"}}","itemsBackwardsCursor":"{\"requestedThreadId\":\"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88\",\"rolloutOrdinal\":30,\"includeAnchor\":true,\"scope\":{\"kind\":\"itemsByCreatedAtOrdinal\"}}"}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":14,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":14,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":15,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/reverted","frame":{"method":"thread/reverted","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88"},"emittedAtMs":1790459906490}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":11,"result":{"turn":{"id":"01a0dfba-09be-7112-8c42-4a6a8d1fe1d6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":15,"result":{"turn":{"id":"01a0dfba-09be-7112-8c42-4a6a8d1fe1d6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790459906518}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","turn":{"id":"01a0dfba-09be-7112-8c42-4a6a8d1fe1d6","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790459906,"completedAt":null,"durationMs":null}},"emittedAtMs":1790459906518}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0dfb9-d3d6-7c72-a2a4-2f0ba5eedd88","name":"codex_apps","status":"failed","error":"MCP client for `codex_apps` failed to start: MCP startup failed: handshaking with MCP server failed: Send message error Transport [codex_rmcp_client::event_notification_transport::EventNotificationTransport>>] error: unexpected server response: HTTP 401: {\n \"error\": {\n \"message\": \"Encountered invalidated oauth token for user, failing request\",\n \"type\": null,\n \"code\": \"token_revoked\",\n \"param\": null\n },\n \"status\": 401\n}, when send initialize request: Send message error Transport [codex_rmcp_client::event_notification_transport::EventNotificationTransport>>] error: unexpected server response: HTTP 401: {\n \"error\": {\n \"message\": \"Encountered invalidated oauth token for user, failing request\",\n \"type\": null,\n \"code\": \"token_revoked\",\n \"param\": null\n },\n \"status\": 401\n}, when send initialize request","failureReason":null},"emittedAtMs":1790459906779}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson index cdef4a5e34484..ce114ac234a55 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson @@ -7,12 +7,14 @@ {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790306244669}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790306245652}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790306245,"updatedAt":1790306245,"recencyAt":1790306245,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T20-17-25-01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Use the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"auto","threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790306245,"updatedAt":1790306245,"recencyAt":1790306245,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T20-17-25-01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790306245772}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790306245772}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790306245772}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790306245794}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d691-5c9c-7873-a191-5a599fd58099","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d691-5c9c-7873-a191-5a599fd58099","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790306245845}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","turn":{"id":"01a0d691-5c9c-7873-a191-5a599fd58099","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790306245,"completedAt":null,"durationMs":null}},"emittedAtMs":1790306245845}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d691-5ba5-77b1-bf0d-1bf7be8e9abe","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790306246472}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson index f97ec297714a3..c186020e12395 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790296989543}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790296989543}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d604-1f8d-7522-8fe1-371c6626896e","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-1f8d-7522-8fe1-371c6626896e","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790296989,"updatedAt":1790296989,"recencyAt":1790296989,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-09-01a0d604-1f8d-7522-8fe1-371c6626896e.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-sol","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"auto","threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol"}}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790296989839}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d604-1f8d-7522-8fe1-371c6626896e","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-1f8d-7522-8fe1-371c6626896e","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-sol","reasoningEffort":null,"createdAt":1790296989,"updatedAt":1790296989,"recencyAt":1790296989,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-09-01a0d604-1f8d-7522-8fe1-371c6626896e.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790296989839}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"on-request","approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-6-sol","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-sol","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790296989848}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d604-2095-7fb0-a7e6-3f63d1949bbe","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d604-2095-7fb0-a7e6-3f63d1949bbe","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790296989915}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d604-1f8d-7522-8fe1-371c6626896e","turn":{"id":"01a0d604-2095-7fb0-a7e6-3f63d1949bbe","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790296989,"completedAt":null,"durationMs":null}},"emittedAtMs":1790296989915}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790296990273}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson index b563d3cc3824f..0f282b80e8b00 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297006729}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297006729}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d604-629d-7162-b667-18c96f6fdf56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-629d-7162-b667-18c96f6fdf56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-terra","reasoningEffort":null,"createdAt":1790297006,"updatedAt":1790297006,"recencyAt":1790297006,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-26-01a0d604-629d-7162-b667-18c96f6fdf56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-5.6-terra","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":{"granular":{"mcp_elicitations":true,"request_permissions":true,"rules":true,"sandbox_approval":true,"skill_approval":true}},"approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-5.6-terra","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"detailed","threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-terra"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":{"granular":{"mcp_elicitations":true,"request_permissions":true,"rules":true,"sandbox_approval":true,"skill_approval":true}},"approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-5.6-terra","sandboxPolicy":{"networkAccess":false,"type":"readOnly"},"summary":"auto","threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-terra"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d604-629d-7162-b667-18c96f6fdf56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d604-629d-7162-b667-18c96f6fdf56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-5.6-terra","reasoningEffort":null,"createdAt":1790297006,"updatedAt":1790297006,"recencyAt":1790297006,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-43-26-01a0d604-629d-7162-b667-18c96f6fdf56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297006923}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297006923}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":{"granular":{"sandbox_approval":true,"rules":true,"skill_approval":true,"request_permissions":true,"mcp_elicitations":true}},"approvalsReviewer":"user","sandboxPolicy":{"type":"readOnly","networkAccess":false},"activePermissionProfile":null,"model":"gpt-5.6-terra","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.6-terra","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297006943}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d604-6358-72f1-bb89-7361cce0045c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d604-6358-72f1-bb89-7361cce0045c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297007009}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d604-629d-7162-b667-18c96f6fdf56","turn":{"id":"01a0d604-6358-72f1-bb89-7361cce0045c","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297006,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297007009}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790297007345}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson index 255049e96f1eb..bb0cb5051c761 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295509345}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295509345}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295509,"updatedAt":1790295509,"recencyAt":1790295509,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-29-01a0d5ed-896f-73b3-8c42-3ffa820eb343.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Create or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295509,"updatedAt":1790295509,"recencyAt":1790295509,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-29-01a0d5ed-896f-73b3-8c42-3ffa820eb343.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295509540}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295509540}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295509553}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-8a2a-7002-b65d-fe5885c9f4e0","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-8a2a-7002-b65d-fe5885c9f4e0","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295509613}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-896f-73b3-8c42-3ffa820eb343","turn":{"id":"01a0d5ed-8a2a-7002-b65d-fe5885c9f4e0","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295509,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295509613}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295510000}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson index 8db38faa3252e..b89e9a51a96d0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson @@ -6,20 +6,22 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295488496}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295488496}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ed-3804-72d0-9a3f-929885a11f32","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295488,"updatedAt":1790295488,"recencyAt":1790295488,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-08-01a0d5ed-3804-72d0-9a3f-929885a11f32.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ed-3804-72d0-9a3f-929885a11f32","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295488,"updatedAt":1790295488,"recencyAt":1790295488,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-18-08-01a0d5ed-3804-72d0-9a3f-929885a11f32.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295488683}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295488683}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295488699}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ed-38b3-7a11-9203-bd9650a02764","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ed-38b3-7a11-9203-bd9650a02764","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295488747}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","turn":{"id":"01a0d5ed-38b3-7a11-9203-bd9650a02764","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295488,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295488747}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295489056}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","name":"xcodebuildmcp","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295489323}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","name":"codex_apps","status":"ready","error":null,"failureReason":null},"emittedAtMs":1790295489469}} -{"type":"expect_outbound","label":"turn/interrupt","frame":{"id":4,"method":"turn/interrupt","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","turnId":"01a0d5ed-38b3-7a11-9203-bd9650a02764"}}} +{"type":"expect_outbound","label":"turn/interrupt","frame":{"id":5,"method":"turn/interrupt","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","turnId":"01a0d5ed-38b3-7a11-9203-bd9650a02764"}}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a0d5ed-3ea7-7aa3-a029-700c3b1aa2e8","clientId":null,"content":[{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","text_elements":[]}]},"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","turnId":"01a0d5ed-38b3-7a11-9203-bd9650a02764","startedAtMs":1790295490219},"emittedAtMs":1790295490220}} {"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a0d5ed-3ea7-7aa3-a029-700c3b1aa2e8","clientId":null,"content":[{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","text_elements":[]}]},"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","turnId":"01a0d5ed-38b3-7a11-9203-bd9650a02764","completedAtMs":1790295490219},"emittedAtMs":1790295490229}} -{"type":"emit_inbound","label":"turn/interrupt","frame":{"id":4,"result":{}}} +{"type":"emit_inbound","label":"turn/interrupt","frame":{"id":5,"result":{}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","status":{"type":"idle"}},"emittedAtMs":1790295490246}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ed-3804-72d0-9a3f-929885a11f32","turn":{"id":"01a0d5ed-38b3-7a11-9203-bd9650a02764","items":[],"itemsView":"notLoaded","status":"interrupted","error":null,"startedAt":1790295488,"completedAt":1790295490,"durationMs":1544}},"emittedAtMs":1790295490246}} {"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson index 5a70c0729d1a9..4b7f91e221b8c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790297700256}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790297700256}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790297700,"updatedAt":1790297700,"recencyAt":1790297700,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-00-01a0d60e-f7cc-7a50-bdff-90174e2c3b56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"detailed","threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"networkAccess":false,"type":"workspaceWrite","writableRoots":[]},"summary":"auto","threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790297700,"updatedAt":1790297700,"recencyAt":1790297700,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-55-00-01a0d60e-f7cc-7a50-bdff-90174e2c3b56.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790297700615}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790297700615}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790297700629}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790297700691}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turn":{"id":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790297700,"completedAt":null,"durationMs":null}},"emittedAtMs":1790297700691}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790297700959}} @@ -25,11 +27,11 @@ {"type":"emit_inbound","label":"item/reasoning/summaryTextDelta","frame":{"method":"item/reasoning/summaryTextDelta","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turnId":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","itemId":"rs_02867d76e88a025b016ab5c669424c87d09ca784eb308b0650","delta":"**Running exact command**","summaryIndex":0},"emittedAtMs":1790297707152}} {"type":"emit_inbound","label":"item/completed","frame":{"method":"item/completed","params":{"item":{"type":"reasoning","id":"rs_02867d76e88a025b016ab5c669424c87d09ca784eb308b0650","summary":["**Running exact command**"],"content":[]},"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turnId":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","completedAtMs":1790297707229},"emittedAtMs":1790297707234}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"commandExecution","id":"exec-0ce96e49-aa1c-4bae-b4f8-687a7473017c","pluginId":null,"scriptPath":null,"command":"/bin/bash -lc \"node -e \\\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\\"\"","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","processId":"87302","source":"unifiedExecStartup","status":"inProgress","commandActions":[{"type":"unknown","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\""}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turnId":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","startedAtMs":1790297707619},"emittedAtMs":1790297707621}} -{"type":"expect_outbound","label":"turn/interrupt","frame":{"id":4,"method":"turn/interrupt","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turnId":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd"}}} +{"type":"expect_outbound","label":"turn/interrupt","frame":{"id":5,"method":"turn/interrupt","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turnId":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd"}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turnId":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","tokenUsage":{"total":{"totalTokens":17953,"inputTokens":17855,"cachedInputTokens":11008,"cacheWriteInputTokens":0,"outputTokens":98,"reasoningOutputTokens":25},"last":{"totalTokens":17953,"inputTokens":17855,"cachedInputTokens":11008,"cacheWriteInputTokens":0,"outputTokens":98,"reasoningOutputTokens":25},"modelContextWindow":258400}},"emittedAtMs":1790297707635}} -{"type":"emit_inbound","label":"turn/interrupt","frame":{"id":4,"result":{}}} -{"type":"expect_outbound","label":"thread/backgroundTerminals/terminate","frame":{"id":5,"method":"thread/backgroundTerminals/terminate","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","processId":"87302"}}} +{"type":"emit_inbound","label":"turn/interrupt","frame":{"id":5,"result":{}}} +{"type":"expect_outbound","label":"thread/backgroundTerminals/terminate","frame":{"id":6,"method":"thread/backgroundTerminals/terminate","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","processId":"87302"}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","status":{"type":"idle"}},"emittedAtMs":1790297707650}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d60e-f7cc-7a50-bdff-90174e2c3b56","turn":{"id":"01a0d60e-f90f-7f23-b6ba-9ca7ee20b5cd","items":[],"itemsView":"notLoaded","status":"interrupted","error":null,"startedAt":1790297700,"completedAt":1790297707,"durationMs":7017}},"emittedAtMs":1790297707650}} -{"type":"emit_inbound","label":"thread/backgroundTerminals/terminate","frame":{"id":5,"result":{"terminated":true}}} +{"type":"emit_inbound","label":"thread/backgroundTerminals/terminate","frame":{"id":6,"result":{"terminated":true}}} {"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson index 527487162687f..5826be00f9c9e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson @@ -6,11 +6,13 @@ {"type":"emit_inbound","label":"configWarning","frame":{"method":"configWarning","params":{"summary":"Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime.","details":null},"emittedAtMs":1790295574462}} {"type":"emit_inbound","label":"remoteControl/status/changed","frame":{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"replay-host","installationId":"00000000-0000-4000-8000-000000000000","environmentId":null},"emittedAtMs":1790295574462}} {"type":"emit_inbound","label":"thread/start","frame":{"id":2,"result":{"thread":{"id":"01a0d5ee-87e4-7461-9ab7-5c316b503107","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295574,"updatedAt":1790295574,"recencyAt":1790295574,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-34-01a0d5ee-87e4-7461-9ab7-5c316b503107.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]},"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"],"instructionSources":["/home/replay-user/.codex/AGENTS.md","/home/replay-user/t3code/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":{"id":":workspace","extends":null},"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":3,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Search the web for FIFA World Cup ticket pricing, then answer exactly: web search fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"detailed","threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} +{"type":"expect_outbound","label":"config/read","frame":{"id":3,"method":"config/read","params":{"cwd":"","includeLayers":false}}} +{"type":"emit_inbound","label":"config/read","frame":{"id":3,"result":{"config":{},"origins":{}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":4,"method":"turn/start","params":{"approvalPolicy":"never","approvalsReviewer":"user","cwd":"","input":[{"text":"Search the web for FIFA World Cup ticket pricing, then answer exactly: web search fixture complete","type":"text"}],"model":"gpt-6-luna","sandboxPolicy":{"type":"dangerFullAccess"},"summary":"auto","threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna"}}}}} {"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"01a0d5ee-87e4-7461-9ab7-5c316b503107","environments":[{"environmentId":"local","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","runtimeWorkspaceRoots":["/home/replay-user/t3code/packages/effect-codex-app-server"]}],"extra":null,"sessionId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","forkedFromId":null,"parentThreadId":null,"preview":"","ephemeral":false,"section":null,"sectionEnteredAt":null,"projectId":null,"historyMode":"paginated","modelProvider":"openai","model":"gpt-6-luna","reasoningEffort":null,"createdAt":1790295574,"updatedAt":1790295574,"recencyAt":1790295574,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/2026/09/24/rollout-2026-09-24T17-19-34-01a0d5ee-87e4-7461-9ab7-5c316b503107.jsonl","cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","cliVersion":"0.156.1","originator":"t3code_desktop","source":"vscode","canAcceptDirectInput":true,"threadSource":null,"agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"daybreakEnabled":null,"turns":[]}},"emittedAtMs":1790295574652}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","name":"codex_apps","status":"starting","error":null,"failureReason":null},"emittedAtMs":1790295574652}} {"type":"emit_inbound","label":"thread/settings/updated","frame":{"method":"thread/settings/updated","params":{"threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","threadSettings":{"disabledPluginIds":[],"cwd":"/home/replay-user/t3code/packages/effect-codex-app-server","approvalPolicy":"never","approvalsReviewer":"user","sandboxPolicy":{"type":"dangerFullAccess"},"activePermissionProfile":null,"model":"gpt-6-luna","modelProvider":"openai","serviceTier":null,"effort":null,"summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-6-luna","reasoning_effort":null,"developer_instructions":null}},"multiAgentMode":"explicitRequestOnly","personality":null}},"emittedAtMs":1790295574669}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":3,"result":{"turn":{"id":"01a0d5ee-8888-7d30-8f2b-68fe28f521bc","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":4,"result":{"turn":{"id":"01a0d5ee-8888-7d30-8f2b-68fe28f521bc","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","status":{"type":"active","activeFlags":[]}},"emittedAtMs":1790295574742}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"01a0d5ee-87e4-7461-9ab7-5c316b503107","turn":{"id":"01a0d5ee-8888-7d30-8f2b-68fe28f521bc","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1790295574,"completedAt":null,"durationMs":null}},"emittedAtMs":1790295574742}} {"type":"emit_inbound","label":"account/updated","frame":{"method":"account/updated","params":{"authMode":"chatgpt","planType":"unknown"},"emittedAtMs":1790295575098}} diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 111cf0541358a..8d81cbfadda7b 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -396,6 +396,38 @@ describe("threadHistoryPaging", () => { expect([...older.items, ...first.items].map((row) => row.sourceItemId)).toEqual( items.map((row) => row.sourceItemId), ); + + // A caller that holds only the newest rows gets the same page when it says what it left + // out: a user turn, and run 3, whose late notification is then not a new turn. + const late = items.with(402, inRun(402, 3, notification("Late"))); + const cursor = encodeThreadHistoryCursor({ + snapshotSequence: 1, + sourceThreadId: THREAD, + sourceItemId: late.at(-1)!.sourceItemId, + position: late.length - 1, + }); + const prefix = { hasUserTurn: true, startedRuns: new Set(["run-3"]) }; + const whole = selectHistoryPageFromCursor({ items: late, cursor, snapshotSequence: 1, policy }); + expect(whole.items[0]?.sourceItemId).toBe(`item-${11 * 4 + 1}`); + expect( + selectHistoryPageFromCursor({ + items: late.slice(22), + cursor, + snapshotSequence: 1, + policy, + prefix, + }), + ).toEqual({ ...whole, oldestScanned: late[45] }); + // A part that ends above the page reports its first row, so the caller reads further back. + expect( + selectHistoryPageFromCursor({ + items: late.slice(60), + cursor, + snapshotSequence: 1, + policy, + prefix, + }).oldestScanned, + ).toBe(late[60]); }); it("pages agent-only child transcripts instead of dropping their earlier activity", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 37b653c451fce..f088d389fdd50 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -72,10 +72,22 @@ type HistoryRow = Pick< readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; }; +/** What a caller that passes only the newest part of a history knows about the rows it left out. */ +export type ThreadHistoryPrefix = { + readonly hasUserTurn: boolean; + /** Runs that have a user message or notification among the rows left out. */ + readonly startedRuns: ReadonlySet; +}; + export type SelectTimelinePageResult = { readonly items: ReadonlyArray; readonly nextCursor: string | null; readonly hasMoreHistory: boolean; + /** + * Set with `prefix`: the oldest row the selection read, which can be older than `items[0]`. + * The page is the same as on the full history only when a row lies before this one. + */ + readonly oldestScanned?: Row | undefined; }; export type BoundedProjectionResult = { @@ -200,6 +212,15 @@ function isDeferrableWorkItem(item: HistoryRow["item"]): boolean { ); } +/** A row that folds when its run is not blocked and its group is large enough. */ +export function isFoldableWorkItem(item: HistoryRow["item"]): boolean { + return ( + item.runId != null && + (item.runStatus === "completed" || item.attemptStatus === "superseded") && + isDeferrableWorkItem(item) + ); +} + export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { const blocked = new Set( items.flatMap(({ item }) => @@ -217,12 +238,7 @@ export function deferWorkHistoryRows(items: ReadonlyArra const item = row.item; if (item.runId == null) continue; if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); - if ( - (item.runStatus !== "completed" && item.attemptStatus !== "superseded") || - blocked.has(item.runId) || - !isDeferrableWorkItem(item) - ) - continue; + if (blocked.has(item.runId) || !isFoldableWorkItem(item)) continue; const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}:${item.attemptId ?? ""}`; const group = groups.get(key) ?? []; group.push(row); @@ -287,8 +303,11 @@ export function selectDeferredWorkPage(input: { } /** Notifications can start wake turns when they are the run's first input; match ProjectionStore run_starts. */ -function threadHistoryTurnStarts(items: ReadonlyArray): ReadonlyArray { - const startedRuns = new Set(); +function threadHistoryTurnStarts( + items: ReadonlyArray, + earlierRuns?: ReadonlySet, +): ReadonlyArray { + const startedRuns = new Set(earlierRuns); return items.map((item) => { if (item.type !== "user_message" && item.type !== "notification") return false; const firstInput = item.runId != null && !startedRuns.has(item.runId); @@ -312,6 +331,7 @@ function selectOlderTimelinePage(input: { readonly snapshotSequence: number; readonly policy?: ThreadHistoryPagePolicy | undefined; readonly rowEncodedBytes?: ((row: Row) => number) | undefined; + readonly prefix?: ThreadHistoryPrefix | undefined; }): SelectTimelinePageResult { const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY; const rowCost = input.rowEncodedBytes ?? projectedRowEncodedBytes; @@ -325,10 +345,15 @@ function selectOlderTimelinePage(input: { let userTurns = 0; let rawTurns = 0; const candidates = input.items.slice(0, end); - const turnStarts = threadHistoryTurnStarts(candidates.map((row) => row.item)); - const turnLimit = candidates.some((row) => isThreadHistoryUserTurn(row.item)) - ? policy.maxUserTurns - : undefined; + const turnStarts = threadHistoryTurnStarts( + candidates.map((row) => row.item), + input.prefix?.startedRuns, + ); + const turnLimit = + input.prefix?.hasUserTurn === true || + candidates.some((row) => isThreadHistoryUserTurn(row.item)) + ? policy.maxUserTurns + : undefined; const maxItems = turnLimit === undefined ? policy.maxItems : (policy.maxTurnItems ?? policy.maxItems); for (let index = end - 1; index >= 0; index -= 1) { @@ -351,6 +376,7 @@ function selectOlderTimelinePage(input: { // Inside a turn longer than the window, no selected row starts a user turn. // Later pages and the snapshot query see only such rows and use `maxItems`, // so this page uses it too. + const oldestScanned = selected.at(-1); if (userTurns === 0) selected.splice(policy.maxItems); selected.reverse(); @@ -376,6 +402,7 @@ function selectOlderTimelinePage(input: { items: renumberPositions(selected), nextCursor, hasMoreHistory, + ...(input.prefix === undefined ? {} : { oldestScanned }), }; } @@ -394,7 +421,7 @@ export function selectRecentTimelineWindow(input: { }); } -function findCursorEndIndex( +export function findCursorEndIndex( items: ReadonlyArray, cursor: ThreadHistoryCursorPayload, ): number { @@ -458,6 +485,8 @@ export function selectHistoryPageFromCursor(input: { readonly policy?: ThreadHistoryPagePolicy | undefined; readonly rowEncodedBytes?: ((row: Row) => number) | undefined; readonly throughEntryId?: string | undefined; + /** Set when `items` holds only the newest part of the history. */ + readonly prefix?: ThreadHistoryPrefix | undefined; }): SelectTimelinePageResult { const decoded = decodeThreadHistoryCursor(input.cursor); return selectOlderTimelinePage({ @@ -466,6 +495,7 @@ export function selectHistoryPageFromCursor(input: { snapshotSequence: input.snapshotSequence, ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }), policy: olderHistoryPagePolicy(input), + prefix: input.prefix, }); } diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5e4784e26fc95..37b74d5c6db01 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -85,6 +85,9 @@ import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts"; import Migration0080 from "./Migrations/080_ScheduledTaskThreadMode.ts"; import Migration0081 from "./Migrations/081_ProjectionMessageSearchText.ts"; import Migration0082 from "./Migrations/082_TurnItemConversationIndex.ts"; +import Migration0083 from "./Migrations/083_CommandReadIndexes.ts"; +import Migration0084 from "./Migrations/084_LastExecutedRunIndex.ts"; +import Migration0085 from "./Migrations/085_TurnItemCounts.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -170,6 +173,9 @@ const migrationEntries = [ [80, "ScheduledTaskThreadMode", Migration0080], [81, "ProjectionMessageSearchText", Migration0081], [82, "TurnItemConversationIndex", Migration0082], + [83, "CommandReadIndexes", Migration0083], + [84, "LastExecutedRunIndex", Migration0084], + [85, "TurnItemCounts", Migration0085], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/083_CommandReadIndexes.ts b/apps/server/src/persistence/Migrations/083_CommandReadIndexes.ts new file mode 100644 index 0000000000000..864b16305ffe4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/083_CommandReadIndexes.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Every command reads its thread's nodes without the settled leaves. This index lets that read + // seek the nodes it keeps instead of fetching each finished tool call to drop it. The + // omitSettledLeafNodes read in ProjectionStore must spell this expression the same way. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_nodes_settled_leaf_idx + ON orchestration_v2_projection_nodes( + thread_id, + (kind IN ('tool_call', 'assistant_message', 'reasoning') + AND status IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back') + AND run_id IS NOT NULL) + ) + `; + + // A dispatcher thread is the source of thousands of consumed transfers. With status in the + // index, a read of the pending ones no longer fetches each consumed row to check it. + yield* sql`DROP INDEX IF EXISTS orchestration_v2_projection_context_transfers_source_thread_idx`; + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_context_transfers_source_thread_status_idx + ON orchestration_v2_projection_context_transfers(source_thread_id, status) + `; +}); diff --git a/apps/server/src/persistence/Migrations/084_LastExecutedRunIndex.ts b/apps/server/src/persistence/Migrations/084_LastExecutedRunIndex.ts new file mode 100644 index 0000000000000..2c36d62634873 --- /dev/null +++ b/apps/server/src/persistence/Migrations/084_LastExecutedRunIndex.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // The shell's last executed run lookup sorts every run of the thread. This index carries + // that order and the same predicate the lookup filters on. A database that already has + // the index keeps it, so a hand-applied one does not fail the migration. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_last_executed_idx + ON orchestration_v2_projection_runs( + thread_id, (completed_at IS NULL) DESC, completed_at DESC, ordinal DESC, run_id DESC + ) + WHERE status <> 'queued' + AND NOT (status = 'cancelled' AND json_extract(payload_json, '$.startedAt') IS NULL) + `; +}); diff --git a/apps/server/src/persistence/Migrations/085_TurnItemCounts.test.ts b/apps/server/src/persistence/Migrations/085_TurnItemCounts.test.ts new file mode 100644 index 0000000000000..cb73f5a8fd8d0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/085_TurnItemCounts.test.ts @@ -0,0 +1,60 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); + +layer("085_TurnItemCounts", (it) => { + it.effect("backfills item counts, then follows upserts, moves, and deletes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 84 }); + const upsert = (id: string, threadId: string, runId: string | null) => sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json + ) + VALUES ( + ${id}, ${threadId}, ${runId}, 1, 'assistant_message', 'completed', + '2026-10-29T00:00:00.000Z', '{}' + ) + ON CONFLICT(turn_item_id) DO UPDATE SET + thread_id = excluded.thread_id, + run_id = excluded.run_id, + status = excluded.status + `; + yield* upsert("runless-1", "thread-1", null); + yield* upsert("runless-2", "thread-1", null); + yield* upsert("run-1", "thread-1", "run-1"); + + yield* runMigrations(); + + const counts = () => + sql<{ readonly thread_id: string; readonly run_key: string; readonly item_count: number }>` + SELECT thread_id, run_key, item_count + FROM orchestration_v2_projection_turn_item_counts + ORDER BY thread_id, run_key + `; + assert.deepEqual(yield* counts(), [ + { thread_id: "thread-1", run_key: "", item_count: 2 }, + { thread_id: "thread-1", run_key: "run-1", item_count: 1 }, + ]); + + yield* upsert("run-1", "thread-1", "run-1"); + yield* upsert("run-2", "thread-2", "run-2"); + yield* upsert("runless-2", "thread-1", "run-1"); + assert.deepEqual(yield* counts(), [ + { thread_id: "thread-1", run_key: "", item_count: 1 }, + { thread_id: "thread-1", run_key: "run-1", item_count: 2 }, + { thread_id: "thread-2", run_key: "run-2", item_count: 1 }, + ]); + + yield* sql`DELETE FROM orchestration_v2_projection_turn_items WHERE thread_id = 'thread-1'`; + assert.deepEqual(yield* counts(), [ + { thread_id: "thread-2", run_key: "run-2", item_count: 1 }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/085_TurnItemCounts.ts b/apps/server/src/persistence/Migrations/085_TurnItemCounts.ts new file mode 100644 index 0000000000000..a432046e6361a --- /dev/null +++ b/apps/server/src/persistence/Migrations/085_TurnItemCounts.ts @@ -0,0 +1,60 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** + * Shell reads count items per (thread, run) here instead of walking every turn item. Triggers + * keep it exact for every writer, including projection rebuilds and dev-database pruning. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // run_key is '' for items without a run; NULLs never conflict in a primary key. + yield* sql` + CREATE TABLE orchestration_v2_projection_turn_item_counts ( + thread_id TEXT NOT NULL, + run_key TEXT NOT NULL, + item_count INTEGER NOT NULL, + PRIMARY KEY (thread_id, run_key) + ) WITHOUT ROWID + `; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_item_counts (thread_id, run_key, item_count) + SELECT thread_id, COALESCE(run_id, ''), COUNT(*) + FROM orchestration_v2_projection_turn_items + GROUP BY thread_id, run_id + `; + // An upsert that hits an existing item fires only the update trigger. + yield* sql` + CREATE TRIGGER orchestration_v2_turn_item_counts_insert + AFTER INSERT ON orchestration_v2_projection_turn_items + BEGIN + INSERT INTO orchestration_v2_projection_turn_item_counts (thread_id, run_key, item_count) + VALUES (new.thread_id, COALESCE(new.run_id, ''), 1) + ON CONFLICT (thread_id, run_key) DO UPDATE SET item_count = item_count + 1; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_turn_item_counts_update + AFTER UPDATE OF thread_id, run_id ON orchestration_v2_projection_turn_items + WHEN old.thread_id IS NOT new.thread_id OR old.run_id IS NOT new.run_id + BEGIN + UPDATE orchestration_v2_projection_turn_item_counts SET item_count = item_count - 1 + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, ''); + DELETE FROM orchestration_v2_projection_turn_item_counts + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, '') AND item_count <= 0; + INSERT INTO orchestration_v2_projection_turn_item_counts (thread_id, run_key, item_count) + VALUES (new.thread_id, COALESCE(new.run_id, ''), 1) + ON CONFLICT (thread_id, run_key) DO UPDATE SET item_count = item_count + 1; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_turn_item_counts_delete + AFTER DELETE ON orchestration_v2_projection_turn_items + BEGIN + UPDATE orchestration_v2_projection_turn_item_counts SET item_count = item_count - 1 + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, ''); + DELETE FROM orchestration_v2_projection_turn_item_counts + WHERE thread_id = old.thread_id AND run_key = COALESCE(old.run_id, '') AND item_count <= 0; + END + `; +}); diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 411efde3a87f9..c042ea9afa7ac 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -14,6 +14,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlError from "effect/sql/SqlError"; import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; @@ -191,6 +192,29 @@ it.effect("drops planner statistics a database already has", () => { ); }); +it.effect("names a database file that is not SQLite", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-notadb-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + NodeFS.writeFileSync(dbPath, "not a db\n"); + + return Effect.gen(function* () { + const opened = yield* Effect.void.pipe( + Effect.provide( + SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer)), + ), + Effect.result, + ); + + assert.equal(opened._tag, "Failure"); + if (opened._tag === "Failure") { + assert.isTrue(SqlError.isSqlError(opened.failure)); + assert.include(opened.failure.message, dbPath); + } + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + const ownT3Home = Effect.fn(function* () { const { serverRuntimeStatePath } = yield* ServerConfig.ServerConfig; // An empty home, so no boot service installed on this machine is read. diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 20f865ac1ba09..e8471f3f59891 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -2,8 +2,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlError from "effect/sql/SqlError"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { hasPendingMigrations, runMigrations } from "./Migrations.ts"; @@ -22,6 +24,32 @@ export class ServerOwnsDatabaseError extends Schema.TaggedError { + const seen = new Set(); + let cause: unknown = error.cause; + while (Predicate.isObject(cause) && !seen.has(cause)) { + seen.add(cause); + if (typeof cause.errcode === "number" && DAMAGED_SQLITE_CODES.has(cause.errcode & 0xff)) { + return true; + } + cause = cause.cause; + } + return false; +}; + +// Keeps the SqlError type so callers' error channels do not change; the original stays as the cause. +const damagedDatabaseError = (dbPath: string, cause: SqlError.SqlError) => + new SqlError.SqlError({ + reason: new SqlError.UnknownError({ + cause, + operation: "open", + message: `The T3 Code database at ${dbPath} is damaged or is not a SQLite database, so T3 Code cannot start. To recover, quit T3 Code, then move that file and any -wal and -shm files next to it out of the folder, or replace them with a backup. T3 Code creates a new database on the next start.`, + }), + }); + // CLI commands open the server's database from another process; migrating behind its owner changes the schema it runs on. const runMigrationsAsOwner = Effect.fn("SqlitePersistence.runMigrationsAsOwner")(function* () { // With nothing pending, runMigrations only validates the recorded history and writes nothing. @@ -90,6 +118,17 @@ const makeLayerFromPath = (setup: Layer.Layer) => "service.name": "t3code-server", }, }), + ).pipe( + Layer.catchTag("SqlError", (error) => + Layer.effect( + SqlClient.SqlClient, + Effect.fail( + SqlError.isSqlError(error) && isDamagedDatabase(error) + ? damagedDatabaseError(dbPath, error) + : error, + ), + ), + ), ); }, Layer.unwrap); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 88348b742b470..3c2cb075cfab4 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -62,6 +62,9 @@ describe("V2 preview upgrade", () => { [80, "ScheduledTaskThreadMode"], [81, "ProjectionMessageSearchText"], [82, "TurnItemConversationIndex"], + [83, "CommandReadIndexes"], + [84, "LastExecutedRunIndex"], + [85, "TurnItemCounts"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -167,6 +170,9 @@ describe("V2 preview upgrade", () => { [80, "ScheduledTaskThreadMode"], [81, "ProjectionMessageSearchText"], [82, "TurnItemConversationIndex"], + [83, "CommandReadIndexes"], + [84, "LastExecutedRunIndex"], + [85, "TurnItemCounts"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 6ab35a9613674..a86dcc415c109 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -799,6 +799,17 @@ it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop" ).pipe(Effect.provide(layer)), ); +// Playwright emulates a light color scheme on every page it attaches to. +it.effect("a desktop tab left on the system appearance clears Playwright's light emulation", () => + Effect.scoped( + Effect.gen(function* () { + const { context } = yield* openDesktopViewer; + + expect(context.page.emulateMedia).toHaveBeenCalledWith({ colorScheme: null }); + }), + ).pipe(Effect.provide(layer)), +); + it.effect("a desktop page that loaded before the server attached reports its loaded title", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index f06c6b34d41b5..ecb3184b09979 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -885,6 +885,8 @@ const make = Effect.gen(function* () { ? await connectDesktop(snapshot) : null; if (desktop) opened.push(desktop.close); + // Attaching over CDP emulates a light scheme; a tab on System follows the desktop's. + if (desktop) await desktop.page.emulateMedia({ colorScheme: null }); // Otherwise a desktop that attaches late shows its own page, and the agent drives another. if (!desktop && desktopChannel.available) { await runPromise( diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 4604da894a86c..2953d0dcbf096 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -94,6 +94,23 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect("resolves a remote whose URL contains a space", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-spaced-url-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "ssh://git@git.example.test/team repos/app.git"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + expect(identity?.locator.remoteUrl).toBe("ssh://git@git.example.test/team repos/app.git"); + expect(identity?.locator.remoteName).toBe("origin"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -303,6 +320,26 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect("uses the fork as the identity when gh repo set-default points at it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-gh-default-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]); + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.canonicalKey).toBe("github.com/julius/t3code-fork"); + expect(identity?.locator.remoteName).toBe("origin"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + it.effect("uses the last remote path segment as the repository name for nested groups", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index cdf9dc5e1b309..a1cf97d4a8398 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -12,7 +12,7 @@ import * as Path from "effect/Path"; import * as ProcessRunner from "../processRunner.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; -import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts"; +import { explicitBareRepositoryEnv, unsetInheritedRepositoryEnv } from "../vcs/VcsProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; @@ -50,11 +50,10 @@ function isLocalRemoteUrl(url: string): boolean { function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; + for (const line of stdout.split(/\r?\n/)) { + // Git uses a tab before the URL and one space before the direction; both can occur in URLs. // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); + const match = /^(\S+)\t(.+) \((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(line); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if ( @@ -70,10 +69,21 @@ function parseRemoteFetchUrls(stdout: string): Map { return remotes; } +// `gh repo set-default` marks the chosen remote with `remote..gh-resolved base`. +function parseGhDefaultRemoteName(stdout: string): string | null { + const marked = stdout + .split("\n") + .map((line) => /^remote\.(.+)\.gh-resolved\s+base$/.exec(line.trim())?.[1]) + .filter((name): name is string => name !== undefined); + return marked.length === 1 ? marked[0]! : null; +} + function pickPrimaryRemote( remotes: ReadonlyMap, + ghDefaultRemoteName: string | null, ): { readonly remoteName: string; readonly remoteUrl: string } | null { - for (const preferredRemoteName of ["upstream", "origin"] as const) { + for (const preferredRemoteName of [ghDefaultRemoteName, "upstream", "origin"]) { + if (preferredRemoteName === null) continue; const remoteUrl = remotes.get(preferredRemoteName); if (remoteUrl) { return { remoteName: preferredRemoteName, remoteUrl }; @@ -137,12 +147,17 @@ class RepositoryIdentityCacheKey extends Data.Class<{ readonly isBare: boolean; }> {} +const gitEnv = (platform: NodeJS.Platform): NodeJS.ProcessEnv => ({ + ...unsetInheritedRepositoryEnv(process.env, platform), + ...explicitBareRepositoryEnv(process.env, platform), +}); + const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); + const env = gitEnv(yield* HostProcess.Platform); // `git -C` cannot enter a missing folder or a file, so every probe below would fail. const isFolder = yield* fileSystem.stat(cwd).pipe( @@ -231,7 +246,7 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( .run({ command: "git", args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"], - env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform), + env: gitEnv(yield* HostProcess.Platform), timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); @@ -239,8 +254,28 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( return null; } + const ghResolvedResult = yield* processRunner + .run({ + command: "git", + args: [ + "-C", + cacheKey.rootPath, + ...(cacheKey.isBare ? ["--git-dir=."] : []), + "config", + "--get-regexp", + "^remote\\..*\\.gh-resolved$", + ], + env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform), + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + const ghDefaultRemoteName = + ghResolvedResult._tag === "Some" && ghResolvedResult.value.code === 0 + ? parseGhDefaultRemoteName(ghResolvedResult.value.stdout) + : null; + const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); - const remote = pickPrimaryRemote(remotes); + const remote = pickPrimaryRemote(remotes, ghDefaultRemoteName); return remote ? buildRepositoryIdentity({ ...remote, diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index f1c58250fd4b0..f8e6636f80673 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -810,6 +810,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("Antigravity inst expect(yield* installation.resolve(executableName)).toMatchObject({ source: "override", }); + expect( + yield* installation + .resolve(`~/external/${executableName}`) + .pipe(Effect.provideService(HostProcess.HomeDirectory, baseDir)), + ).toMatchObject({ source: "override", executablePath: externalExecutable }); yield* fs.remove(externalHarness); expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({ operation: "resolve", diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 74b5cbf981b9a..6f78dc15e842c 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query. import * as EffectNodeStream from "@effect/platform-node/NodeStream"; import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import { resolveCommandPath } from "@t3tools/shared/shell"; @@ -329,7 +330,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma "AntigravityInstallation.resolve", )( function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) { - const override = binaryPath?.trim(); + const override = expandHomePath(binaryPath?.trim() ?? "", yield* HostProcess.HomeDirectory); if (override) { for (const candidate of yield* commandCandidates(override, processEnvironment)) { const selected = yield* fromExternal(candidate, "override"); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 0068e899e011e..3fde32add2198 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -760,7 +760,12 @@ const makePendingCodexProvider = Effect.fn("CodexProvider.makePendingCodexProvid }); }); -function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { +const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + +function accountProbeStatus( + { account, rateLimits }: CodexAppServerProviderSnapshot, + codexHome: string | undefined, +): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; @@ -788,7 +793,9 @@ function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnaps return { status: "error", auth: { status: "unauthenticated" }, - message: "Codex CLI is not authenticated. Run `codex login` and try again.", + message: codexHome + ? `Codex CLI is not authenticated. Run \`codex login\` with CODEX_HOME set to ${quotePath(codexHome)} and try again.` + : "Codex CLI is not authenticated. Run `codex login` and try again.", }; } @@ -903,7 +910,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot); + : accountProbeStatus( + snapshot, + codexSettings.homePath + ? expandHomePath(codexSettings.homePath, yield* HostProcess.HomeDirectory) + : undefined, + ); // Sign-in guidance still applies when Codex itself says it needs an OpenAI login. const usageLimits = !snapshot.nativeUsage && !accountStatus.message diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 311695db2a31c..b5501943ca04f 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -466,6 +466,25 @@ it.layer( }), ); + it.effect("names the instance's CODEX_HOME in the sign-in hint for a custom home", () => + Effect.gen(function* () { + const homePath = "/srv/codex-work-home"; + const status = yield* checkCodexProviderStatus( + decodeCodexSettings({ binaryPath: "codex", homePath }), + () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { account: null, requiresOpenaiAuth: true }, + }), + ), + ); + + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.include(status.message ?? "", "CODEX_HOME"); + assert.include(status.message ?? "", homePath); + }), + ); + it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 486c4314927ad..e166af3f78340 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48131 + "mcpToolListJsonBytes": 48126 } diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index f54c77007c313..88bcd219af1ff 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2671,6 +2671,40 @@ it.effect("does not revive old summaries when project epochs are evicted", () => }), ); +it.effect("a finished turn leaves another project's cached listing in place", () => + Effect.gen(function* () { + const reads: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "api", workspaceRoot: "/b", repository: "acme/api" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: ({ repository }) => { + reads.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + const webListing = { state: "open" as const, projectId: "p1" as ProjectId }; + const everyListing = { state: "open" as const }; + + yield* service.list(webListing); + yield* service.list(everyListing); + reads.length = 0; + yield* service.refreshAfterTurn("p2" as ProjectId); + yield* service.list(webListing); + yield* service.list(everyListing); + assert.deepStrictEqual(reads, []); + + yield* service.refreshAfterTurn("p2" as ProjectId, { pushed: true }); + yield* service.list(everyListing); + assert.deepStrictEqual(reads.toSorted(), ["acme/api", "acme/web"]); + }), +); + it.effect("a refresh or a finished turn drops the provider's own change request reads", () => Effect.gen(function* () { const forgotten: Array<{ readonly repository: string; readonly number?: number } | undefined> = @@ -4321,6 +4355,26 @@ it.effect("leaves a mark alone when the host could not say what the head has of }), ); +it.effect("reports a file pushed to after the diff the reader ticked was drawn as changed", () => + Effect.gen(function* () { + const revisions = new Map([["src/a.ts", "shown-a"]]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.filesViewed(GITLAB_REFERENCE); + yield* TestClock.adjust("61 seconds"); + revisions.set("src/a.ts", "pushed-a"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true, revision: "shown-a" }], + }); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + it.effect("forgets what the head had of a marked file once a mutation moves the head", () => Effect.gen(function* () { const revisions = new Map([["src/a.ts", "blob-a"]]); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9009e2d09e91f..91ee91b9ab083 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -218,7 +218,10 @@ export class PullRequestService extends Context.Service< input: PullRequestReportStateInput, ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; - readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; + readonly refreshAfterTurn: ( + projectId: ProjectId, + options?: { readonly pushed?: boolean }, + ) => Effect.Effect; /** Project and remote lookups inside `effect` read once per filter, e.g. across one sync sweep. */ readonly withSharedProjectReads: ( effect: Effect.Effect, @@ -3639,12 +3642,14 @@ export const make = Effect.gen(function* () { const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.fn( "PullRequestService.refreshAfterTurn", - )(function* (projectId) { + )(function* (projectId, options) { // Everything the provider holds, not just the project's repository: a turn can touch a // change request reached through another repository, and no epoch may move until those // reads are gone, or a reader in between keeps the old one under the new epoch. yield* forgetProviderReads(); - listingsEpoch = ++epochCounter; + const epoch = ++epochCounter; + // A push can open or move a PR; a plain turn waits for the listing TTL to spare the API quota. + if (options?.pushed === true) listingsEpoch = epoch; projectEpochs.delete(projectId); if (projectEpochs.size >= REF_EPOCH_CAPACITY) { const oldest = projectEpochs.keys().next().value; @@ -3653,8 +3658,7 @@ export const make = Effect.gen(function* () { projectEpochs.delete(oldest); } } - projectEpochs.set(projectId, listingsEpoch); - const epoch = listingsEpoch; + projectEpochs.set(projectId, epoch); yield* readCache.invalidate(`project:${projectId}`); yield* SubscriptionRef.set(pullRequestRefreshes, epoch); }); diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 596808eb46a07..ee956dfa0fe9c 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -260,7 +260,9 @@ export const make = (dependencies: Dependencies) => { input: PullRequestSetFilesViewedInput, ): Effect.fn.Return { const viewer = yield* requiredViewerOf(project, "setFilesViewed"); - const cleared = input.files.filter((file) => file.viewed).map((file) => file.path); + const cleared = input.files + .filter((file) => file.viewed && file.revision === undefined) + .map((file) => file.path); const revisions = cleared.length === 0 ? null @@ -276,10 +278,10 @@ export const make = (dependencies: Dependencies) => { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), - // Missing paths have null baselines; empty revision means confirmed deletion. + // Shown revisions win over the head; missing paths have null baselines; empty means deleted. files: input.files.map((file) => ({ path: file.path, - revision: revisions?.get(file.path) ?? null, + revision: file.revision ?? revisions?.get(file.path) ?? null, viewed: file.viewed, })), viewedAt, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 293d6670bbf0b..f92b3cbaaf636 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; import { guardHttpResponseWriteErrors, + keepIdleConnections, rejectRequestsUntilServing, } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; @@ -217,6 +218,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe, + readTailscaleServeRootHolders, readTailscaleServeRoutesTo, readTailscaleStatus, } from "@t3tools/tailscale"; @@ -353,7 +355,7 @@ const layerHttpServer = Layer.unwrap( return NodeHttpServer.layer( () => { const server = rejectRequestsUntilServing( - guardHttpResponseWriteErrors(NodeHttp.createServer()), + keepIdleConnections(guardHttpResponseWriteErrors(NodeHttp.createServer())), ); if (config.privateNetworkOnly) { server.on("connection", (socket) => { @@ -904,6 +906,22 @@ const layerMakeServer = Layer.unwrap( ); }), ); + // Who holds the Serve port's root: nobody, this server, only dead loopback ports (an unclean + // exit leaves one), or another live server or app, whose route is left alone. + const tailscaleServeRootHolder = Effect.fn("server.tailscaleServeRootHolder")( + function* (target: { readonly host: string; readonly port: number }) { + const net = yield* NetService.NetService; + const holders = yield* readTailscaleServeRootHolders(config.tailscaleServePort, target); + if (holders.length === 0) return "none" as const; + if (holders.some((holder) => holder.ours)) return "ours" as const; + const live = yield* Effect.forEach(holders, (holder) => + holder.ours || holder.loopback === undefined + ? Effect.succeed(true) + : net.hasListenerOnHost(holder.loopback.port, holder.loopback.host), + ); + return live.includes(true) ? ("other" as const) : ("dead" as const); + }, + ); const layerTailscaleServe = config.tailscaleServeEnabled ? Layer.effectDiscard( Effect.acquireRelease( @@ -918,16 +936,31 @@ const layerMakeServer = Layer.unwrap( const localPort = address.port; const localHost = loopbackUrlHostForAddress(address); + const route = { + localPort, + urlHost: localHost, + localHost: localHost.replace(/^\[|\]$/gu, ""), + servePort: config.tailscaleServePort, + }; + const holder = yield* tailscaleServeRootHolder({ + host: route.localHost, + port: localPort, + }).pipe(Effect.orElseSucceed(() => "unreadable" as const)); + if (holder === "other" || holder === "unreadable") { + yield* Effect.logWarning( + holder === "other" + ? "Tailscale Serve left off; its port already serves another server or app" + : "Tailscale Serve left off; its current routes could not be read", + { localPort, servePort: config.tailscaleServePort }, + ); + return route; + } return yield* ensureTailscaleServe({ localPort, servePort: config.tailscaleServePort, localHost, }).pipe( - Effect.as({ - localPort, - localHost: localHost.replace(/^\[|\]$/gu, ""), - servePort: config.tailscaleServePort, - }), + Effect.as(route), Effect.tap(() => Effect.logInfo("Tailscale Serve configured", { localPort, @@ -992,8 +1025,42 @@ const layerMakeServer = Layer.unwrap( ), ) : Effect.void, + ).pipe( + // Startup is not the only time the route can go stale, so whoever finds it on a dead + // port takes it back. + Effect.tap((configured) => + configured === null + ? Effect.void + : Effect.sleep("30 seconds").pipe( + Effect.andThen( + tailscaleServeRootHolder({ + host: configured.localHost, + port: configured.localPort, + }), + ), + Effect.flatMap((holder) => + holder === "dead" + ? ensureTailscaleServe({ + localPort: configured.localPort, + servePort: configured.servePort, + localHost: configured.urlHost, + }).pipe( + Effect.andThen( + Effect.logInfo("Tailscale Serve repaired", { + localPort: configured.localPort, + servePort: configured.servePort, + }), + ), + ) + : Effect.void, + ), + Effect.ignore, + Effect.forever, + Effect.forkScoped, + ), + ), ), - ) + ).pipe(Layer.provide(NetService.layer)) : Layer.empty; const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b933895c4de1a..e7220718f15a4 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -205,6 +205,13 @@ describe("V2 storage cleanup eligibility", () => { resetIn: -1_000, idle: false, }, + { + name: "never armed because its reset is days overdue", + armed: false, + autoResumeSetting: true, + resetIn: -5 * DAY_MS, + idle: true, + }, { name: "not armed with recovery settings off", armed: false, diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d044c2f7bdc40..673c121612c9a 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -42,6 +42,7 @@ import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; +import { MAX_OVERDUE_ARM_MS } from "./orchestration-v2/UsageLimitRecoveryWorker.ts"; import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -182,10 +183,12 @@ function heldForLimitRecovery( // An auto-resume holds until its continuation replaces the failed run. return recovery.autoResume || (recovery.snooze === true && resetMs > now); // The recovery worker arms this failure on a later tick; hold it until then. - // Like the worker, skip a reset that had already passed when the run ended. + // Like the worker, skip a reset that had already passed when the run ended + // or that is too overdue to arm. if ( !Number.isFinite(resetMs) || - resetMs <= DateTime.toEpochMillis(thread.latestRunCompletedAt ?? thread.updatedAt) + resetMs <= DateTime.toEpochMillis(thread.latestRunCompletedAt ?? thread.updatedAt) || + now - resetMs > MAX_OVERDUE_ARM_MS ) return false; return settings.autoResumeLimitedThreads || (settings.snoozeLimitedThreads && resetMs > now); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b67d3f0ac5830..389a6458677e7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,5 +1,4 @@ import { - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProjectId, ProviderDriverKind, type ModelSelection, @@ -559,6 +558,11 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima model: "chat-model", options: [{ id: "effort", value: "high" }], }; + const threadDefault = { + instanceId: fallback, + model: "z-ai/glm-5.3-flash", + options: [{ id: "thinking", value: false }], + }; const calls: ModelSelection[] = []; const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); @@ -627,8 +631,12 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima }), ServerSettings.layerTest({ providerInstances: { - [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + [fallback]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { customModels: [threadDefault.model] }, + }, }, + defaultModelSelection: threadDefault, ...(primaryState === "not installed, fallback disabled for project" ? { projectSettingsOverrides: { @@ -660,11 +668,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima primaryState === "not installed, fallback disabled for project" || primaryState === "signed out, fallback failed" ? selection - : { - instanceId: fallback, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], - }, + : threadDefault, ]); }), ); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 3042c97191cbd..b89e264db8d57 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -9,10 +9,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; -import { - defaultTextGenerationModel, - fallbackTextGenerationModelSelection, -} from "@t3tools/shared/serverSettings"; +import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -125,21 +122,25 @@ export const make = Effect.gen(function* () { ), ); selection = - fallbackTextGenerationModelSelection(settings.providerInstances, (instanceId, driver) => { - const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); - return ( - instanceId !== selection.instanceId && - provider?.enabled === true && - resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) && - provider.installed && - provider.auth.status !== "unauthenticated" && - provider.status !== "error" && - isProviderAvailable(provider) && - isProviderTextGenerationCapable(provider) && - (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && - !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) - ); - }) ?? selection; + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver, model) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + provider.status !== "error" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + !exhaustedUsageWindow(provider, now, model) + ); + }, + { defaultModelSelection: settings.defaultModelSelection }, + ) ?? selection; } if (textGeneratorCannotRun && selection.instanceId === textGeneratorId) { return yield* new TextGenerationError({ diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index be364729819c9..ae0126d438254 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1887,6 +1887,43 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("counts unpriced tokens from the unpriced records of a mixed model", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const message = (id: string, cost: number, input: number, output: number, minute: number) => + encodeUnknownJson({ + id, + sessionID: "session-1", + role: "assistant", + modelID: "acme-coder-1", + providerID: "acme", + time: { created: Date.parse(`2026-08-01T10:0${minute}:00Z`) }, + cost, + tokens: { input, output, reasoning: 0, cache: { read: 0, write: 0 } }, + }); + const priced = yield* message("msg_priced", 1, 60, 40, 0); + const unpriced = yield* message("msg_unpriced", 0, 9_000, 900, 1); + yield* Effect.promise(async () => { + const directory = NodePath.join(home, "opencode", "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(directory, "msg_priced.json"), priced); + await NodeFSP.writeFile(NodePath.join(directory, "msg_unpriced.json"), unpriced); + }); + const service = yield* UsageService.make.pipe( + Effect.provide(layerService({ prefix: "usage-service-unpriced-tokens", home, settings })), + ); + const summary = yield* service.readSummary(WINDOW); + const model = mergeUsage( + [{ environmentId: EnvironmentId.make("local"), label: "local", summary }], + summary.contractVersion, + ).models.find((entry) => entry.model === "acme-coder-1"); + + assert.strictEqual(model?.costUsd, 1); + assert.strictEqual(model?.totalTokens, 10_000); + assert.strictEqual(model?.unpricedTokens, 9_900); + }).pipe(Effect.scoped), + ); + it.live("includes host and merged OpenCode instance histories once across list aliases", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 0bdf1b8cc2612..8d1d799859737 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -58,6 +58,7 @@ interface MutableBucket { speedPremiumUsd: number; records: number; unpricedRecords: number; + unpricedTokens: number; providerReportedRecords: number; sessions: Set; } @@ -283,7 +284,14 @@ export class UsageAggregator { this.#options.priceOverrides, ); bucket.records += 1; - if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; + if (priced.costSource === "unpriced") { + bucket.unpricedRecords += 1; + bucket.unpricedTokens += + record.totals.uncachedInputTokens + + record.totals.cachedInputTokens + + record.totals.cacheCreationTokens + + record.totals.outputTokens; + } if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; } @@ -330,6 +338,7 @@ export class UsageAggregator { speedPremiumUsd: 0, records: 0, unpricedRecords: 0, + unpricedTokens: 0, providerReportedRecords: 0, sessions: new Set(), }; @@ -377,6 +386,7 @@ export class UsageAggregator { costSource: resolveCostSource(bucket), records: bucket.records, unpricedRecords: bucket.unpricedRecords, + unpricedTokens: bucket.unpricedTokens, sessions: bucket.sessions.size, }); } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index dbc87e088d905..ff0eac7c00093 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -986,7 +986,6 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const hostEnv = yield* HostProcess.Environment; const commitEnv: NodeJS.ProcessEnv = { - ...hostEnv, GIT_INDEX_FILE: tempIndexPath, GIT_AUTHOR_NAME: "T3 Code", GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0fce1ca55f521..542057e60c3ec 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6,6 +6,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; @@ -649,6 +650,25 @@ it.effect.each(["bare", "unborn"] as const)( ).pipe(Effect.provide(layerTest)), ); +it.effect("reads the project's own repository when the server inherited GIT_DIR", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const other = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* initRepoWithCommit(other); + yield* git(other, ["switch", "-c", "other-repository"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllEnvs())); + vi.stubEnv("GIT_DIR", path.join(other, ".git")); + assert.equal((yield* driver.statusDetailsLocal(cwd)).branch, initialBranch); + const refs = yield* driver.listRefs({ cwd }); + assert.isFalse(refs.refs.some((ref) => ref.name === "other-repository")); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect.each(["on a branch", "detached", "unborn"] as const)( "reads only the repository root for review requests that ignore the branch when %s", (state) => @@ -1167,6 +1187,48 @@ describe("pullCurrentBranch", () => { ), ), ); + + // A slow smudge filter (LFS) can hold the checkout past 30 seconds; killing it there leaves a + // half-updated working tree. The automatic pulls and pull request refresh use these defaults. + it.effect.each(["pull", "merge"] as const)( + "lets a slow %s checkout finish instead of killing it at 30 seconds", + (subcommand) => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const { cwd, remoteHead } = yield* cloneBehindRemote; + yield* git(cwd, ["fetch", "origin"]); + const checkoutStarted = yield* Deferred.make(); + const releaseCheckout = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + if (!ChildProcess.isStandardCommand(command) || command.args[0] !== subcommand) { + return handle; + } + yield* Deferred.succeed(checkoutStarted, undefined); + return ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: Deferred.await(releaseCheckout).pipe(Effect.andThen(handle.exitCode)), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const move = + subcommand === "pull" + ? Effect.asVoid(driver.pullCurrentBranch(cwd)) + : Effect.asVoid(driver.refreshCheckedOutBranch({ cwd, targetCommit: remoteHead })); + const moving = yield* move.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(checkoutStarted); + yield* TestClock.adjust("60 seconds"); + yield* Deferred.succeed(releaseCheckout, undefined); + + yield* Fiber.join(moving); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead); + }).pipe(Effect.provide(layerTest)), + ); }); it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => @@ -1875,9 +1937,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); + // Selected paths add up to more than Linux's default 2 MiB argument limit. + const dir = "d".repeat(200); + yield* fs.makeDirectory(path.join(cwd, dir)); const files = Array.from( { length: 6_000 }, - (_, index) => `file-${String(index).padStart(5, "0")}-${"x".repeat(160)}.txt`, + (_, index) => `${dir}/file-${String(index).padStart(5, "0")}-${"x".repeat(160)}.txt`, ); yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "before\n"), { concurrency: 16, @@ -1903,6 +1968,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isNotNull(context); assert.isAbove(context!.stagedSummary.length, 1_000_000); assert.lengthOf(context!.stagedSummary.split("\n"), files.length); + const selected = files.slice(1); + yield* driver.commit(cwd, "selected", "", { stage: { filePaths: selected } }); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "selected"); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), `M ${files[0]}`); }), ); describe("structured errors", () => { @@ -1938,6 +2007,28 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("fails with a GitCommandError when spawn throws", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + + // Over the OS argument limit, Node's spawn throws E2BIG synchronously. + const error = yield* driver + .execute({ + operation: "GitVcsDriver.test.spawnThrow", + cwd, + args: ["status", "x".repeat(4 * 1024 * 1024)], + }) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.test.spawnThrow", + detail: "Failed to spawn Git process.", + }); + }), + ); + // regression: #8645 name the cause of a failed git command it.effect("names the branch conflict behind a failed worktree add", () => Effect.gen(function* () { @@ -2093,6 +2184,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect( + "switches a remote ref to its tracking branch when a tag shares the branch name", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/v1.2"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["branch", "--track", "v1.2", "origin/v1.2"]); + yield* git(cwd, ["tag", "v1.2"]); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "origin/v1.2", + }); + + assert.equal(result.refName, "v1.2"); + assert.equal(yield* git(cwd, ["symbolic-ref", "HEAD"]), "refs/heads/v1.2"); + }), + ); + it.effect.each([ // Git cannot tell a missing revision from a missing file in these. { label: "an argument that may be a path", args: ["log", "-1", "missing"] }, @@ -5424,6 +5540,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); assert.equal(yield* fileSystem.exists(created.worktree.path), true); + yield* git(cwd, ["worktree", "remove", "--force", created.worktree.path]); + yield* git(cwd, ["branch", "-D", "feature/custom-dir"]); + yield* fileSystem.makeDirectory(created.worktree.path); + const leftover = pathService.join(created.worktree.path, "notes.txt"); + yield* fileSystem.writeFileString(leftover, "my notes"); + const recreated = yield* driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: "feature/custom-dir" }, + { worktreesDirectory }, + ); + assert.notEqual(recreated.worktree.path, created.worktree.path); + assert.equal( + yield* git(recreated.worktree.path, ["branch", "--show-current"]), + "feature/custom-dir", + ); + assert.deepEqual(yield* fileSystem.readDirectory(created.worktree.path), ["notes.txt"]); + const error = yield* driver .createWorktree( { cwd, path: null, refName: initialBranch, newRefName: "feature/relative-dir" }, @@ -5963,39 +6095,82 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("tracks a remote start point once another process releases the config lock", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const remote = yield* makeTmpDir("git-remote-"); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "origin", initialBranch]); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const lockPath = path.join(cwd, ".git", "config.lock"); - yield* fileSystem.writeFileString(lockPath, ""); - const { driver, failures } = yield* makeDriverReleasingLock( - (args) => args.some((arg) => arg.startsWith("--set-upstream-to")), - lockPath, - ); + it.effect.each(["createWorktree", "switchRef"] as const)( + "tracks a remote start point once another process releases the config lock: %s", + (operation) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", `${initialBranch}:refs/heads/feature/tracked`]); + yield* git(cwd, ["fetch", "origin"]); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lockPath = path.join(cwd, ".git", "config.lock"); + yield* fileSystem.writeFileString(lockPath, ""); + const { driver, failures } = yield* makeDriverReleasingLock( + (args) => args.some((arg) => arg.startsWith("--set-upstream-to")), + lockPath, + ); - // The upstream retry waits on the real clock; the lock is already gone by then. - yield* TestClock.withLive( - driver.createWorktree({ - cwd, - path: path.join(yield* makeTmpDir("git-worktrees-"), "tracked"), - refName: `origin/${initialBranch}`, - newRefName: "feature/tracked", - }), - ); + // The upstream retry waits on the real clock; the lock is already gone by then. + if (operation === "createWorktree") { + yield* TestClock.withLive( + driver.createWorktree({ + cwd, + path: path.join(yield* makeTmpDir("git-worktrees-"), "tracked"), + refName: "origin/feature/tracked", + newRefName: "feature/tracked", + }), + ); + } else { + const switched = yield* TestClock.withLive( + driver.switchRef({ cwd, refName: "origin/feature/tracked" }), + ); + assert.equal(switched.refName, "feature/tracked"); + } - assert.equal(yield* Ref.get(failures), 1); - assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"]), - `origin/${initialBranch}`, - ); - }), + assert.equal(yield* Ref.get(failures), 1); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"]), + "origin/feature/tracked", + ); + }), + ); + + it.effect( + "leaves no untracked branch behind when the config stays locked during a switch", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", remote]); + yield* git(cwd, ["push", "upstream", `${initialBranch}:refs/heads/feature`]); + yield* git(cwd, ["fetch", "upstream"]); + const fileSystem = yield* FileSystem.FileSystem; + const lockPath = (yield* Path.Path).join(cwd, ".git", "config.lock"); + yield* fileSystem.writeFileString(lockPath, ""); + const driver = yield* GitVcsDriver.GitVcsDriver; + + // The upstream retry waits on the real clock before giving up. + yield* TestClock.withLive(driver.switchRef({ cwd, refName: "upstream/feature" })).pipe( + Effect.flip, + ); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + assert.equal(yield* git(cwd, ["branch", "--list", "feature"]), ""); + + yield* fileSystem.remove(lockPath); + const switched = yield* driver.switchRef({ cwd, refName: "upstream/feature" }); + assert.equal(switched.refName, "feature"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature@{upstream}"]), + "upstream/feature", + ); + }), ); it.effect("fetches a remote branch into a tracking branch past another process's lock", () => @@ -6234,28 +6409,42 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("prepares selected files without staging and commits only those files", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - yield* writeTextFile(cwd, "a.txt", "a\n"); - yield* writeTextFile(cwd, "b.txt", "b\n"); - - const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); - assert.include(context?.stagedSummary ?? "", "a.txt"); - assert.notInclude(context?.stagedSummary ?? "", "b.txt"); - assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), ""); + it.effect.each(["root", "aliased-nested"] as const)( + "prepares selected files without staging and commits only those files from %s", + (location) => + Effect.gen(function* () { + const sandbox = yield* makeTmpDir(); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repository = path.join(sandbox, "repo"); + const nested = path.join(repository, "sub", "dir"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* initRepoWithCommit(repository); + const cwd = location === "aliased-nested" ? path.join(sandbox, "link") : repository; + if (location === "aliased-nested") NodeFS.symlinkSync(nested, cwd, "junction"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const selected = "sub/dir/a.txt"; + yield* writeTextFile(repository, selected, "a\n"); + yield* writeTextFile(repository, "b.txt", "b\n"); - const commit = yield* driver.commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } }); - assert.match(commit.commitSha, /^[a-f0-9]{40}$/); - assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); + const context = yield* driver.prepareCommitContext(cwd, [selected]); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + assert.equal(yield* git(repository, ["diff", "--cached", "--name-only"]), ""); - const status = yield* git(cwd, ["status", "--porcelain"]); - assert.include(status, "?? b.txt"); - assert.notInclude(status, "a.txt"); - }), + const commit = yield* driver.commit(cwd, "Add a", "", { + stage: { filePaths: [selected] }, + }); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(repository, ["log", "-1", "--pretty=%s"]), "Add a"); + assert.equal( + yield* git(repository, ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]), + selected, + ); + const status = yield* git(repository, ["status", "--porcelain"]); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); + }), ); it.effect("stops a selected-file commit when another process holds the index lock", () => @@ -6400,7 +6589,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), mainHead); assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); - if (conflict) yield* writeTextFile(cwd, "README.md", "resolved version\n"); + if (conflict) { + const unresolved = yield* driver + .commit(commitCwd, "Unresolved merge", "", { stage: {} }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(unresolved), "committed an unresolved conflict"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), mainHead); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + // A lone "=======" is a heading underline, not a conflict boundary. + yield* writeTextFile(cwd, "README.md", "Heading\n=======\n\nresolved\n"); + assert.isNotNull(yield* driver.prepareCommitContext(commitCwd)); + } yield* driver.commit(commitCwd, "Finish merge", "", { stage: {} }); assert.equal( yield* git(cwd, ["show", "-s", "--format=%P", "HEAD"]), @@ -6410,10 +6609,52 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["show", "HEAD:feature.txt"]), "feature"); assert.equal(yield* git(cwd, ["show", "HEAD:unrelated.txt"]), "unrelated"); if (conflict) - assert.equal(yield* git(cwd, ["show", "HEAD:README.md"]), "resolved version"); + assert.equal( + yield* git(cwd, ["show", "HEAD:README.md"]), + "Heading\n=======\n\nresolved", + ); }), ); + it.effect.each([ + { operation: "cherry-pick", head: "CHERRY_PICK_HEAD" }, + { operation: "revert", head: "REVERT_HEAD" }, + ])("rejects selected-file commits during a $operation", ({ operation, head }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "other"]); + yield* writeTextFile(cwd, "README.md", "other version\n"); + yield* writeTextFile(cwd, "picked.txt", "picked\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Other"]); + if (operation === "cherry-pick") yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "README.md", "main version\n"); + yield* git(cwd, ["commit", "-am", "Main"]); + const originalHead = yield* git(cwd, ["rev-parse", "HEAD"]); + const result = yield* driver.execute({ + operation: `test.${operation}`, + cwd, + args: [operation, operation === "cherry-pick" ? "other" : "HEAD~1"], + allowNonZeroExit: true, + }); + assert.equal(result.exitCode, 1); + yield* writeTextFile(cwd, "README.md", "resolved version\n"); + yield* git(cwd, ["add", "README.md"]); + const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); + const selected = operation === "cherry-pick" ? "picked.txt" : "README.md"; + + const commit = yield* driver + .commit(cwd, "Partial", "", { stage: { filePaths: [selected] } }) + .pipe(Effect.result); + + assert.isTrue(Result.isFailure(commit), `Selected-file ${operation} commit succeeded`); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), originalHead); + assert.isTrue(NodeFS.existsSync(`${gitDir}/${head}`)); + }), + ); + it.effect("treats selected file paths literally", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e04bd48a8c843..a0b99e6d6d7f5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -42,7 +42,11 @@ import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { removeEmptyDirectory, type RemoveEmptyDirectoryError } from "./removeEmptyDirectory.ts"; -import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts"; +import { + explicitBareRepositoryEnv, + gitConfigEnv, + unsetInheritedRepositoryEnv, +} from "./VcsProcess.ts"; import { makeWorktreeCheckoutCache } from "./worktreeCheckoutCache.ts"; import { removeLinkedWorktree } from "./worktreeRemoval.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; @@ -993,8 +997,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ); + const hostEnv = yield* HostProcess.Environment; const baseEnv = { - ...(yield* HostProcess.Environment), + ...hostEnv, + ...unsetInheritedRepositoryEnv(hostEnv, hostPlatform), // Concurrent status polls must not take index.lock to save refreshed indexes. GIT_OPTIONAL_LOCKS: "0", ...input.env, @@ -1003,6 +1009,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const env = { ...baseEnv, ...explicitBareRepositoryEnv(baseEnv, hostPlatform) }; const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); + const spawnError = (cause: unknown) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to spawn Git process.", + cause, + }); // cmd wrappers truncate multiline commits; leave git.cmd handling to Node lookup. const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner @@ -1018,14 +1030,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ) .pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Failed to spawn Git process.", - cause, - }), - ), + Effect.mapError(spawnError), + // Synchronous spawn throws (such as E2BIG) arrive as defects. + Effect.catchDefect((cause) => Effect.fail(spawnError(cause))), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -2489,31 +2496,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { env }, ); } else { - const mergeHeadPath = yield* runGitStdout("GitVcsDriver.commit.mergeHead", cwd, [ + const inProgressHeads = [ + { head: "MERGE_HEAD", operation: "merge" }, + { head: "CHERRY_PICK_HEAD", operation: "cherry-pick" }, + { head: "REVERT_HEAD", operation: "revert" }, + ] as const; + const headPaths = yield* runGitStdout("GitVcsDriver.commit.mergeHead", cwd, [ "rev-parse", - "--git-path", - "MERGE_HEAD", + ...inProgressHeads.flatMap(({ head }) => ["--git-path", head]), ]); - const isMerging = yield* fileSystem.exists(path.resolve(cwd, mergeHeadPath.trim())).pipe( + const resolvedHeadPaths = headPaths.trim().split("\n"); + const inProgress = yield* Effect.findFirst(inProgressHeads, (_, index) => + fileSystem.exists(path.resolve(cwd, resolvedHeadPaths[index] ?? "")), + ).pipe( Effect.mapError( (cause) => new GitCommandError({ operation: "GitVcsDriver.commit.mergeHead", command: "git commit", cwd, - detail: "Could not check whether a merge is in progress.", + detail: "Could not check whether a merge, cherry-pick or revert is in progress.", cause, }), ), ); - // Like git commit --only, reject partial merges before reset can discard their ancestry. - if (isMerging) { + // Like git commit --only, reject partial commits before reset can discard their state. + if (Option.isSome(inProgress)) { + const { operation } = inProgress.value; return yield* new GitCommandError({ operation: "GitVcsDriver.commit", command: "git commit", cwd, - detail: - "Cannot commit selected files during a merge. Commit all files to finish the merge.", + detail: `Cannot commit selected files during a ${operation}. Commit all files to finish the ${operation}.`, }); } yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); @@ -2521,11 +2535,86 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.commit.addSelected", cwd, - // Status paths are repository-relative, while cwd can be a subdirectory. - [...config, "add", "-A", "--", ...filePaths.map((file) => `:(top,literal)${file}`)], - env ? { env } : {}, + [...config, "add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], + { + ...(env ? { env } : {}), + // Status paths are repository-relative, while cwd can be a subdirectory. + stdin: filePaths.map((file) => `:(top,literal)${file}\0`).join(""), + }, ); } else { + // add -A marks unmerged paths resolved, so refuse while one still has conflict markers. + const unmergedPaths = (yield* runGitStdoutWithOptions( + "GitVcsDriver.commit.unmergedPaths", + cwd, + ["-c", "diff.relative=false", "diff", "--name-only", "--diff-filter=U", "-z"], + env ? { env } : {}, + )) + .split("\0") + .filter((file) => file.length > 0); + if (unmergedPaths.length > 0) { + const check = yield* executeGit( + "GitVcsDriver.commit.conflictMarkers", + cwd, + [ + "-c", + "diff.relative=false", + "diff", + "--check", + "--no-color", + "--", + ...unmergedPaths.map((file) => `:(top,literal)${file}`), + ], + { + ...(env ? { env } : {}), + allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + const reports = check.stdout.split("\n"); + const conflicted: string[] = []; + let topLevel: string | undefined; + for (const file of unmergedPaths) { + const markerLines = new Set( + reports.flatMap((line) => { + const match = line.startsWith(`${file}:`) + ? /^(\d+): leftover conflict marker$/.exec(line.slice(file.length + 1)) + : null; + return match ? [Number(match[1]) - 1] : []; + }), + ); + if (markerLines.size === 0) continue; + topLevel ??= (yield* runGitStdout("GitVcsDriver.commit.topLevel", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const lines = (yield* fileSystem.readFileString(path.join(topLevel, file)).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.commit.conflictMarkers", + command: "git commit", + cwd, + detail: `Could not check ${file} for conflict markers.`, + cause, + }), + ), + )).split("\n"); + // Git also reports a lone "=======", which is valid content such as a heading underline. + const reported = (marker: string) => + lines.some((line, index) => markerLines.has(index) && line.startsWith(marker)); + if (reported("<") && reported(">")) conflicted.push(file); + } + if (conflicted.length > 0) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit", + command: "git commit", + cwd, + detail: `Cannot commit with unresolved merge conflicts in ${conflicted.join(", ")}. Resolve them, then commit.`, + }); + } + } yield* runGit( "GitVcsDriver.commit.addAll", cwd, @@ -2544,7 +2633,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, ["rev-parse", "--git-path", "index", "--git-path", "objects"], )).split("\n"); - const indexPath = path.resolve(cwd, indexValue.trim()); + // Git's relative paths use the physical cwd, even when the project uses a symlink. + const physicalCwd = yield* fileSystem.realPath(cwd); + const indexPath = path.resolve(physicalCwd, indexValue.trim()); const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-commit-index-", }); @@ -2562,7 +2653,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const quoted = (entry: string) => entry.startsWith('"') ? entry : `"${entry.replace(/[\\"]/g, "\\$&")}"`; const alternates = [ - path.resolve(cwd, objectsValue.trim()), + path.resolve(physicalCwd, objectsValue.trim()), ...objectDirectories .split("\n") .flatMap((line) => @@ -2984,8 +3075,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // After a successful pull, HEAD before and after decides the result, so its output is not // needed. A large fast-forward's diffstat can exceed the output cap, which must not fail a // pull Git applied. + // A deadline would kill git mid-checkout (e.g. a slow LFS smudge) and strand a half-updated tree. const options = { - timeoutMs: 30_000, + timeoutMs: null, appendTruncationMarker: true, allowNonZeroExit: true, }; @@ -4467,8 +4559,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (Buffer.byteLength(prefix + character) > 23) break; prefix += character; } - const directoryName = `${prefix}-${Hex.encode(hash).slice(0, 16)}`; - worktreePath = path.join(parentDir, repoName, directoryName); + const basePath = path.join(parentDir, repoName, `${prefix}-${Hex.encode(hash).slice(0, 16)}`); + // The name is stable per branch, so a folder left by an earlier worktree must not block it. + worktreePath = basePath; + for ( + let suffix = 2; + yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => false)); + suffix++ + ) { + worktreePath = `${basePath}-${suffix}`; + } } const newRefName = input.newRefName; if (newRefName) { @@ -4773,7 +4873,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? ["merge", "--ff-only", input.targetCommit] : ["reset", "--merge", input.targetCommit], { - timeoutMs: 30_000, + // Killing a slow checkout partway through strands a half-updated working tree. + timeoutMs: null, fallbackErrorDetail: "git failed to move the checkout onto the pull request head", }, ); @@ -5156,7 +5257,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? yield* executeGit( "GitVcsDriver.switchRef.localTrackingBranch", input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + // `:short` prefixes a name a tag shares with `heads/`, so names are unambiguous here. + ["for-each-ref", "--format=%(refname:lstrip=2)\t%(upstream)", "refs/heads"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -5164,7 +5266,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe( Effect.map((result) => result.exitCode === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + ? parseTrackingBranchByUpstreamRef(result.stdout, `refs/remotes/${input.refName}`) : null, ), ) @@ -5206,15 +5308,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // Created before the checkout so the shared-config lock is not held while files are written. // Not rolled back on failure: Git may already have switched HEAD to it (post-checkout hooks). if (newTrackingBranch) { - yield* executeGit( + const startRef = `refs/remotes/${input.refName}`; + yield* executeGitWithStableDiagnostics( "GitVcsDriver.switchRef.createTrackingBranch", input.cwd, - ["branch", "--track", "--", newTrackingBranch, input.refName], - { - timeoutMs: 10_000, - fallbackErrorDetail: "git branch create failed", - writesSharedConfig: true, - }, + ["branch", "--no-track", "--", newTrackingBranch, startRef], + { fallbackErrorDetail: "git branch create failed" }, + ); + // An untracked branch of this name would make every later switch to the remote detach. + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.switchRef.trackRemote", + input.cwd, + ["branch", `--set-upstream-to=${startRef}`, "--", newTrackingBranch], + { writesSharedConfig: true }, + ).pipe( + retryWhileConfigLocked, + Effect.tapError(() => + executeGit( + "GitVcsDriver.switchRef.removeUntrackedBranch", + input.cwd, + ["update-ref", "-d", `refs/heads/${newTrackingBranch}`, startRef], + { allowNonZeroExit: true }, + ), + ), ); } diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index c379caf48847a..4bfdcc90037ec 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -134,6 +134,29 @@ export const gitConfigEnv = ( export const explicitBareRepositoryEnv = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => gitConfigEnv(env, "safe.bareRepository", "explicit", platform); +const REPOSITORY_SELECTORS = new Set([ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +]); + +/** + * Unsets repository selectors the server inherited, such as a Git hook's `GIT_DIR`, so Git finds + * the repository from cwd. Spread it before a command's own env, which may still set them. + */ +export const unsetInheritedRepositoryEnv = ( + inherited: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): NodeJS.ProcessEnv => + Object.fromEntries( + Object.keys(inherited) + .filter((name) => REPOSITORY_SELECTORS.has(platform === "win32" ? name.toUpperCase() : name)) + .map((name) => [name, undefined]), + ); + const classifyNonZeroExit = ( stderr: string, classifyFailure: VcsProcessInput["classifyFailure"], @@ -210,6 +233,9 @@ export const make = Effect.gen(function* () { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.onStdoutChunk !== undefined ? { onStdoutChunk: input.onStdoutChunk } : {}), env: { + ...(input.command === "git" + ? unsetInheritedRepositoryEnv(process.env, hostPlatform) + : {}), // Failures are read by English patterns; without LANG, macOS Git follows the system language. // LANGUAGE leaves LC_CTYPE, which hooks inherit, alone. ...(input.command === "git" ? { LANGUAGE: "C" } : {}), diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 62d6ab0a63b33..87cab4a2c88c3 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -457,17 +457,31 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("matches square brackets in the query literally", () => + it.effect("matches glob metacharacters in the query literally", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-brackets-" }); yield* writeTextFile(cwd, "src/app/game/[id]/index.tsx"); + yield* writeTextFile(cwd, "src/foo{bar}.ts"); + yield* writeTextFile(cwd, "src/main.ts"); + yield* writeTextFile(cwd, "src/notes.md"); - for (const query of ["game/[i", "game/[id]"]) { - const result = yield* searchWorkspaceEntries({ cwd, query, limit: 10 }); - expect(result.entries.map((entry) => entry.path)).toContain( - "src/app/game/[id]/index.tsx", + const search = (query: string) => + searchWorkspaceEntries({ cwd, query, limit: 10 }).pipe( + Effect.map((result) => result.entries.map((entry) => entry.path)), ); + + for (const query of ["game/[i", "game/[id]"]) { + expect(yield* search(query)).toContain("src/app/game/[id]/index.tsx"); } + expect(yield* search("foo{bar}.ts")).toContain("src/foo{bar}.ts"); + expect((yield* search("*.{ts,tsx}")).toSorted()).toEqual([ + "src/app/game/[id]/index.tsx", + "src/foo{bar}.ts", + "src/main.ts", + ]); + expect((yield* search("*.{ts}")).toSorted()).toEqual(["src/foo{bar}.ts", "src/main.ts"]); + expect(yield* search("src/{main}.ts")).toEqual(["src/main.ts"]); + expect(yield* search("src/{foo{bar},notes}.md")).toEqual(["src/notes.md"]); }), ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 11a19e1839861..23d17759c9fc1 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -447,32 +447,43 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }, ); - const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( - "WorkspaceSearchIndex.search", - )(function* (rawQuery, limit, kind, imageOnly) { - // fff parses any word containing "[" as a glob and has no escape for it; fuzzy - // matching is subsequence-based, so dropping "[" still matches "[id]" folders. - const query = rawQuery.replaceAll("[", ""); + const searchNames = Effect.fn("WorkspaceSearchIndex.searchNames")(function* ( + query: string, + limit: number, + kind: ProjectEntryKind | undefined, + imageOnly: boolean | undefined, + ) { const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); - const incompleteBeforeQuery = hasIncompleteInitialScan(); if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - const mapped = mapFileSearchResult(result, limit, imageOnly); - return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; + return mapFileSearchResult(result, limit, imageOnly); } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => finder.directorySearch(query, { pageSize }), ); - const mapped = mapDirectorySearchResult(result, limit); - return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; + return mapDirectorySearchResult(result, limit); } const result = yield* runSearch(query, pageSize, "mixedSearch", () => finder.mixedSearch(query, { pageSize }), ); - const mapped = mapMixedSearchResult(result, limit); + return mapMixedSearchResult(result, limit); + }); + + const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( + "WorkspaceSearchIndex.search", + )(function* (rawQuery, limit, kind, imageOnly) { + // fff parses any word containing "[" or "{" as a glob and has no escape for them; fuzzy + // matching is subsequence-based, so dropping them still matches "[id]" and "a{b}" paths. + // "{" is dropped only when the query finds nothing as a glob, so "*.{ts,tsx}" keeps working. + const query = rawQuery.replaceAll("[", ""); + const incompleteBeforeQuery = hasIncompleteInitialScan(); + let mapped = yield* searchNames(query, limit, kind, imageOnly); + if (mapped.entries.length === 0 && query.includes("{")) { + mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly); + } return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2b64947dbc7df..492b874cc451e 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -149,6 +149,7 @@ import * as EventSink from "./orchestration-v2/EventSink.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ThreadCommandExecutor from "./orchestration-v2/ThreadCommandExecutor.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { projectIdentitiesDigest } from "./orchestration-v2/ShellStream.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -708,6 +709,8 @@ it.effect("shell snapshots omit idle children and older settled threads only on shell("live", "parent", "running"), ...Array.from({ length: 25 }, (_, index) => shell(`recent-${index}`, null, null, 1)), shell("old", null, null, 30), + shell("remote-parent", null, null, 40), + shell("remote-child-parent", "parent", null), ]; const firstSnapshot = (input: { readonly omitIdleSubagents?: boolean; @@ -724,6 +727,7 @@ it.effect("shell snapshots omit idle children and older settled threads only on pinnedAt: null, snoozedUntil: null, mayBeLive: thread.activityRunStatus !== null, + hasRemoteTasks: thread.id.startsWith("remote-"), })), ), readShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => @@ -767,15 +771,17 @@ it.effect("shell snapshots omit idle children and older settled threads only on const withoutChildren = yield* firstSnapshot({ omitIdleSubagents: true }); assert.deepEqual( withoutChildren?.threads.map((thread) => thread.id), - ["parent", "live", ...recent, "old"], + ["parent", "live", ...recent, "old", "remote-parent", "remote-child-parent"], ); + // A linked environment's clients nest its task under its parent, so that parent stays: an + // older settled thread or an idle subagent. const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); assert.deepEqual( withoutOld?.threads.map((thread) => thread.id), - ["parent", "live", ...recent], + ["parent", "live", ...recent, "remote-parent", "remote-child-parent"], ); assert.deepEqual(withoutOld?.olderSettled?.counts, [ - { projectId: ProjectId.make("project"), count: 1 }, + { projectId: ProjectId.make("project"), count: 2 }, ]); }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); @@ -1168,6 +1174,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ], }; let scansCwd = true; + let registryProviders: ReadonlyArray = [provider]; const providerChanges = yield* Queue.unbounded>(); const pulled = yield* Queue.unbounded(); const responses = yield* Queue.unbounded(); @@ -1178,11 +1185,12 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( resolveFileManagerRevealKind: () => Effect.undefined, }), Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([provider]), + getProviders: Effect.sync(() => registryProviders), streamChanges: Stream.fromQueue(providerChanges).pipe( Stream.tap(() => Queue.offer(pulled, undefined)), ), - refreshWorkspaceSnapshot: () => Effect.succeed([provider]), + refresh: () => Effect.sync(() => registryProviders), + refreshWorkspaceSnapshot: () => Effect.sync(() => registryProviders), }), Layer.mock(ProjectService.ProjectService)({ workspaceScanDirectory: (cwd) => Effect.succeed(cwd), @@ -1204,6 +1212,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]), streamChanges: Stream.make([]), + refresh: Effect.void, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ resolveTargets: () => Effect.succeed([]), @@ -1359,6 +1368,121 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( }, ]); yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); + + // A client that takes digests and deltas, against a catalog of over 200 KB. + const heavy: ServerProvider = { + ...provider, + slashCommands: Array.from({ length: 250 }, (_, index) => ({ + name: `command-${index}`, + description: "d".repeat(400), + })), + skills: Array.from({ length: 200 }, (_, index) => ({ + name: `skill-${index}`, + description: "d".repeat(500), + path: `/home/user/.claude/skills/skill-${index}/SKILL.md`, + enabled: true, + })), + }; + registryProviders = [heavy]; + const lightRequest = RpcMessage.RequestId("light-config"); + yield* server.write(0, { + _tag: "Request", + id: lightRequest, + tag: WS_METHODS.subscribeServerConfig, + payload: { + workspaceSnapshotsOnDemand: true, + providerCatalogsOnDemand: true, + providerCatalogDigests: true, + providerStatusDeltas: true, + }, + headers: Headers.empty, + }); + const light = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (light.type !== "snapshot") return yield* Effect.die("Missing light snapshot"); + const digest = light.config.providers[0]?.catalogDigest; + assert.isString(digest); + assert.deepEqual( + [light.config.providers[0]?.slashCommands, light.config.providers[0]?.skills], + [[], []], + ); + // Budget for the first config a phone decodes: the catalog stays out of it. + assert.isBelow(JSON.stringify(light.config.providers).length, 1024); + yield* server.write(0, { _tag: "Ack", requestId: lightRequest }); + + // A health check sends the provider without the lists the client already holds. + yield* Queue.offer(providerChanges, [{ ...heavy, checkedAt: "2026-10-25T00:10:00.000Z" }]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const delta = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (delta.type !== "providerStatusDeltas") return yield* Effect.die("Expected provider deltas"); + assert.deepEqual( + delta.payload.providers.map((entry) => [ + entry.checkedAt, + entry.catalogDigest, + entry.models, + entry.slashCommands, + entry.skills, + ]), + [["2026-10-25T00:10:00.000Z", digest, undefined, undefined, undefined]], + ); + yield* server.write(0, { _tag: "Ack", requestId: lightRequest }); + + // A changed description changes the digest, so the client loads the lists again. + yield* Queue.offer(providerChanges, [ + { + ...heavy, + checkedAt: "2026-10-25T00:20:00.000Z", + skills: heavy.skills.map((skill, index) => + index === 0 ? { ...skill, description: "changed" } : skill, + ), + }, + ]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const redescribed = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (redescribed.type !== "providerStatusDeltas") { + return yield* Effect.die("Expected provider deltas"); + } + assert.notEqual(redescribed.payload.providers[0]?.catalogDigest, digest); + yield* server.write(0, { _tag: "Ack", requestId: lightRequest }); + + // A caller that only waits for a machine refresh gets no provider list back. + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh-omitted"), + tag: WS_METHODS.serverRefreshProviders, + payload: { workspaceSnapshotsOnDemand: true, omitProviders: true }, + headers: Headers.empty, + }); + assert.deepEqual(yield* Queue.take(responses), { providers: [] }); + + // The cwd snapshot brings the lists the digest stands for, under the same digest. + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh-machine-catalog"), + tag: WS_METHODS.serverRefreshProviders, + payload: { + instanceId, + cwd: "/b", + workspaceSnapshotsOnDemand: true, + providerCatalogsOnDemand: true, + omitProviders: true, + machineCatalog: true, + }, + headers: Headers.empty, + }); + const loaded = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.equal(loaded.providers[0]?.catalogDigest, digest); + assert.deepEqual(loaded.providers[0]?.slashCommands[0], { name: "command-0" }); + assert.deepEqual(loaded.providers[0]?.skills[0], { + name: "skill-0", + path: "/home/user/.claude/skills/skill-0/SKILL.md", + enabled: true, + }); + assert.lengthOf(loaded.providers[0]?.workspaceSnapshots ?? [], 1); + yield* server.write(0, { _tag: "Interrupt", requestId: lightRequest, interruptors: [] }); }).pipe( Effect.provide( Layer.mergeAll( @@ -1857,41 +1981,123 @@ it.effect.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); -// A thread idle since before the boundary missed nothing; resuming must not resend its body. -it.effect("thread resume at its own high-water below the pruned boundary skips the snapshot", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const now = "2026-10-13T00:00:00.000Z"; - const threadId = ThreadId.make("thread:idle"); - const [last] = yield* sql<{ sequence: number }>` - 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 ( - 'idle:0', 'thread', ${threadId}, 0, 'provider-session.detached', - ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:idle", detachedAt: now })}, - '{}', 2 - ) RETURNING sequence - `; - yield* sql` - INSERT INTO orchestration_v2_projection_metadata ( - projection_name, schema_version, last_sequence, updated_at - ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${last!.sequence + 10}, ${now}) - `; - const exit = yield* subscribeOrchestrationV2Thread({ - threadId, - afterSequence: last!.sequence, - }).pipe( - Effect.provideService(ThreadManagement.ThreadManagementService, { - ensureLegacyTranscript: () => Effect.void, - getThreadSnapshot: () => Effect.die("snapshot"), - streamStoredEventsFrom: () => Stream.empty, - } as unknown as ThreadManagement.ThreadManagementService["Service"]), - Effect.exit, - ); - // A snapshot load dies with "snapshot", so success means the resume replayed nothing. - assert.isTrue(Exit.isSuccess(exit)); - }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +// A cursor at the high-water missed nothing, even below the boundary; resuming must not resend +// a snapshot. The shell cases are a client that just loaded the HTTP snapshot of a pruned +// database: it gets the marker, and the project identities only when it does not name them. +it.effect.each([ + { scope: "thread", knowsIdentities: false, isRepository: true, kinds: [] }, + { + scope: "shell", + knowsIdentities: false, + isRepository: true, + kinds: ["snapshot", "synchronized"], + }, + { scope: "shell", knowsIdentities: true, isRepository: true, kinds: ["synchronized"] }, + // The client keeps a cached identity over an absent one, so "resolved to none" still goes out. + { + scope: "shell", + knowsIdentities: true, + isRepository: false, + kinds: ["snapshot", "synchronized"], + }, +] as const)( + "$scope resume at its own high-water below the pruned boundary skips the snapshot (knows identities: $knowsIdentities, repository: $isRepository)", + ({ scope, knowsIdentities, isRepository, kinds }) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-10-13T00:00:00.000Z"; + const threadId = ThreadId.make("thread:idle"); + const [last] = yield* sql<{ sequence: number }>` + 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 ( + 'idle:0', 'thread', ${threadId}, 0, 'provider-session.detached', + ${now}, 'server', ${JSON.stringify({ providerSessionId: "session:idle", detachedAt: now })}, + '{}', 2 + ) RETURNING sequence + `; + yield* sql` + INSERT INTO orchestration_v2_projection_metadata ( + projection_name, schema_version, last_sequence, updated_at + ) VALUES (${OrchestrationEventStore.EVENT_LOG_PRUNED_THROUGH}, 2, ${last!.sequence + 10}, ${now}) + `; + const identity = isRepository + ? ({ + canonicalKey: "github.com/t3/idle", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:t3/idle", + }, + } as const) + : null; + const project = { + id: ProjectId.make("project:idle"), + title: "Idle", + workspaceRoot: "/tmp/idle", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + } satisfies OrchestrationProjectShell; + const subscription = Effect.gen(function* () { + if (scope === "thread") { + return yield* subscribeOrchestrationV2Thread({ + threadId, + afterSequence: last!.sequence, + }).pipe(Effect.as([])); + } + const stream = yield* subscribeOrchestrationV2Shell({ + afterSequence: last!.sequence, + requestCompletionMarker: true, + ...(knowsIdentities + ? { + knownProjectIdentities: projectIdentitiesDigest([ + { ...project, repositoryIdentity: identity }, + ]), + } + : {}), + }); + return yield* stream.pipe( + Stream.takeUntil((item) => item.kind === "synchronized"), + Stream.map((item) => item.kind), + Stream.runCollect, + ); + }); + const exit = yield* subscription.pipe( + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: () => Effect.die("snapshot"), + listActiveShellIndex: () => Effect.die("snapshot"), + readShellSnapshot: () => Effect.die("snapshot"), + listActiveSubagentThreadIds: () => Effect.succeed([]), + streamStoredEventsFrom: () => Stream.empty, + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([project]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService( + ProjectService.ProjectService, + {} as ProjectService.ProjectService["Service"], + ), + Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { + subscribeChanges: PubSub.subscribe(yield* PubSub.unbounded()), + getAvailable: () => + Effect.succeed({ + repositoryIdentity: identity, + faviconPath: null, + repositoryIdentityResolved: true, + }), + } as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"]), + Effect.exit, + ); + // A snapshot load dies with "snapshot", so success means the resume replayed nothing. + assert.deepEqual( + Exit.isFailure(exit) ? Cause.squash(exit.cause) : Array.from(exit.value), + kinds, + ); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); const acpDriver = ProviderDriverKind.make("acpRegistry"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5257da599c4f2..4cfceb4ed8001 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,6 @@ import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- A synchronous digest inside the pure provider mapping. +import * as NodeCrypto from "node:crypto"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -83,6 +85,7 @@ import { type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, type ServerProvider, + serverProviderDeltas, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -133,6 +136,8 @@ import { shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, loadSubscriberShellSnapshot, + canResumeShellFrom, + resumeIdentityRoots, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -445,26 +450,67 @@ function workspaceSnapshotOrCatalog( ]; } -// On-demand catalog clients read full entries from the cwd snapshot they fetch. Names still -// change the config when the machine catalog does, so the client refetches, and skill names -// and labels keep skill links working where no snapshot is fetched. +// What an on-demand catalog client keeps of the machine catalog: full entries come from the +// cwd snapshot it fetches, and skill names and labels keep skill links working without one. +function machineCatalogNames(provider: Pick) { + return { + slashCommands: provider.slashCommands.map(({ name }) => ({ name })), + skills: provider.skills.map(({ description: _d, shortDescription: _s, ...skill }) => skill), + }; +} + +// Stable across restarts, so a client's cached lists stay current until the catalog changes. +// It covers the full entries: a changed description or input hint must reach the client too. +const machineCatalogDigest = (provider: Pick) => + NodeCrypto.createHash("sha256") + .update(JSON.stringify([provider.slashCommands, provider.skills])) + .digest("hex") + .slice(0, 16); + +// Names, or the digest that stands for them, still change the config when the machine catalog +// does, so the client refetches its cwd snapshot. function withoutWorkspaceSnapshots( providers: ReadonlyArray, catalogsPerCwd: ReadonlySet = new Set(), + catalogDigests = false, ): ServerProvider[] { - return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => - catalogsPerCwd.has(provider.instanceId) + return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => { + if (!catalogsPerCwd.has(provider.instanceId)) return provider; + return catalogDigests ? { ...provider, - slashCommands: provider.slashCommands.map(({ name }) => ({ name })), - skills: provider.skills.map( - ({ description: _d, shortDescription: _s, ...skill }) => skill, - ), + slashCommands: [], + skills: [], + catalogDigest: machineCatalogDigest(provider), } - : provider, - ); + : { ...provider, ...machineCatalogNames(provider) }; + }); } +// Deltas are taken from the list this client last received, which a late snapshot replaces. +const withProviderStatusDeltas = (sent: ReadonlyArray) => + Stream.mapAccum( + () => sent, + ( + previous, + event: ServerConfigStreamEvent, + ): readonly [ReadonlyArray, ReadonlyArray] => { + if (event.type === "snapshot") return [event.config.providers, [event]]; + if (event.type !== "providerStatuses") return [previous, [event]]; + const { providers } = event.payload; + return [ + providers, + [ + { + version: 1, + type: "providerStatusDeltas", + payload: { providers: serverProviderDeltas(previous, providers) }, + }, + ], + ]; + }, + ); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -678,12 +724,6 @@ const isOtherWsRpc = (rpc: ServerWsRpc): rpc is Exclude const serverWsRpcs = Array.from(ServerWsRpcGroup.requests.values()); const SplitWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isSplitWsRpc)); const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); -// Replaying each intervening event costs a shell refetch; past this gap a -// single O(active-threads) snapshot is cheaper and bounded. -const SHELL_RESUME_MAX_GAP = 1_000; -// Row counts do not bound large tool payloads; sum stored JSON bytes in SQL and snapshot above budget. -const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; - function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -820,36 +860,6 @@ function readClientOwners( ); } -const canReplayPersistedRange = Effect.fnUntraced(function* ( - afterSequence: number, - headSequence: number, - maxGap: number, -) { - const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; - - const replayGap = headSequence - afterSequence; - if (replayGap < 0 || replayGap > maxGap) { - return false; - } - if (afterSequence < (yield* applicationEvents.prunedThroughSequence)) return false; - const stats = yield* applicationEvents.getReplayStats({ - afterSequence, - throughSequence: headSequence, - }); - if (stats.rawPayloadBytes > ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES) { - yield* Effect.logDebug("orchestration replay replaced by snapshot", { - afterSequence, - headSequence, - replayGap, - eventCount: stats.eventCount, - payloadBytes: stats.rawPayloadBytes, - payloadBudgetBytes: ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES, - }); - return false; - } - return true; -}); - const enrichProjectShells = Effect.fn("ws.orchestrationV2.enrichProjectShells")( (projects: ReadonlyArray) => Effect.flatMap(ProjectEnrichmentService.ProjectEnrichmentService, (projectEnrichment) => @@ -1114,6 +1124,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly httpSnapshotFallback?: true; + readonly knownProjectIdentities?: string; readonly requestCompletionMarker?: boolean; readonly omitIdleSubagents?: boolean; readonly omitOlderSettled?: boolean; @@ -1339,7 +1350,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }); }); const highWater = yield* applicationEvents.latestApplicationSequence; - if (!(yield* canReplayPersistedRange(input.afterSequence, highWater, SHELL_RESUME_MAX_GAP))) { + if (!(yield* canResumeShellFrom(applicationEvents, input.afterSequence, highWater))) { return yield* fromSnapshot; } @@ -1358,12 +1369,22 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc skipUnknownEventTypes: true, }) .pipe(Stream.filter(filterSubagentTranscript), Stream.runCollect); - if (input.afterSequence < (yield* applicationEvents.prunedThroughSequence)) { + if ( + input.afterSequence < highWater && + input.afterSequence < (yield* applicationEvents.prunedThroughSequence) + ) { return yield* fromSnapshot; } const replay = toShellStream(Stream.fromIterable(replayed)); return composeShellStreamWithEnrichment({ - initial: initialEnrichmentItems(loaded), + initial: initialEnrichmentItems({ + snapshot: loaded.snapshot, + resolvedRepositoryIdentityRoots: resumeIdentityRoots({ + projects: loaded.snapshot.projects, + resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots, + knownProjectIdentities: input.knownProjectIdentities, + }), + }), tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)), enrichment: enrichmentRefreshes, }); @@ -1767,6 +1788,7 @@ export const layerWsRpc = ( readonly workspaceSnapshotsOnDemand: boolean; readonly deferNetworkDiscovery: boolean; readonly catalogsPerCwd?: ReadonlySet; + readonly catalogDigests?: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; @@ -1774,7 +1796,11 @@ export const layerWsRpc = ( ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; const providers = options.workspaceSnapshotsOnDemand - ? withoutWorkspaceSnapshots(commandProviders, options.catalogsPerCwd) + ? withoutWorkspaceSnapshots( + commandProviders, + options.catalogsPerCwd, + options.catalogDigests, + ) : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, @@ -2448,12 +2474,15 @@ export const layerWsRpc = ( providers = yield* providerRegistry.refreshInstance(instance.instanceId); } } + const { instanceId, cwd } = input; + if (input.omitProviders === true && (instanceId === undefined || cwd === undefined)) { + return { providers: [] }; + } if (input.workspaceSnapshotsOnDemand !== true) return { providers }; const commandProviders = input.usageLimitsCommand === true ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) : providers; - const { instanceId, cwd } = input; if (instanceId === undefined || cwd === undefined) { return { providers: withoutWorkspaceSnapshots(commandProviders) }; } @@ -2463,14 +2492,23 @@ export const layerWsRpc = ( return { providers: commandProviders .filter((provider) => provider.instanceId === instanceId) - .map((provider) => ({ - ...provider, - // Callers read only the cwd snapshot; the config stream carries the rest. - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: workspaceSnapshotOrCatalog(provider, cwd, catalogOnDemand), - })), + .map((provider) => { + const catalog = + catalogOnDemand && input.machineCatalog === true + ? machineCatalogNames(provider) + : undefined; + return { + ...provider, + // Callers read only the cwd snapshot; the config stream carries the rest. + models: [], + slashCommands: catalog?.slashCommands ?? [], + skills: catalog?.skills ?? [], + ...(catalog === undefined + ? {} + : { catalogDigest: machineCatalogDigest(provider) }), + workspaceSnapshots: workspaceSnapshotOrCatalog(provider, cwd, catalogOnDemand), + }; + }), }; }), [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), @@ -3222,6 +3260,7 @@ export const layerWsRpc = ( ), ) : Effect.succeed(new Set()); + const catalogDigests = input.providerCatalogDigests === true; // Buffer changes through discovery and the initial snapshot's send. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; @@ -3239,6 +3278,7 @@ export const layerWsRpc = ( workspaceSnapshotsOnDemand, deferNetworkDiscovery: true, catalogsPerCwd: yield* currentCatalogsPerCwd, + catalogDigests, }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ @@ -3269,7 +3309,11 @@ export const layerWsRpc = ( workspaceSnapshotsOnDemand ? currentCatalogsPerCwd.pipe( Effect.map((catalogsPerCwd) => - withoutWorkspaceSnapshots(commandProviders, catalogsPerCwd), + withoutWorkspaceSnapshots( + commandProviders, + catalogsPerCwd, + catalogDigests, + ), ), ) : Effect.succeed(commandProviders), @@ -3335,19 +3379,22 @@ export const layerWsRpc = ( remoteResult?._tag === "Success" ? { ...config, remoteOpenTargets: remoteResult.value } : config; + const updates = withLateConfigDiscovery( + initialConfig, + liveUpdates, + externalLauncher, + { + resolveTargets: () => Fiber.join(remoteTargets), + }, + { resolve: () => Fiber.join(directResult) }, + ); return Stream.concat( rpcInitialItems([ { version: 1 as const, type: "snapshot" as const, config: initialConfig }, ]), - withLateConfigDiscovery( - initialConfig, - liveUpdates, - externalLauncher, - { - resolveTargets: () => Fiber.join(remoteTargets), - }, - { resolve: () => Fiber.join(directResult) }, - ), + input.providerStatusDeltas === true + ? updates.pipe(withProviderStatusDeltas(initialConfig.providers)) + : updates, ); }), ), diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index fa8e88e6b1a90..faa71137db049 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -14,6 +14,7 @@ import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter"; import { startPreviewTitleRecording } from "./browserHistoryStore"; import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; +import { resolveWindowTitleThreadStatus } from "./components/Sidebar.logic"; import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog"; import { startHostFreezeToasts } from "./components/HostFreezeToastCoordinator"; @@ -133,7 +134,10 @@ function readOpenThreadRef(router: AppRouter): ScopedThreadRef | null { return resolveActiveThreadRouteRef(target, draft); } -/** Names the open thread and its project in the window title, or the app when none is open. */ +/** + * Names the open thread and its project in the window title, or the app when none is open. + * Screen readers read it on Alt+Tab, so it can lead with the thread and what it's doing. + */ function startDocumentTitle(router: AppRouter): void { const readOnGuestPage = () => router.state.matches.some((match) => match.routeId === "/guest"); let openThreadRef = readOpenThreadRef(router); @@ -163,6 +167,11 @@ function startDocumentTitle(router: AppRouter): void { }), projectTitle: project?.title ?? null, threadTitle: thread?.title ?? null, + ...(getClientSettings().screenReaderWindowTitle && { + screenReader: { + threadStatus: thread === null ? null : resolveWindowTitleThreadStatus(thread), + }, + }), desktop: isElectron, }); // Projects change whenever any project does; most changes leave the title alone. @@ -198,6 +207,7 @@ function startDocumentTitle(router: AppRouter): void { watchOpenThread(); appAtomRegistry.subscribe(primaryServerConfigAtom, write, { immediate: true }); appAtomRegistry.subscribe(environmentProjects.projectsAtom, write); + subscribeClientSettings(write); router.subscribe("onResolved", updateOpenThread); useComposerDraftStore.subscribe(updateOpenThread); } diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 6490edbb4a05c..f53103d904ad0 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -60,14 +60,23 @@ export function resolveWindowTitle(input: { readonly appDisplayName: string; readonly projectTitle: string | null; readonly threadTitle: string | null; + /** The screen reader title: status, then thread, then project, since a screen reader reads from the start. */ + readonly screenReader?: { readonly threadStatus: string | null }; readonly desktop: boolean; }): string { const threadTitle = input.threadTitle?.trim() ?? ""; + const status = input.screenReader?.threadStatus; if (threadTitle === "") { - return input.appDisplayName; + return status ? `${status}: ${input.appDisplayName}` : input.appDisplayName; } const projectTitle = input.projectTitle?.trim() ?? ""; - const context = projectTitle === "" ? threadTitle : `${projectTitle} / ${threadTitle}`; - return input.desktop ? context : `${context} - ${input.appDisplayName}`; + const context = + projectTitle === "" + ? threadTitle + : input.screenReader + ? `${threadTitle} - ${projectTitle}` + : `${projectTitle} / ${threadTitle}`; + const title = status ? `${status}: ${context}` : context; + return input.desktop ? title : `${title} - ${input.appDisplayName}`; } diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index e1c1459facb36..5e3c97649fd06 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -23,3 +23,18 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); } + +/** Storage events fire only for other tabs' writes; a null key means storage was cleared. */ +export function subscribeBrowserClientSettingsWrites(onChange: () => void): () => void { + if (!hasWindow()) { + return () => undefined; + } + const handleStorage = (event: StorageEvent) => { + if (event.key === CLIENT_SETTINGS_STORAGE_KEY || event.key === null) { + onChange(); + } + }; + const target = window; + target.addEventListener("storage", handleStorage); + return () => target.removeEventListener("storage", handleStorage); +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index da845e95fc0b6..ac1496b04a5c6 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,7 +1,5 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; -import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -40,7 +38,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles"; -import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows"; +import { ThreadDetailsThreadPrRows } from "./chat/ThreadDetailsPrRows"; import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-link-input"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useComposerMenuProps } from "./chat/composerEventScope"; @@ -58,10 +56,8 @@ import { } from "./BranchToolbar.logic"; import { ThreadPullRequestBadgeControl, - prStatusIndicator, resolveDisplayedThreadPr, - resolveThreadPullRequestBadge, - useLinkedThreadPullRequest, + useThreadDisplayedPullRequest, } from "./ThreadStatusIndicators"; import { ComboboxItem, ComboboxTrigger } from "./ui/combobox"; @@ -600,32 +596,18 @@ export function BranchToolbarBranchSelector({ threadBranch: branchPrBranch, gitStatus: branchStatusQuery.data, }); - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); - const linkedStatus = useLinkedThreadPullRequest( + const { + badge: prBadge, + number: prNumber, + url: prUrl, + status: prStatus, + } = useThreadDisplayedPullRequest( environmentId, - serverThread?.linkedPullRequest, - true, - serverThread?.pullRequests, - serverThread?.branchPullRequest, - ); - const currentLinkedPr = supportsMultiplePullRequests - ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) - : null; - const prBadge = supportsMultiplePullRequests - ? resolveThreadPullRequestBadge(serverThread?.pullRequests) - : null; - const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); - const displayedPrStatus = prStatusIndicator( - displayedPr, - linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, + serverThread, + branchPr, + branchStatusQuery.data?.sourceControlProvider, ); - const prNumber = currentLinkedPr?.number ?? displayedPr?.number; - const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); - const panelPrLabel = - prNumber === undefined - ? "" - : `#${prNumber}${displayedPr?.title.trim() ? `: ${displayedPr.title}` : ""}`; function selectPickerItem(itemValue: string) { if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { @@ -757,7 +739,7 @@ export function BranchToolbarBranchSelector({ pullRequests={serverThread?.pullRequests ?? []} number={prNumber} url={prUrl} - status={displayedPrStatus} + status={prStatus} onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} onOpenPullRequest={(event, targetUrl = prUrl) => { if (targetUrl) openPrLink(event, targetUrl); @@ -795,21 +777,12 @@ export function BranchToolbarBranchSelector({ - {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( - openPrLink(event, prUrl)} + branchPr={branchPr} + branchSourceControlProvider={branchStatusQuery.data?.sourceControlProvider} onActed={() => branchStatusQuery.refresh()} /> ) : null} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5674a3df2483e..d2248c3af708e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -174,6 +174,7 @@ import { filesystemEnvironment } from "../state/filesystem"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, + pickProjectLinkMatch, pickWorkspaceLinkMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, workspaceLinkLookupQuery, @@ -792,13 +793,44 @@ function useChatMarkdownState({ }); if (metadata._tag !== "Success" || metadata.value.entries[0] !== null) return null; } - const result = await searchProjectEntries({ - environmentId, - input: { cwd, query, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file" }, - }); - return result._tag === "Success" - ? pickWorkspaceLinkMatch(workspaceRelativePath, cwd, result.value.entries) - : null; + const searchRoot = async (root: string) => { + const rootQuery = workspaceLinkLookupQuery(workspaceRelativePath, root); + if (rootQuery === null) return { root, entries: [] }; + const result = await searchProjectEntries({ + environmentId, + input: { + cwd: root, + query: rootQuery, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return { root, entries: result._tag === "Success" ? result.value.entries : [] }; + }; + const own = await searchRoot(cwd); + const ownMatch = pickWorkspaceLinkMatch(workspaceRelativePath, cwd, own.entries); + if (ownMatch !== null) return ownMatch; + const threadProjectId = threadRef ? readThreadShell(threadRef)?.projectId : undefined; + const otherRoots = projects.flatMap((project) => + project.environmentId === environmentId && + project.id !== threadProjectId && + project.workspaceRoot !== cwd + ? [project.workspaceRoot] + : [], + ); + if (otherRoots.length === 0) return null; + // Another project's file never replaces a thread-local file the search missed. + if (needsWorkspaceBasenameLookup(workspaceRelativePath)) { + const metadata = await getFilesystemMetadata({ + environmentId, + input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + }); + if (metadata._tag !== "Success" || metadata.value.entries[0] !== null) return null; + } + return pickProjectLinkMatch( + workspaceRelativePath, + await Promise.all(otherRoots.map(searchRoot)), + ); }; const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; @@ -1592,6 +1624,7 @@ const CHAT_MARKDOWN_COMPONENTS = { _tag: "media-file", threadId: threadRef.threadId, path: imageSource.path, + ...(imageSource.relative ? { hostOnly: true as const } : {}), }} alt={altText} kind={kind} diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 40ff6e66f47d8..0ee0caa61d480 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -18,7 +18,9 @@ import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, + dispatchedCommands, installFakeEnvironment, + rejectPendingCommand, threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; @@ -191,3 +193,39 @@ it("a stashed prompt ending in a file chip restores with the chip", async () => expect(editor.element().textContent).not.toContain("[package.json](package.json)"); await expect.element(chip).toBeVisible(); }); + +it("a rejected send leaves the timeline when a new draft was typed meanwhile", async () => { + const thread = threadWithHistory("rejected-send", 1); + using _environment = installFakeEnvironment([thread], { + acceptCommands: true, + holdCommand: (command) => command.type === "message.dispatch", + }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText(`Answer 0 for ${thread.id}.`)).toBeVisible(); + + const editor = page.getByTestId("composer-editor"); + await userEvent.click(editor); + await userEvent.keyboard("Failed prompt{Enter}"); + await vi.waitFor(() => + expect(dispatchedCommands().map((command) => command.type)).toContain("message.dispatch"), + ); + await expect.element(page.getByText("Failed prompt")).toBeVisible(); + await userEvent.keyboard("Next draft"); + + await rejectPendingCommand("Attachment 'sample.png' cannot be sent."); + + await expect.element(page.getByText("Attachment 'sample.png' cannot be sent.")).toBeVisible(); + await expect.element(page.getByText("Failed prompt")).not.toBeInTheDocument(); + expect(editor.element().textContent).toContain("Next draft"); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 906b83e9838c7..fdbb91d9073ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3432,6 +3432,7 @@ export default function ChatView(props: ChatViewProps) { activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? null } activeTurnStartedAt={activeWorkStartedAt} + providerStartingLabel={activeActivityRun?.providerStartingLabel ?? null} worktreeSetup={worktreeSetup} onCancelWorktreeSetup={onCancelWorktreeSetupStable} retryableWorkspacePreparationRunIds={retryableWorkspacePreparationRunIds} @@ -3860,6 +3861,7 @@ export default function ChatView(props: ChatViewProps) { worktreePath={activeThread.worktreePath} isServerThread={isServerThread} {...(draftId ? { draftId } : {})} + composerDraftTarget={composerDraftTarget} activeProjectName={activeProject?.title} activeProjectScripts={activeProject ? activeProjectScripts : undefined} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index c7106f0cff289..8292121ac2ceb 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -5,6 +5,8 @@ import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { LAST_USED_PROJECT_STORAGE_KEY } from "../hooks/useHandleNewThread"; +import { removeLocalStorageItem } from "../hooks/useLocalStorage"; import { makeThreadFixture } from "../test-fixtures"; import type { Project, Thread } from "../types"; import { CommandPalette } from "./CommandPalette"; @@ -20,6 +22,9 @@ vi.mock("../state/entities", async (original) => ({ })); function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArray } = {}) { + // Earlier browser test files share this origin's localStorage; a remembered project + // that is not listed here hides the default "New thread in " row. + removeLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY); useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); entities.projects = state.projects ?? []; entities.threads = state.threads ?? []; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 9767972d73d05..4231b669aa76b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -24,6 +24,7 @@ import { import * as Schema from "effect/Schema"; import { type MouseEvent, useEffect, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { useCodeViewScrollRestore } from "./diffs/useCodeViewScrollRestore"; import { useFilesystemReadAccess } from "~/state/filesystem"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { useFileContextMenuHandler } from "../fileContextMenu"; @@ -67,7 +68,7 @@ import { formatRelativeTimeLabel, formatShortTimestamp } from "../timestampForma import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { DiffFileTree } from "./diffs/DiffFileTree"; import { FileExplorerPane } from "./files/FileExplorerPane"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; @@ -693,12 +694,27 @@ export default function DiffPanel({ const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); + // Lazy files that sat above the saved position load again only when asked for. + const codeViewScroll = useCodeViewScrollRestore( + collapseScopeKey, + !isRefreshingDiff && remainingFileCount === 0, + remainingFileCount > 0 ? loadNextFiles : null, + ); + const attachCodeView = useStableCallback((handle: AnnotatableCodeViewHandle | null) => { + codeViewScroll.attach(handle); + codeViewReveal.attach(handle); + }); + const flushCodeView = useStableCallback(() => { + codeViewScroll.flush(); + codeViewReveal.flush(); + }); const revealDiffFile = (filePath: string) => { const index = renderableFileEntries.findIndex( (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, ); const file = renderableFileEntries[index]; if (!file) return false; + codeViewScroll.discard(); updateCollapsedDiffFiles((current) => { const next = new Set(current); next.delete(file.fileKey); @@ -775,6 +791,7 @@ export default function DiffPanel({ }); const toggleDiffFileCollapse = () => { + codeViewScroll.discard(); setCodeViewRevision((current) => current + 1); updateCollapsedDiffFiles((current) => toggleAllDiffFiles(shownFileKeys, current)); }; @@ -1431,7 +1448,8 @@ export default function DiffPanel({ key={collapseScopeKey ?? reviewSectionId} environmentId={activeThread?.environmentId} projectId={activeThread?.projectId} - viewerRef={codeViewReveal.attach} + viewerRef={attachCodeView} + onScroll={codeViewScroll.onScroll} codeViewKey={codeViewMountKey} className="h-full min-h-0 overflow-auto" files={codeViewFiles} @@ -1493,7 +1511,7 @@ export default function DiffPanel({ preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme as DiffThemeType, stickyHeaders: true, - onPostRender: codeViewReveal.flush, + onPostRender: flushCodeView, ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), }} /> diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2822621c7fa85..bb89514b2f0a1 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1651,28 +1651,36 @@ export default function GitActionsControl({ ) : ( - - - + + } > - {quickAction.label} - - + + + {quickAction.label} + + + {quickAction.label} + )} {gitActionProgress ? ( - + + } + disabled={isGitActionRunning || !hasGitMenuContent} + /> + } + > + + + Git action options + {gitItems} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 29eaab6adf793..8fae9515fe4e8 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -330,21 +330,29 @@ export default function ProjectScriptsControl({ setActionsMenuOpen({ presentation, scripts: open, imports: false }) } > - + + } + /> + } + > + - } - > - - + + Script actions + { const { Atom } = await import("effect/reactivity"); const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings"); @@ -190,3 +194,56 @@ describe("sidebar render isolation", () => { expect(jumpLabelsSince(beforeRelease)).toEqual([]); }); }); + +describe("sidebar thread rename", () => { + // regression: #35151 an untouched rename wrote back the title it opened with over a newer one + it.each(["{Enter}", "{Tab}"])( + "ending an untouched rename with %s keeps a title that arrived while it was open", + async (finish) => { + const activeProject = project("active"); + const routeThreadRef: ScopedThreadRef = { + environmentId: activeProject.environmentId, + threadId: ThreadId.make("thread-0"), + }; + const shellTitled = (title: string) => ({ ...threadShell(activeProject.id, 0), title }); + const updateMetadata = vi.fn().mockResolvedValue({ _tag: "Success" }); + vi.mocked(useOrchestrationCommand).mockReturnValue(updateMetadata); + vi.mocked(readEnvironmentScope).mockReturnValue(true); + vi.mocked(useEnvironmentScope).mockReturnValue(true); + vi.mocked(useProjects).mockReturnValue([activeProject]); + vi.mocked(useQuietThreadShells).mockReturnValue([shellTitled("Fix the login redirect")]); + vi.mocked(useThreadRouteTarget).mockReturnValue({ + kind: "server", + threadRef: routeThreadRef, + }); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: { + environmentId: activeProject.environmentId, + projectId: activeProject.id, + }, + defaultProjectFallbackReady: true, + handleNewThread: vi.fn().mockResolvedValue(null), + routeDraftId: null, + routeThreadRef, + }); + const tree = () => ( + + + + ); + await using screen = await render(tree()); + await userEvent.dblClick(screen.getByText("Fix the login redirect").first()); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await expect.element(input).toHaveValue("Fix the login redirect"); + + vi.mocked(useQuietThreadShells).mockReturnValue([shellTitled("Generated title")]); + await screen.rerender(tree()); + await userEvent.keyboard(finish); + + await expect.element(input).not.toBeInTheDocument(); + expect(updateMetadata).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed8..d1d045ed41a35 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,6 +16,7 @@ import { resolveSidebarSweepKeys, resolveSidebarThreadStatus, resolveThreadStatusPill, + resolveWindowTitleThreadStatus, resolveWorkingStartedAt, searchSidebarThreads, shouldRecedeSidebarThread, @@ -310,6 +311,24 @@ describe("resolveSidebarThreadStatus", () => { }), ).toBe("waiting"); }); + + it("titles the window with live work and requests only", () => { + const cases = [ + [{ ...idle, hasPendingApprovals: true, runtime }, "Needs approval"], + [{ ...idle, hasPendingUserInput: true }, "Needs your input"], + [{ ...idle, runtime: { ...runtime, status: "preparing" as const } }, "Working"], + [{ ...idle, runtime }, "Working"], + [{ ...idle, runtime, pendingGuestMessageCount: 1 }, "Working"], + [{ ...idle, runtime, blockedOnThreadWait: true }, "Working"], + [{ ...idle, runtime: { ...runtime, status: "idle" as const } }, null], + [{ ...idle, runtime: { ...runtime, status: "failed" as const } }, null], + [{ ...idle, runtime: { ...runtime, status: "completed" as const } }, null], + [idle, null], + ] as const; + for (const [thread, title] of cases) { + expect(resolveWindowTitleThreadStatus(thread)).toBe(title); + } + }); }); describe("searchSidebarThreads", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e84d307d0eec5..7f60c1d35215a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -40,6 +40,7 @@ import { import type { SidebarThreadSummary, Thread } from "../types"; import { normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; +import { THREAD_STATUS_TEXT } from "@t3tools/client-runtime/state/thread-activity-announcement"; import { observeIntersection } from "../lib/observeIntersection"; import { deadlineAfter, deadlineStore, type ClockStore } from "@t3tools/client-runtime/clock"; import { useDeadlinePassed } from "../hooks/useDeadline"; @@ -990,6 +991,25 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } +/** What the window title says the open thread is doing, in the words its announcer uses. */ +export function resolveWindowTitleThreadStatus(thread: SidebarThreadStatusInput): string | null { + // A guest waiting doesn't hide that the agent is still working. + switch (resolveSidebarThreadStatus({ ...thread, pendingGuestMessageCount: 0 })) { + case "approval": + return THREAD_STATUS_TEXT.approval; + case "input": + return THREAD_STATUS_TEXT.input; + case "preparing": + case "working": + return THREAD_STATUS_TEXT.working; + case "waiting": + // Waiting on a child thread is still this turn, so it must not read as finished. + return thread.runtime?.status === "running" ? THREAD_STATUS_TEXT.working : null; + default: + return null; + } +} + /** * The dot on a closed boring-sidebar folder: the most urgent state among its * hidden threads. Waiting threads stay quiet, like their rows. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8fa58d8456f75..6cb1e722322b7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -101,6 +101,7 @@ import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { configuredHostedAppUrl } from "../hostedPairing"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { SidebarFolderDrop } from "./sidebar/SidebarFolderDrop"; import { pickableProjectGroups, selectProjectGroupingSettings, @@ -1040,6 +1041,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? scopedThreadKey(renamingThreadRef) : null; const [renamingTitle, setRenamingTitle] = useState(""); + const renamingOriginalTitleRef = useRef(""); const settlingThreadKeysRef = useRef(new Set()); const threadListRef = useRef(null); const searchResultsListRef = useRef(null); @@ -1533,9 +1535,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; setRenamingThreadRef(threadRef); setRenamingTitle(title); + renamingOriginalTitleRef.current = title; }; const cancelThreadRename = () => setRenamingThreadRef(null); - const commitThreadRename = (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { + const commitThreadRename = (threadRef: ScopedThreadRef, title: string) => { + const originalTitle = renamingOriginalTitleRef.current; void (async () => { if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; const trimmed = title.trim(); @@ -2965,7 +2969,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( - <> + - + ); } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 99e0e01dfe03f..317b4f505c0dd 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -8,6 +8,7 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePull import { useStableCallback } from "~/hooks/useStableCallback"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveEnvironmentMachineKind, type EnvironmentId, @@ -116,6 +117,41 @@ export function useLinkedThreadPullRequest( }; } +/** The pull request a thread shows: its current link, else the one found for its branch. */ +export function useThreadDisplayedPullRequest( + environmentId: EnvironmentId, + thread: Pick< + EnvironmentThreadShell, + "pullRequests" | "linkedPullRequest" | "branchPullRequest" + > | null, + branchPr: ThreadPr = null, + branchSourceControlProvider?: VcsStatusResult["sourceControlProvider"], +) { + const supportsLinks = useSupportsMultiplePullRequests(environmentId); + const linkedStatus = useLinkedThreadPullRequest( + environmentId, + thread?.linkedPullRequest, + true, + thread?.pullRequests, + thread?.branchPullRequest, + ); + const currentLink = supportsLinks + ? resolveThreadCurrentPullRequestLink(thread?.pullRequests ?? []) + : null; + const pr = linkedStatus?.pr ?? (currentLink === null ? branchPr : null); + return { + currentLink, + badge: supportsLinks ? resolveThreadPullRequestBadge(thread?.pullRequests) : null, + pr, + status: prStatusIndicator( + pr, + linkedStatus?.sourceControlProvider ?? branchSourceControlProvider, + ), + number: currentLink?.number ?? pr?.number, + url: currentLink?.url ?? pr?.url, + }; +} + function linkedPullRequestReference(fallback: ThreadLinkedPullRequest) { const host = sourceControlClients.parseChangeRequestUrl(fallback.url)?.link.host; return { ...fallback, ...(host === undefined ? {} : { host }) }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bafafcb9612cf..6f6d4fbd50ffc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -698,7 +698,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) uncommittedSnapShotIds, standaloneComposerImages, } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations); - const preparingComposerImages = usePreparingComposerImages(attachmentTargetKey); + const preparingComposerImages = usePreparingComposerImages(attachmentTargetKey).filter( + (image) => !image.hidden, + ); const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds; const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); const openPrLink = useOpenPrLink(routeThreadRef); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 3d9eeb2f1b3b9..49227eb76eadd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -228,9 +228,18 @@ describe("MessagesTimeline work rows", () => { const props = baseProps(); const html = injectHtmlRenderBootstrap(`
Earlier preview content
More rows
More rows
- `); + `); const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + // The frame loads on its own clock, and a toggle posted before its script runs is lost. + const listening = new Promise((resolve) => { + const onMessage = (event: MessageEvent) => { + if (event.data !== "listening") return; + window.removeEventListener("message", onMessage); + resolve(); + }; + window.addEventListener("message", onMessage); + }); function Preview() { const [height, setHeight] = useState(1437); return ( @@ -270,6 +279,7 @@ describe("MessagesTimeline work rows", () => { expect(viewport.scrollTop).toBeGreaterThan(0); expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2); }); + await listening; const top = frame.getBoundingClientRect().top; frame.focus(); frame.contentWindow!.postMessage("toggle", "*"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d9460ccde7733..3bbea6a44eb21 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -232,6 +232,8 @@ interface MessagesTimelineProps { isWorking: boolean; runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; + /** Replaces "Thinking" while the active run's provider has not started a turn. */ + providerStartingLabel?: string | null; worktreeSetup?: WorktreeSetupSnapshot | null; onCancelWorktreeSetup?: () => void; retryableWorkspacePreparationRunIds?: ReadonlySet; @@ -349,6 +351,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ isWorking, runlessWorkActive = false, activeTurnStartedAt = null, + providerStartingLabel = null, worktreeSetup = null, footer = null, onCancelWorktreeSetup, @@ -851,7 +854,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ const onAppFullscreenChange = (rowId: string, fullscreen: boolean) => { setFullscreenAppRowId((current) => (fullscreen ? rowId : current === rowId ? null : current)); }; - const alwaysRenderIndices = restoringAlwaysRender?.indices ?? []; + const selectedRowIds = useSelectedTimelineRowIds(timelineViewportElement); + const alwaysRenderIndices = [ + ...(restoringAlwaysRender?.indices ?? []), + ...timelineRowSpanIndices(rows, selectedRowIds), + ]; // Screen readers can only reach headings in the DOM. Keeping the first and // last messages mounted lets heading navigation wrap to either end. const firstMessageRow = rows.find(isMessageRow); @@ -1158,6 +1165,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ backgroundWorktreeSetup, awaitingUser: awaitingUser !== null, isPreparingWorktree, + providerStartingLabel, latestRunId: latestRun?.runId ?? null, liveAnswerPreview: isWorking && toolCallVisibility === "zen" ? liveAnswerPreview(timelineEntries) : null, @@ -1518,6 +1526,52 @@ function isMessageRow(row: MessagesTimelineRow) { return row.kind === "message" && row.message.role !== "system"; } +// LegendList recycles a row's container once it leaves the draw buffer, which +// moves a native selection's ends onto other rows. Pin the selected rows. +function useSelectedTimelineRowIds(viewport: HTMLElement | null) { + const [rowIds, setRowIds] = useState>([]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to document selectionchange to track which timeline rows a native selection covers + useEffect(() => { + if (!viewport) return; + const update = () => { + const selection = document.getSelection(); + const range = + selection && !selection.isCollapsed && selection.rangeCount > 0 + ? selection.getRangeAt(0) + : null; + const next: string[] = []; + if (range?.intersectsNode(viewport)) { + for (const row of viewport.querySelectorAll("[data-timeline-row-id]")) { + const rowId = row.dataset.timelineRowId; + if (rowId && range.intersectsNode(row)) next.push(rowId); + } + } + setRowIds((current) => + current.length === next.length && current.every((id, i) => id === next[i]) ? current : next, + ); + }; + document.addEventListener("selectionchange", update); + return () => { + document.removeEventListener("selectionchange", update); + setRowIds([]); + }; + }, [viewport]); + return rowIds; +} + +// Rows between the selected ones are covered too, even if not mounted right now. +function timelineRowSpanIndices( + rows: ReadonlyArray, + rowIds: ReadonlyArray, +): number[] { + if (rowIds.length === 0) return []; + const selected = new Set(rowIds); + const first = rows.findIndex((row) => selected.has(row.id)); + const last = rows.findLastIndex((row) => selected.has(row.id)); + if (first < 0) return []; + return Array.from({ length: last - first + 1 }, (_, i) => first + i); +} + function keyExtractor(item: MessagesTimelineRow) { return item.id; } diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 3375fd7f60ea9..d61a1a4a6063a 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -33,6 +33,7 @@ import { MenuSubPopup, MenuTrigger, } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AntigravityIcon, CursorIcon, @@ -363,6 +364,50 @@ export const OpenInPicker = memo(function OpenInPicker({ ); } + const primaryControlLabel = compact ? "Open file in preferred editor" : primaryLabel; + const primaryControl = ( + { + if (!preferredEditor) return; + const label = editorLabelForPlatform(preferredEditor, navigator.platform); + void openInEditor(preferredEditor).then((opened) => { + if (opened) announce(`Opened in ${label}`); + }); + }} + > + {primaryOption?.Icon ? ( + + ); + return ( - { - if (!preferredEditor) return; - const label = editorLabelForPlatform(preferredEditor, navigator.platform); - void openInEditor(preferredEditor).then((opened) => { - if (opened) announce(`Opened in ${label}`); - }); - }} - > - {primaryOption?.Icon ? ( - + + + {primaryControlLabel} + {isPanel ? (