diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0c5462d26a70..f92d2d07b8815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,10 +143,16 @@ jobs: - name: Start installing browser secret helper build libraries if: needs.changes.outputs.build != 'false' run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config xvfb echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -290,10 +296,16 @@ jobs: # the background while Vite+ installs; the step before the tests waits for it. - name: Start installing browser secret helper build libraries run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c02be425c1ab7..53b0a74a1f5aa 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -88,7 +88,7 @@ jobs: axe --version - name: Install foldable simulator controls - run: npm install --global agent-device@0.21.25 + run: npm install --global agent-device@0.21.36 - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56c76be7e0e7..84acc7c74f865 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1482,7 +1482,18 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main + + # main moves while this job runs, so a rejected push rebases and retries. + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt). Rebasing onto latest main." + git pull --rebase origin main + done + + echo "Could not push the version bump after 5 attempts." >&2 + exit 1 announce_discord: name: Announce release on Discord diff --git a/.gitignore b/.gitignore index a3e75ffe9acc6..d8ea7d732cbdc 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ apps/mobile/.android-cache/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ artifacts/mobile-behavior/ +scripts/.e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 96493a5dd94e8..e63c3529014dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md). - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4a4884ba73e29..c6114f1bb48b8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "main": "dist-electron/boot.cjs", @@ -27,7 +27,7 @@ "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "react-grab": "^0.1.32", "tldts": "catalog:" }, diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index b823c17a816f4..45825a10003f5 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; +import { ChildProcessSpawner } from "effect/process"; import * as NetService from "@t3tools/shared/Net"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -224,6 +225,7 @@ it.effect.each([false, true])( Layer.mock(DesktopQuickMessage.DesktopQuickMessage, { initialize: Effect.void, }), + Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}), Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}), Layer.mock(DesktopCliCommand.DesktopCliCommand, {}), Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index ef9d2aad56795..206a143128a8e 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -116,6 +116,11 @@ export const make = Effect.gen(function* () { yield* electronApp.quit; return yield* Effect.interrupt; } + // Chromium opens these caches at the first event-loop turn, before ready. + yield* DesktopUserData.clearStaleGpuCaches({ + userDataPath, + electronVersion: process.versions.electron, + }); const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 6a7473e2f1b1a..de3230de02624 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -49,6 +49,13 @@ export const make = FileSystem.makeNoop({ syncFs("makeDirectory", path, () => { NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false }); }), + remove: (path, options) => + syncFs("remove", path, () => + NodeFS.rmSync(path, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }), + ), writeFileString: (path, data, options) => syncFs("writeFileString", path, () => NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }), diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 78bab4085627b..53e548bd1d41b 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -3,8 +3,9 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; -import { resolveUserDataPath } from "./DesktopUserData.ts"; +import { clearStaleGpuCaches, resolveUserDataPath } from "./DesktopUserData.ts"; it.effect.each(["t3code", "T3 Code (Alpha)"])( "preserves Windows credential keys from %s without copying browser databases", @@ -40,3 +41,100 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])( ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +const gpuCacheDirectories = [ + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUPersistentCache", +]; + +it.effect.each([ + { profile: "written by Electron 44.10.0", marker: "44.10.0" }, + { profile: "with no recorded Electron version", marker: undefined }, +])("clears GPU caches $profile once when Electron 44.10.1 starts", ({ marker }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userDataPath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-gpu-cache-" }); + for (const name of gpuCacheDirectories) { + yield* fs.makeDirectory(path.join(userDataPath, name), { recursive: true }); + yield* fs.writeFileString(path.join(userDataPath, name, "data_0"), "44.10.0 shaders"); + } + yield* fs.makeDirectory(path.join(userDataPath, "Local Storage"), { recursive: true }); + yield* fs.writeFileString(path.join(userDataPath, "Local Storage", "LOCK"), "profile"); + yield* fs.writeFileString(path.join(userDataPath, "Local State"), "profile"); + if (marker !== undefined) { + yield* fs.writeFileString(path.join(userDataPath, "gpu-cache-electron-version"), marker); + } + + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + + const survivingCaches: Array = []; + for (const name of gpuCacheDirectories) { + if (yield* fs.exists(path.join(userDataPath, name))) survivingCaches.push(name); + } + assert.deepStrictEqual(survivingCaches, []); + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "Local Storage", "LOCK")), + "profile", + ); + assert.equal(yield* fs.readFileString(path.join(userDataPath, "Local State")), "profile"); + + yield* fs.makeDirectory(path.join(userDataPath, "GPUCache")); + yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders"); + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")), + "44.10.1 shaders", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps GPU caches built on the first launch of a fresh install", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const appData = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-fresh-" }); + const userDataPath = path.join(appData, "t3code-v2"); + + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + yield* fs.makeDirectory(path.join(userDataPath, "GPUCache")); + yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders"); + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")), + "44.10.1 shaders", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("retries on the next launch when a GPU cache cannot be removed", () => + Effect.gen(function* () { + const writes: Array = []; + const fileSystem = FileSystem.makeNoop({ + remove: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => + Effect.sync(() => { + writes.push(path); + }), + }); + + yield* clearStaleGpuCaches({ userDataPath: "/profile", electronVersion: "44.10.1" }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + + assert.deepStrictEqual(writes, []); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index 99a082b438b0d..0124aa72e0ad5 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -95,3 +95,43 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa return destinationPath; }, ); + +const GPU_CACHE_DIRECTORIES = [ + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUPersistentCache", +]; + +// Keyed on Electron, not Chromium: Electron patches ship Chromium backports under the same Chrome version. +export const clearStaleGpuCaches = Effect.fn("desktop.userData.clearStaleGpuCaches")( + function* (input: { readonly userDataPath: string; readonly electronVersion: string }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const markerPath = path.join(input.userDataPath, "gpu-cache-electron-version"); + const cachedVersion = yield* fs.readFileString(markerPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.undefined, + ), + ); + if (cachedVersion?.trim() === input.electronVersion) return; + for (const name of GPU_CACHE_DIRECTORIES) { + yield* fs.remove(path.join(input.userDataPath, name), { recursive: true, force: true }); + } + yield* fs.makeDirectory(input.userDataPath, { recursive: true }); + yield* fs.writeFileString(markerPath, input.electronVersion); + }, + (effect, input) => + effect.pipe( + Effect.catch((error) => + Effect.logWarning("could not clear stale GPU caches", { + userDataPath: input.userDataPath, + electronVersion: input.electronVersion, + error: error.message, + }), + ), + ), +); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 2741daa1ddd07..3126d12a2e938 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -421,7 +421,7 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("starts the configured backend and closes the scoped process on stop", () => + it.effect("stops the scoped process with output still pending", () => Effect.scoped( Effect.gen(function* () { let startCount = 0; @@ -458,6 +458,7 @@ describe("DesktopBackendManager", () => { yield* Scope.addFinalizer(scope, close); return makeProcess({ + stdout: Stream.never, exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), kill: () => close, }); @@ -721,14 +722,12 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () => + it.effect("the quit stop keeps waiting for a backend that outlasts its deadline", () => Effect.scoped( Effect.gen(function* () { const spawned = yield* Deferred.make(); const teardownStarted = yield* Deferred.make(); const teardownFinished = yield* Deferred.make(); - // The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that - // ignores SIGTERM therefore finishes tearing down just after the grace. const instance = yield* makeTestInstance({ spawnerLayer: Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -739,7 +738,7 @@ describe("DesktopBackendManager", () => { yield* Scope.addFinalizer( scope, Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Effect.sleep(Duration.seconds(6))), + Effect.andThen(Effect.sleep(Duration.seconds(10))), Effect.andThen(Deferred.succeed(teardownFinished, undefined)), Effect.asVoid, ), @@ -764,11 +763,11 @@ describe("DesktopBackendManager", () => { .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); yield* Deferred.await(teardownStarted); - // Past the SIGTERM grace but before the force kill lands: quit must still be waiting. - yield* TestClock.adjust(Duration.millis(5_500)); + // Past the quit deadline, quit must still wait for the backend to exit. + yield* TestClock.adjust(Duration.seconds(9)); assert.isFalse(yield* Deferred.isDone(quitDone)); - yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); yield* Fiber.join(quitFiber); assert.isTrue(yield* Deferred.isDone(teardownFinished)); }).pipe(Effect.provide(TestClock.layer())), diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 98de41e247155..d24b2f52f295c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -240,6 +240,7 @@ export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly start: Effect.Effect; + /** Timeout controls the warning; stop still waits for process teardown and exit. */ readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; @@ -307,7 +308,7 @@ const closeRun = ( run: ActiveBackendRun, parentScope: Scope.Scope, options?: { readonly timeout?: Duration.Duration }, -): Effect.Effect => { +) => { const waitForFiber = Option.match(run.fiber, { onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), @@ -319,12 +320,24 @@ const closeRun = ( const timeout = options?.timeout; if (!timeout) { - return close.pipe(Effect.as(true)); + return close; } - return Effect.forkIn(close, parentScope).pipe( + return Effect.forkIn(close, parentScope, { startImmediately: true }).pipe( Effect.flatMap((closeFiber) => - Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)), + Fiber.await(closeFiber).pipe( + Effect.timeoutOption(timeout), + Effect.tap((completed) => + Option.isNone(completed) + ? logBackendProcessWarning("backend shutdown exceeded timeout; waiting for exit", { + timeoutMs: Duration.toMillis(timeout), + }) + : Effect.void, + ), + // Parent-scope interruption still finishes release; do not cancel sibling stops. + Effect.andThen(Fiber.await(closeFiber)), + Effect.asVoid, + ), ), ); }; @@ -1099,10 +1112,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( onNone: () => Effect.void, // The enclosing operation owns the span for this inline handler. onSome: Effect.fnUntraced(function* (run) { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } + yield* closeRun(run, parentScope, options); const cleanup = yield* mutex.withPermits(1)( SubscriptionRef.modify( state, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 2b2dd841ab53f..b4c7827ce1ae4 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -1,11 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; +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 Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -21,18 +27,105 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; -function layerPool(labelRef: Ref.Ref): Layer.Layer { +// Models the spawner's release for a backend that ignores SIGTERM, without OS signals. +const makeFakeBackendProcess = Effect.fn("test.makeFakeBackendProcess")(function* () { + const started = yield* Deferred.make(); + const exited = yield* Deferred.make(); + const signals = yield* Queue.unbounded<"SIGTERM" | "SIGKILL">(); + let running = false; + const killWait = Duration.seconds(1); + const exit = Effect.sync(() => { + running = false; + }).pipe(Effect.andThen(Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0))), Effect.asVoid); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") { + return yield* Effect.die("Expected a standard backend command"); + } + const terminateGrace = Duration.fromInputUnsafe(command.options.forceKillAfter ?? 0); + running = true; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Queue.offer(signals, "SIGTERM"); + yield* Effect.sleep(terminateGrace); + yield* Queue.offer(signals, "SIGKILL"); + yield* Effect.sleep(killWait); + yield* exit; + }), + ); + yield* Deferred.succeed(started, undefined); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + exitCode: Deferred.await(exited), + isRunning: Effect.sync(() => running), + kill: () => exit, + unref: Effect.succeed(Effect.void), + }); + }), + ), + ); + return { + layer, + started: Deferred.await(started), + signals, + isRunning: Effect.sync(() => running), + killWait, + }; +}); + +const runningConfig = { + executablePath: "/electron", + args: [], + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "fd3", + extendEnv: true, + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: false, + preflightFailure: Option.none(), +} satisfies DesktopBackendStartConfig; + +function layerPool( + labelRef: Ref.Ref, + backend?: Effect.Success>, +): Layer.Layer { return DesktopBackendPool.layer.pipe( Layer.provideMerge( Layer.mergeAll( - FileSystem.layerNoop({}), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), - ), + FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), + backend?.layer ?? + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + ), Layer.succeed( HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), + HttpClient.make((request) => + backend === undefined + ? Effect.die("unexpected HTTP request") + : Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ), + ), ), Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { forInstance: () => @@ -90,6 +183,62 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer { + it.effect("unregister during pool quit does not interrupt sibling stops", () => + Effect.scoped( + Effect.gen(function* () { + const backend = yield* makeFakeBackendProcess(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const ready = yield* Deferred.make(); + const siblingStarted = yield* Deferred.make(); + const finishSibling = yield* Deferred.make(); + let siblingCompleted = false; + const instance = yield* pool.register({ + id, + label: Effect.succeed("WSL (Ubuntu)"), + configResolve: Effect.succeed(runningConfig), + onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* backend.started; + yield* Deferred.await(ready); + const quitFiber = yield* Effect.all( + [ + instance.stop({ timeout: Duration.seconds(5) }), + Deferred.succeed(siblingStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishSibling)), + Effect.andThen( + Effect.sync(() => { + siblingCompleted = true; + }), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(siblingStarted); + assert.equal(yield* Queue.take(backend.signals), "SIGTERM"); + const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(5)); + assert.equal(yield* Queue.take(backend.signals), "SIGKILL"); + assert.isUndefined(quitFiber.pollUnsafe()); + assert.isUndefined(unregisterFiber.pollUnsafe()); + assert.isTrue(yield* backend.isRunning); + yield* TestClock.adjust(backend.killWait); + yield* Fiber.join(unregisterFiber); + yield* Deferred.succeed(finishSibling, undefined); + yield* Fiber.join(quitFiber); + + assert.isTrue(siblingCompleted); + assert.isFalse(yield* backend.isRunning); + assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); + assert.isTrue(Option.isNone(yield* pool.get(id))); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend))); + }), + ), + ); + it.effect("unregister stops the registered instance", () => Effect.scoped( Effect.gen(function* () { @@ -99,27 +248,9 @@ describe("DesktopBackendPool", () => { id, label: Effect.succeed("WSL (Ubuntu)"), configResolve: Effect.succeed({ - executablePath: "/electron", - args: [], - entryPath: "/server/bin.mjs", - cwd: "/server", - env: {}, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: 3774, - t3Home: "/tmp/t3", - host: "127.0.0.1", - desktopBootstrapToken: "token", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - bootstrapDelivery: "fd3", - extendEnv: true, - httpBaseUrl: new URL("http://127.0.0.1:3774"), - captureOutput: false, + ...runningConfig, preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), - } satisfies DesktopBackendStartConfig), + }), }); yield* instance.start; assert.equal((yield* instance.snapshot).restartScheduled, true); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index b648fe88bc4a4..cb2ca3ecaa59f 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -77,7 +77,10 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM const normalizedItem: ContextMenuItem = { id: sourceItem.id, - label: sourceItem.label, + label: + typeof sourceItem.detail === "string" && sourceItem.detail.length > 0 + ? `${sourceItem.label} (${sourceItem.detail})` + : sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 0000000000000..82c21512e24d4 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,79 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { assert, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: vi.fn() } })); + +import * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopIpc from "./DesktopIpc.ts"; + +it.effect("answers only app windows' top frames", () => + Effect.gen(function* () { + const mainFrame = {}; + const panelFrame = {}; + const panel = { + webContents: { id: 3, mainFrame: panelFrame }, + } as unknown as Electron.BrowserWindow; + ElectronWindow.markAuxiliaryWindow(panel); + vi.mocked(Electron.BrowserWindow.getAllWindows).mockReturnValue([panel]); + const invoke = new Map(); + const sync = new Map(); + const ipc = yield* DesktopIpc.DesktopIpc.pipe( + Effect.provide( + DesktopIpc.layer({ + removeHandler: (channel) => void invoke.delete(channel), + handle: (channel, listener) => void invoke.set(channel, listener), + removeAllListeners: (channel) => void sync.delete(channel), + on: (channel, listener) => void sync.set(channel, listener), + }).pipe( + Layer.provide( + Layer.mock(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ + webContents: { id: 1, mainFrame }, + } as unknown as Electron.BrowserWindow), + }), + ), + ), + ), + ); + yield* ipc.handle( + DesktopIpc.makeIpcMethod({ + channel: "token", + payload: Schema.Void, + result: Schema.String, + handler: () => Effect.succeed("secret"), + }), + ); + yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") }); + + const senders = { + app: { sender: { id: 1 }, senderFrame: mainFrame }, + panel: { sender: { id: 3 }, senderFrame: panelFrame }, + panelSubframe: { sender: { id: 3 }, senderFrame: {} }, + appSubframe: { sender: { id: 1 }, senderFrame: {} }, + previewGuest: { sender: { id: 2 }, senderFrame: {} }, + }; + const results: Record = {}; + for (const [name, sender] of Object.entries(senders)) { + const event = { ...sender, returnValue: "unset" as unknown }; + sync.get("token-sync")!(event); + results[name] = [ + yield* Effect.promise(() => + Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"), + ), + event.returnValue, + ]; + } + assert.deepEqual(results, { + app: ["secret", "secret"], + panel: ["secret", "secret"], + panelSubframe: ["rejected", undefined], + appSubframe: ["rejected", undefined], + previewGuest: ["rejected", undefined], + }); + }).pipe(Effect.scoped), +); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 71229fe687eef..0eb438abdf1ba 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,14 +1,19 @@ +import * as Electron from "electron"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; + export interface DesktopIpcInvokeEvent { readonly sender: { readonly id: number }; + readonly senderFrame?: unknown; } -export interface DesktopIpcSyncEvent { +export interface DesktopIpcSyncEvent extends Omit { returnValue: unknown; /** * Present for a real Electron sync event. Missing on direct calls, which the @@ -17,6 +22,8 @@ export interface DesktopIpcSyncEvent { readonly sender?: { readonly id: number }; } +export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect; + export type DesktopIpcHandleListener = ( event: DesktopIpcInvokeEvent, raw: unknown, @@ -57,6 +64,15 @@ export class DesktopIpcUnregistrationError extends Schema.TaggedError()( + "DesktopIpcUntrustedSenderError", + { channel: Schema.String }, +) { + override get message(): string { + return `Rejected ${this.channel} from a sender that is not the app window.`; + } +} + export const DesktopIpcError = Schema.Union([ DesktopIpcRegistrationError, DesktopIpcUnregistrationError, @@ -85,7 +101,11 @@ export class DesktopIpc extends Context.Service< } >()("@t3tools/desktop/ipc/DesktopIpc") {} -export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => +// ipcMain is shared by every webContents, including preview guests that load arbitrary pages. +export const make = ( + ipcMain: DesktopIpcMain, + isTrustedSender: DesktopIpcSenderGuard, +): DesktopIpc["Service"] => DesktopIpc.of({ handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, @@ -103,6 +123,9 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => runPromise( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); + if (!(yield* isTrustedSender(event))) { + return yield* new DesktopIpcUntrustedSenderError({ channel }); + } return yield* handler(raw, event); }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), ), @@ -136,6 +159,14 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => event.returnValue = runSync( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); + // Throwing here would surface as an uncaught main-process exception. + if ( + event.sender === undefined || + !(yield* isTrustedSender({ ...event, sender: event.sender })) + ) { + yield* Effect.logWarning("Rejected sync IPC from an untrusted sender."); + return undefined; + } return yield* handler(event); }).pipe( Effect.annotateLogs({ channel }), @@ -157,7 +188,32 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => }), }); -export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); +export const layer = (ipcMain: DesktopIpcMain) => + Layer.effect( + DesktopIpc, + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + return make(ipcMain, (event) => + Effect.map(electronWindow.main, (main) => { + const window = + Option.isSome(main) && main.value.webContents.id === event.sender.id + ? main.value + : Electron.BrowserWindow.getAllWindows().find( + (candidate) => + ElectronWindow.isAuxiliaryWindow(candidate) && + candidate.webContents.id === event.sender.id, + ); + return window !== undefined && window.webContents.mainFrame === event.senderFrame; + }), + ); + }), + ); + +export const layerTest = (ipcMain: DesktopIpcMain) => + Layer.succeed( + DesktopIpc, + make(ipcMain, () => Effect.succeed(true)), + ); export interface DesktopIpcMethodRegistration< Payload, diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 752ed08ccc27d..ff376db4ad15a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -76,6 +76,7 @@ export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-beare export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const SSH_PASSWORD_PROMPT_SETTLED_CHANNEL = "desktop:ssh-password-prompt-settled"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; @@ -100,6 +101,8 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_FIND_IN_PAGE_CHANNEL = "desktop:preview-find-in-page"; +export const PREVIEW_STOP_FIND_IN_PAGE_CHANNEL = "desktop:preview-stop-find-in-page"; export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; @@ -124,6 +127,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; +export const PREVIEW_FIND_RESULT_CHANNEL = "desktop:preview-find-result"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.test.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.test.ts index 814ee4f77b632..fe4a692edf85a 100644 --- a/apps/desktop/src/ipc/methods/legacyLocalStorage.test.ts +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.test.ts @@ -207,14 +207,17 @@ describe("legacy local storage migration", () => { const electronWindow = yield* ElectronWindow.ElectronWindow; yield* electronWindow.setMain(main); const listeners = new Map(); - const ipc = DesktopIpc.make({ - removeHandler: () => undefined, - handle: () => undefined, - removeAllListeners: () => undefined, - on: (channel, listener) => { - listeners.set(channel, listener); + const ipc = DesktopIpc.make( + { + removeHandler: () => undefined, + handle: () => undefined, + removeAllListeners: () => undefined, + on: (channel, listener) => { + listeners.set(channel, listener); + }, }, - }); + () => Effect.succeed(true), + ); yield* Effect.scoped( Effect.gen(function* () { @@ -231,7 +234,7 @@ describe("legacy local storage migration", () => { const missingSender: DesktopIpc.DesktopIpcSyncEvent = { returnValue: "unset" }; listener!(missingSender); - assert.isNull(missingSender.returnValue); + assert.isUndefined(missingSender.returnValue); assert.deepEqual(yield* Ref.get(items), Option.some(payload)); yield* expectRejected( diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index 7126b1cd31945..b3c85c9ead283 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -80,7 +80,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () => Effect.provideService(HostProcess.Platform, "linux"), Effect.provide([ ElectronApp.layer, - DesktopIpc.layer({ + DesktopIpc.layerTest({ handle: (channel, handler) => { handlers.set(channel, handler); }, diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 802f2f3fb18b6..bd7e44bec8307 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -42,14 +42,17 @@ describe("preview IPC methods", () => { Effect.scoped( Effect.gen(function* () { let listener: DesktopIpc.DesktopIpcHandleListener | undefined; - const ipc = DesktopIpc.make({ - removeHandler: vi.fn(), - handle: (_channel, registered) => { - listener = registered; + const ipc = DesktopIpc.make( + { + removeHandler: vi.fn(), + handle: (_channel, registered) => { + listener = registered; + }, + removeAllListeners: vi.fn(), + on: vi.fn(), }, - removeAllListeners: vi.fn(), - on: vi.fn(), - }); + () => Effect.succeed(true), + ); yield* ipc.handle(method); return yield* Effect.promise(async () => structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 98a0aab831645..ea291b66b3149 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,6 +5,7 @@ import { DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewConfigInputSchema, + DesktopPreviewFindInPageInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, DesktopPreviewRecordingSaveInputSchema, @@ -59,6 +60,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeOpenLinks((event) => electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), ); + yield* manager.subscribeFindResults((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, event), + ); }); const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ @@ -116,6 +120,21 @@ const navigate = DesktopIpc.makeIpcMethod({ }), }); +const findInPage = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, + payload: DesktopPreviewFindInPageInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.findInPage")(function* ({ + tabId, + text, + forward, + newSession, + }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.findInPage(tabId, text, { forward, newSession }); + }), +}); + const tabMethod = ( channel: string, name: string, @@ -169,6 +188,11 @@ const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +const stopFindInPage = tabMethod( + IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, + "desktop.ipc.preview.stopFindInPage", + (manager, tabId) => manager.stopFindInPage(tabId), +); const setZoomFactor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, payload: DesktopPreviewSetZoomFactorInputSchema, @@ -452,6 +476,8 @@ export const methods = [ zoomOut, resetZoom, hardReload, + findInPage, + stopFindInPage, setColorScheme, setZoomFactor, setAudioMuted, diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index dd7e1b86f8a82..a2b1dda5a4a53 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -217,11 +217,11 @@ describe("installRelayTelemetryEnabled", () => { yield* installRelayTelemetryEnabled().pipe( Effect.provideService(HostProcess.Environment, env), Effect.provide( - Layer.mergeAll(DesktopIpc.layer(ipcMain), DesktopManagedPolicy.layerTest(policy)), + Layer.mergeAll(DesktopIpc.layerTest(ipcMain), DesktopManagedPolicy.layerTest(policy)), ), ); - const event = { returnValue: undefined as unknown }; - listeners.get(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL)?.(event as never); + const event = { sender: { id: 1 }, returnValue: undefined as unknown }; + listeners.get(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL)?.(event); return event.returnValue; }).pipe(Effect.scoped); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3a125f2095b86..e6df03d221b83 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -142,7 +142,7 @@ const layerElectron = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - DesktopIpc.layer(Electron.ipcMain), + DesktopIpc.layer(Electron.ipcMain).pipe(Layer.provide(ElectronWindow.layer)), ); const layerDesktopFoundation = Layer.mergeAll( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 24087267d6224..3507e9e062f9c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import type { DesktopAppearanceSnapshot, DesktopBridge, DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, @@ -248,6 +249,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); }; }, + onSshPasswordPromptSettled: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, requestId: unknown) => { + if (typeof requestId !== "string") return; + listener(requestId); + }; + + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + }; + }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), @@ -490,6 +502,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + findInPage: (tabId, text, { forward, newSession }) => + ipcRenderer.invoke(IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, { + tabId, + text, + forward, + newSession, + }), + stopFindInPage: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, { tabId }), setShown: (tabId, shown) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => @@ -590,5 +611,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); }, + onFindResult: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, findEvent: unknown) => { + if (typeof findEvent !== "object" || findEvent === null) return; + listener(findEvent as DesktopPreviewFindResultEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index b2a3886f87668..7b8729a11646c 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -34,6 +34,8 @@ const command = ( const makeDebuggee = ( options: { readonly deferFocus?: boolean; + /** The guest is still loading its first page. */ + readonly loading?: boolean; readonly refuse?: (method: string, sessionId: string | undefined) => boolean; } = {}, ) => { @@ -69,10 +71,13 @@ const makeDebuggee = ( sendWaiters.shift()?.(); }), }); + let destroyed = false; const webContents = Object.assign(new NodeEvents.EventEmitter(), { getURL: () => "http://localhost/", getTitle: () => "Page", getUserAgent: () => "Electron", + isLoadingMainFrame: () => options.loading === true, + isDestroyed: () => destroyed, }); return { tab: { @@ -81,6 +86,12 @@ const makeDebuggee = ( }, emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""), navigate: () => webContents.emit("did-navigate"), + domReady: () => webContents.emit("dom-ready"), + destroy: () => { + destroyed = true; + }, + failLoad: (code: number) => + webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true), /** Ids of the commands the debugger received, in order. */ sent, focusEmulation, @@ -144,6 +155,71 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("announces a guest still loading its first page only once that page is up", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const loading = makeDebuggee({ loading: true }); + const other = { ...key, tabId: "tab-2" }; + const reader = yield* takeEvents(host, 3).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.attach(key, loading.tab); + host.setShown(key, true); + host.attach(other, makeDebuggee().tab); + loading.domReady(); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...other }, + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + ]); + }), + ); + + it.effect( + "keeps a loading guest unannounced across a replaced navigation and a new backend", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const loading = makeDebuggee({ loading: true }); + const other = { ...key, tabId: "tab-2" }; + host.attach(key, loading.tab); + loading.failLoad(-3); + const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.attach(other, makeDebuggee().tab); + loading.domReady(); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...other }, + { type: "attached", ...key }, + ]); + }), + ); + + it.effect( + "announces a first page that never comes up, but never a tab closed or destroyed first", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const closed = { ...key, tabId: "tab-2" }; + const destroyed = { ...key, tabId: "tab-3" }; + const destroyedGuest = makeDebuggee({ loading: true }); + const reader = yield* takeEvents(host, 1).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.attach(closed, makeDebuggee({ loading: true }).tab); + host.detach(closed); + host.attach(destroyed, destroyedGuest.tab); + destroyedGuest.destroy(); + host.attach(key, makeDebuggee({ loading: true }).tab); + yield* TestClock.adjust("5 seconds"); + + expect(yield* Fiber.join(reader)).toEqual([{ type: "attached", ...key }]); + }), + ); + it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 80a23417b7163..2168532902e15 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -8,6 +8,7 @@ import { import * as Context from "effect/Context"; import * as NodePath from "node:path"; 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 PubSub from "effect/PubSub"; @@ -26,6 +27,9 @@ const AGENT_INPUT_COMMAND = const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; const FOCUS_IDLE = "5 seconds"; +/** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */ +const FIRST_PAGE_TIMEOUT = "5 seconds"; +const ERR_ABORTED = -3; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -78,6 +82,8 @@ interface AttachedTab { sessionId: string, ) => void; readonly onNewDocument: () => void; + /** Stops waiting for the first page; set until the tab is announced. */ + stopWaiting: (() => void) | null; } export class DesktopBrowserHost extends Context.Service< @@ -413,11 +419,13 @@ export const make = Effect.gen(function* () { const tab = tabs.get(id); if (!tab) return; tabs.delete(id); + const announced = tab.stopWaiting === null; + tab.stopWaiting?.(); tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); syncPainting(tab); - emit({ type: "detached", ...key }); + if (announced) emit({ type: "detached", ...key }); }; const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => { @@ -455,13 +463,53 @@ export const make = Effect.gen(function* () { dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, + stopWaiting: null, }; tabs.set(id, tab); debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); - emit({ type: "attached", ...key }); - const shown = shownTabs.get(id); - if (shown !== undefined) emit({ type: "shown", ...key, shown }); + const announce = () => { + emit({ type: "attached", ...key }); + const shown = shownTabs.get(id); + if (shown !== undefined) emit({ type: "shown", ...key, shown }); + }; + if (!debuggee.webContents.isLoadingMainFrame()) { + announce(); + return; + } + // Electron does not pause a guest for the debugger, so Playwright connecting during its first + // commit builds its utility world in the discarded document and every wait on it hangs. + const { webContents } = debuggee; + // ERR_ABORTED is a newer navigation replacing this one, whose commit is still to come. + const onFailLoad = ( + _event: Electron.Event, + code: number, + _description: string, + _url: string, + isMainFrame: boolean, + ) => { + if (isMainFrame && code !== ERR_ABORTED) ready(); + }; + const ready = () => { + if (tab.stopWaiting === null) return; + // The manager's cleanup for a destroyed guest can still be queued behind another tab's attach. + if (webContents.isDestroyed()) return detach(key); + tab.stopWaiting(); + announce(); + }; + const timeout = runFork( + Effect.sleep(FIRST_PAGE_TIMEOUT).pipe(Effect.andThen(Effect.sync(ready))), + ); + tab.stopWaiting = () => { + tab.stopWaiting = null; + webContents.off("dom-ready", ready); + webContents.off("did-stop-loading", ready); + webContents.off("did-fail-load", onFailLoad); + runFork(Fiber.interrupt(timeout)); + }; + webContents.on("dom-ready", ready); + webContents.on("did-stop-loading", ready); + webContents.on("did-fail-load", onFailLoad); }; const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => { @@ -472,7 +520,7 @@ export const make = Effect.gen(function* () { } if (shownTabs.get(id) === shown) return; shownTabs.set(id, shown); - if (tabs.has(id)) emit({ type: "shown", ...key, shown }); + if (tabs.get(id)?.stopWaiting === null) emit({ type: "shown", ...key, shown }); }; const handleCommandLine = (line: string) => @@ -511,6 +559,8 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { dropRelay(tab); + // Announced once its first page is up. + if (tab.stopWaiting !== null) return Effect.void; const shown = shownTabs.get(keyOf(tab.key)); return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( Effect.andThen( diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index a4fd88c475bca..fbf90a38c4eb9 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,7 @@ export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const PAGE_UNHANDLED_SHORTCUT_CHANNEL = "preview:page-unhandled-shortcut"; export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b4e4b2a8b75ce..79a059d828943 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2,6 +2,7 @@ import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { + DesktopPreviewFindResultEvent, DesktopPreviewRecordingInputEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -812,6 +813,135 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["darwin", true], + ["linux", false], + ["win32", false], + ] as const)( + "lets the page and its iframes claim the find shortcut before the host opens find on %s", + ([platform, usesMeta]) => + withManager( + (manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const send = vi.fn(); + const mainFrame = { processId: 1, routingId: 1 }; + Object.assign(preview.webContents, { + hostWebContents: { isDestroyed: () => false, send }, + mainFrame, + focusedFrame: mainFrame, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_page_find"); + yield* manager.registerWebview("tab_page_find", 42); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + ]); + const beforeInput = preview.listeners.get("before-input-event")!; + const findKey = { + type: "keyDown", + key: "f", + code: "KeyF", + meta: usesMeta, + control: !usesMeta, + }; + const preventDefault = vi.fn(); + + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + + const ipcOn = (preview.webContents as Electron.WebContents).ipc.on as ReturnType< + typeof vi.fn + >; + const pageUnhandledShortcut = ipcOn.mock.calls.find( + ([channel]) => channel === "preview:page-unhandled-shortcut", + )![1]; + pageUnhandledShortcut( + {}, + { + key: "f", + code: "KeyF", + metaKey: usesMeta, + ctrlKey: !usesMeta, + shiftKey: false, + altKey: false, + }, + ); + expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "preview.find"); + + Object.assign(preview.webContents, { focusedFrame: { processId: 1, routingId: 2 } }); + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + + Object.assign(preview.webContents, { focusedFrame: mainFrame }); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("ctrl+alt+8")! }, + ]); + pageUnhandledShortcut( + {}, + { + key: "[", + code: "Digit8", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: true, + }, + ); + expect(send).toHaveBeenCalledTimes(usesMeta ? 2 : 1); + }), + platform, + ), + ); + + effectIt.effect("searches the guest page and reports its match counts", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const findInPage = vi.fn(); + const stopFindInPage = vi.fn(); + Object.assign(preview.webContents, { findInPage, stopFindInPage }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_find"); + yield* manager.registerWebview("tab_find", 42); + const reported = yield* Deferred.make(); + yield* manager.subscribeFindResults((event) => Deferred.succeed(reported, event)); + + yield* manager.findInPage("tab_find", "needle", { forward: true, newSession: true }); + expect(findInPage).toHaveBeenCalledExactlyOnceWith("needle", { + forward: true, + findNext: true, + }); + + preview.listeners.get("found-in-page")!( + {} as never, + { + requestId: 1, + activeMatchOrdinal: 2, + matches: 5, + finalUpdate: true, + } as never, + ); + expect(yield* Deferred.await(reported)).toEqual({ + tabId: "tab_find", + activeMatchOrdinal: 2, + matches: 5, + }); + + yield* manager.stopFindInPage("tab_find"); + expect(stopFindInPage).toHaveBeenCalledExactlyOnceWith("clearSelection"); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index c668c39061e00..14bdd0e1d6ba9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -12,6 +12,7 @@ import type { DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, @@ -80,6 +81,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, @@ -437,6 +439,23 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; +type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effect; + +const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ + "preview.find", +]); + +const decodePageShortcutKeys = Schema.decodeUnknownOption( + Schema.Struct({ + key: Schema.String, + code: Schema.String, + metaKey: Schema.Boolean, + ctrlKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, + }), +); + // about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); @@ -557,6 +576,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); const openLinkListenersRef = yield* Ref.make>(new Set()); + const findResultListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -822,7 +842,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link" | "find-result", tabId: string, delivery: () => Effect.Effect, ) => @@ -1425,6 +1445,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; + // Chromium splits one search across several updates (the ordinal and the + // final count can land separately), so every update is forwarded. + const foundInPage = (_event: unknown, result: Electron.Result): void => { + runFork( + emitFindResult({ + tabId, + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1461,6 +1492,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function syncMenuShortcuts(window.webContents, input); }); }; + const findForwardedShortcut = (keys: Parameters[0]) => + !( + hostPlatform !== "darwin" && + keys.ctrlKey && + keys.altKey && + /^(?:[^a-zA-Z0-9]|Dead)$/u.test(keys.key) + ) && + forwardedShortcuts.find(({ shortcut }) => + matchesKeybindingShortcut( + keys, + shortcut, + hostPlatform === "darwin" ? "MacIntel" : hostPlatform, + ), + ); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { syncMenuShortcuts(wc, input); const host = wc.hostWebContents; @@ -1470,27 +1515,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function host && !host.isDestroyed() && holdsKeyboardFocus(wc) && - !( - hostPlatform !== "darwin" && - input.control && - input.alt && - /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key) - ) && - forwardedShortcuts.find(({ shortcut }) => - matchesKeybindingShortcut( - { - key: input.key, - code: input.code, - metaKey: input.meta, - ctrlKey: input.control, - shiftKey: input.shift, - altKey: input.alt, - }, - shortcut, - hostPlatform === "darwin" ? "MacIntel" : hostPlatform, - ), - ); + findForwardedShortcut({ + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + }); if (forwarded && host) { + // The page keeps these keys. Only the main-frame preload can report one it + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. + if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { if ( @@ -1515,6 +1551,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } }; + const pageUnhandledShortcut = (_event: unknown, rawKeys?: unknown): void => { + const host = wc.hostWebContents; + if (!host || host.isDestroyed() || !holdsKeyboardFocus(wc)) return; + const keys = decodePageShortcutKeys(rawKeys); + if (Option.isNone(keys)) return; + const forwarded = findForwardedShortcut(keys.value); + if (!forwarded || !PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; + host.send(MENU_ACTION_CHANNEL, forwarded.command); + }; yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { @@ -1529,11 +1574,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); + wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), ); @@ -1550,9 +1597,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); + wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { const action = previewWindowOpenAction(details); @@ -2052,6 +2101,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); const hardReload = (tabId: string) => withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + const findInPage = ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => + withWebContents("findInPage", tabId, (wc) => { + // Electron's `findNext` means "begin a new session", not "go to the next match". + wc.findInPage(text, { forward: options.forward, findNext: options.newSession }); + }); + const stopFindInPage = (tabId: string) => + withWebContents("stopFindInPage", tabId, (wc) => wc.stopFindInPage("clearSelection")).pipe( + Effect.catchTags({ + PreviewTabNotFoundError: () => Effect.void, + PreviewWebContentsNotFoundError: () => Effect.void, + }), + ); const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); @@ -3345,6 +3410,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); + const emitFindResult = Effect.fn("PreviewManager.emitFindResult")(function* ( + event: DesktopPreviewFindResultEvent, + ) { + const listeners = yield* Ref.get(findResultListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("find-result", event.tabId, () => listener(event)), + { discard: true }, + ); + }); const subscribe = ( ref: Ref.Ref>, listener: A, @@ -3367,6 +3442,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), + Ref.set(findResultListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3381,6 +3457,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function closeTab, copyArtifactToClipboard, createTab, + findInPage, goBack, goForward, hardReload, @@ -3410,10 +3487,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), startRecording, closePictureInPicture, + stopFindInPage, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), + subscribeFindResults: (listener: FindResultListener) => + subscribe(findResultListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -3595,6 +3675,12 @@ export class PreviewManager extends Context.Service< readonly resetZoom: (tabId: string) => Effect.Effect; readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly findInPage: ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => Effect.Effect; + readonly stopFindInPage: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, colorScheme: DesktopPreviewColorScheme, @@ -3659,6 +3745,9 @@ export class PreviewManager extends Context.Service< readonly subscribeOpenLinks: ( listener: OpenLinkListener, ) => Effect.Effect; + readonly subscribeFindResults: ( + listener: FindResultListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -3760,6 +3849,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { resetZoom: operations.resetZoom, reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, + findInPage: operations.findInPage, + stopFindInPage: operations.stopFindInPage, setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, @@ -3808,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, + subscribeFindResults: operations.subscribeFindResults, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index dcb16b851dc23..1d8c503d7edf8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -33,6 +33,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, RECORDING_KEY_CHANNEL, @@ -204,6 +205,26 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Capture runs before a page can stop the event; the task delay lets every page +// listener call preventDefault before the host is told the key went unhandled. +const reportUnhandledShortcut = (event: KeyboardEvent): void => { + if (!event.isTrusted || event.repeat || event.isComposing) return; + if (!event.metaKey && !event.ctrlKey && !event.altKey && !/^F\d{1,2}$/u.test(event.key)) return; + setTimeout(() => { + if (event.defaultPrevented) return; + ipcRenderer.send(PAGE_UNHANDLED_SHORTCUT_CHANNEL, { + key: event.key, + code: event.code, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }); + }, 0); +}; + +window.addEventListener("keydown", reportUnhandledShortcut, true); + const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index c0f8210f18146..1e5b6dafc6cae 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -8,7 +8,10 @@ import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -144,6 +147,52 @@ describe("DesktopSshPasswordPrompts", () => { }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); + it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( + "tells the window a prompt ended when it is %s", + (outcome) => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.exit, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const request = testWindow.sentMessages[0]?.args[0] as { readonly requestId: string }; + assert.ok(request); + switch (outcome) { + case "answered": + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + break; + case "cancelled": + yield* prompts.resolve({ requestId: request.requestId, password: null }); + break; + case "timed out": + yield* TestClock.adjust("1 second"); + break; + case "interrupted": + yield* Fiber.interrupt(fiber); + break; + } + yield* Fiber.await(fiber); + + assert.deepEqual( + testWindow.sentMessages + .filter((message) => message.channel === SSH_PASSWORD_PROMPT_SETTLED_CHANNEL) + .map((message) => message.args), + [[request.requestId]], + ); + }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }, + ); + it.effect("keeps a submitted password when a later presentation step fails", () => { const testWindow = makeTestWindow({ isMinimizedError: new Error("failed to read minimized state"), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 3c16b938331ca..5fccc78d9a918 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -13,7 +13,10 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; @@ -38,6 +41,7 @@ const DesktopSshPromptPresentationOperation = Schema.Literals([ "check-window-after-restore", "focus-window", "remove-window-close-listener", + "send-prompt-settled", ]); type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; @@ -371,11 +375,22 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( cause, }), }); + // However the request ends, the window drops its prompt, so no ended prompt stays on screen. + const notifySettled = runPresentationOperation("send-prompt-settled", () => { + if (!window.value.isDestroyed()) { + window.value.webContents.send(SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, requestId); + } + }).pipe(Effect.ignore); const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe( + Effect.orDie, + Effect.ensuring(removePending(pendingRef, requestId)), + Effect.ensuring(notifySettled), + Effect.asVoid, + ); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..604fbeda3c9f1 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,5 +1,8 @@ +import * as NodeModule from "node:module"; + import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; +import { computeReleaseNotes } from "electron-updater/out/providers/GitHubProvider.js"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -22,6 +25,57 @@ import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +type ReleaseFeed = Parameters[2]; +type RunningVersion = Parameters[0]; + +// electron-updater's own dependencies, which apps/desktop does not declare. +const requireFromUpdater = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("electron-updater"), +); +const { parseXml } = requireFromUpdater("builder-util-runtime") as { + parseXml: (xml: string) => ReleaseFeed; +}; +const semver = requireFromUpdater("semver") as { parse: (version: string) => RunningVersion }; + +const releaseTitle = (version: string) => + version.includes("-nightly.") + ? `T3 Code Nightly ${version} (0123456789ab)` + : `T3 Code v${version}`; + +const releaseFeed = (...versions: ReadonlyArray) => + parseXml( + `${versions + .map( + (version) => + `${releaseTitle(version)}<ul><li>${version} change</li></ul>`, + ) + .join("")}`, + ); + +/** The update info electron-updater's GitHub provider reports when /releases/latest names `version`. */ +const stableUpdateInfo = ( + feed: ReleaseFeed, + version: string, + runningVersion: string, + fullChangelog: boolean, +) => { + const latestRelease = + feed + .getElements("entry") + .find((entry) => entry.element("link").attribute("href").endsWith(`/tag/v${version}`)) ?? + feed.element("entry"); + return { + version, + releaseName: latestRelease.elementValueOrEmpty("title"), + releaseNotes: computeReleaseNotes( + semver.parse(runningVersion), + fullChangelog, + feed, + latestRelease, + ), + }; +}; + describe("DesktopUpdates", () => { it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { @@ -109,6 +163,51 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect.each([ + { + name: "shows no notes for a Stable release missing from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"], + expected: [], + }, + { + name: "shows a Stable release's own notes from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + { + name: "shows a Stable release's notes when switching down from a newer Nightly", + runningVersion: "1.2.5-nightly.20261027.1", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + ])("$name", ({ runningVersion, feedVersions, expected }) => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit( + "update-available", + stableUpdateInfo( + releaseFeed(...feedVersions), + "1.2.4", + runningVersion, + harness.fullChangelog(), + ), + ); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.availableVersion, "1.2.4"); + assert.deepEqual(state.releaseNotes, expected); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("preserves a queued installer when the feed has no update", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 14688e5258067..36492ca7b9063 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -36,7 +36,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; -import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; +import { + normalizeDesktopUpdateReleaseNotes, + releaseNotesOfOfferedVersion, +} from "./releaseNotes.ts"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -72,6 +75,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseName: Schema.optional(Schema.NullOr(Schema.String)), releaseDate: Schema.optional(Schema.String), }); @@ -939,7 +943,7 @@ export const make = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( - info.releaseNotes, + releaseNotesOfOfferedVersion(info), info.version, state.channel, info.releaseDate, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index a319898222092..715c1767e6a04 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -171,6 +171,22 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * Untagged notes come from the feed entry named by releaseName, which is the newest entry when the + * offered release is not on the feed's first page. Keep them only when that entry is the offered one. + */ +export function releaseNotesOfOfferedVersion(info: { + readonly version: string; + readonly releaseNotes?: unknown; + readonly releaseName?: string | null | undefined; +}): unknown { + if (typeof info.releaseNotes !== "string" || !info.releaseName) return info.releaseNotes; + const namesVersion = new RegExp(`(?:^|[^\\w.-])v?${escapeRegExp(info.version)}(?![\\w.-])`); + return namesVersion.test(info.releaseName) ? info.releaseNotes : undefined; +} + /** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c1919a11e3938..4fdf6070e48e1 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -701,6 +701,55 @@ describe("DesktopWindow", () => { }), ); + it.effect("ignores input that arrives after the window is destroyed", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); + const inputEvent = fakeWindow.webContentsListeners.get("input-event"); + if (!beforeInput || !inputEvent) { + return yield* Effect.die("input listeners were not registered"); + } + + // Electron throws on any member access of a destroyed BrowserWindow. + fakeWindow.isDestroyed.mockReturnValue(true); + Object.defineProperty(fakeWindow.window, "webContents", { + get: () => { + throw new TypeError("Object has been destroyed"); + }, + }); + + // A held Cmd+Q quits from the grace timer, so the trailing Q keyUp lands here. + assert.doesNotThrow(() => + beforeInput( + { preventDefault: () => {} }, + { + type: "keyUp", + isAutoRepeat: false, + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + }, + ), + ); + assert.doesNotThrow(() => inputEvent({}, { type: "gestureScrollEnd" })); + }).pipe(Effect.provide(layer)); + }), + ); + // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index ea5505b562eb0..2e0b7a2ec64b4 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -710,6 +710,7 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + if (window.isDestroyed()) return; // Cmd/Ctrl+W reaches the renderer; DevTools uses the native menu shortcut. window.webContents.setIgnoreMenuShortcuts( (environment.platform === "darwin" ? input.meta : input.control) && @@ -725,6 +726,7 @@ export const make = Effect.gen(function* () { } }); window.webContents.on("input-event", (_event, input) => { + if (window.isDestroyed()) return; if (input.type === "gestureScrollBegin") { window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); } else if (input.type === "gestureScrollEnd") { @@ -910,6 +912,8 @@ export const make = Effect.gen(function* () { void runPromise(revealWhenReady); }); + // Desktop IPC only answers the main window, so register it before its renderer can call in. + yield* electronWindow.setMain(window); loadApplication(); if (environment.isDevelopment) { window.webContents.openDevTools({ mode: "detach" }); @@ -928,7 +932,6 @@ export const make = Effect.gen(function* () { const createMain = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { - yield* electronWindow.setMain(window.value); mainClosedDuringStartup = false; yield* logWindowInfo("main window created"); } diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 730b9e1a97f34..7a4cd4eff5046 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -5,6 +5,10 @@ /* ─── Theme tokens ──────────────────────────────────────────────────── */ @layer theme { :root { + /* Native text takes one family name, not Tailwind's font stack. */ + @variant ios { + --font-mono: "ui-monospace"; + } @variant android { --font-mono: "monospace"; } diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx index 369fcf19ef55a..a17e48c2604c7 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx @@ -6,7 +6,10 @@ import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { useHardwareBack } from "../../lib/backHandler"; -import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; +import { + deriveMenuHorizontalPlacement, + deriveMenuVerticalPlacement, +} from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; @@ -41,10 +44,9 @@ export type AndroidAnchoredMenuProps = { }; export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { - const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); + const { scale, menuWidth: desiredMenuWidth, menuItemHeight } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); - const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); const windowHinge = use(WindowHingeContext); // State, not a ref: `open` reaches render-prop children, and the compiler @@ -58,7 +60,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { setAnchor(null); setPath([]); setOverlay(null); - setRootHeight(null); }; const open = () => { @@ -70,7 +71,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); - setRootHeight(height); }); }; @@ -110,17 +110,19 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { margin: SCREEN_MARGIN, hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width }, }); - // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. - const usableBottom = - overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); - const spaceBelow = + const verticalPlacement = local === null || overlay === null - ? 0 - : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN; - const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN; - const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; - const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); - const placeable = local !== null && rootHeight !== null; + ? null + : deriveMenuVerticalPlacement({ + anchorY: local.y, + anchorHeight: local.height, + overlayHeight: overlay.height, + // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. + keyboardInset: keyboardVisible ? keyboardHeight : 0, + gap: ANCHOR_GAP, + margin: SCREEN_MARGIN, + minHeight: 2 * menuItemHeight + 14 * scale, + }); const onPressItem = (action: MenuAction) => { if ((action.subactions?.length ?? 0) > 0) { @@ -167,7 +169,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onLayout={measureOverlay} > - {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( + {local === null ? null : !anchor.keyboardWasVisible ? ( {/* Compose DropdownMenu takes focus; in-window editor menus preserve the keyboard. */} diff --git a/apps/mobile/src/components/ui/AppSymbol.android.tsx b/apps/mobile/src/components/ui/AppSymbol.android.tsx index aae5db34beeba..b9230d2982c22 100644 --- a/apps/mobile/src/components/ui/AppSymbol.android.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.android.tsx @@ -65,6 +65,7 @@ import IconFileText from "@tabler/icons-react-native/IconFileText"; import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; import IconFilter from "@tabler/icons-react-native/IconFilter"; import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; +import IconFlask from "@tabler/icons-react-native/IconFlask"; import IconFolder from "@tabler/icons-react-native/IconFolder"; import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; import IconHeadphones from "@tabler/icons-react-native/IconHeadphones"; @@ -91,6 +92,7 @@ import IconListNumbers from "@tabler/icons-react-native/IconListNumbers"; import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion"; import IconMessage from "@tabler/icons-react-native/IconMessage"; +import IconMessageDots from "@tabler/icons-react-native/IconMessageDots"; import IconMessagePlus from "@tabler/icons-react-native/IconMessagePlus"; import IconMinus from "@tabler/icons-react-native/IconMinus"; import IconMoon from "@tabler/icons-react-native/IconMoon"; @@ -107,11 +109,11 @@ import IconPlayerStopFilled from "@tabler/icons-react-native/IconPlayerStopFille import IconPlus from "@tabler/icons-react-native/IconPlus"; import IconQrcode from "@tabler/icons-react-native/IconQrcode"; import IconRefresh from "@tabler/icons-react-native/IconRefresh"; +import IconRobot from "@tabler/icons-react-native/IconRobot"; import IconSearch from "@tabler/icons-react-native/IconSearch"; import IconShare from "@tabler/icons-react-native/IconShare"; import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; -import IconSparkles from "@tabler/icons-react-native/IconSparkles"; import IconStack2 from "@tabler/icons-react-native/IconStack2"; import IconStar from "@tabler/icons-react-native/IconStar"; import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; @@ -211,6 +213,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "exclamationmark.circle": IconAlertCircle, eye: IconEye, "eye.slash": IconEyeOff, + flask: IconFlask, folder: IconFolder, "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, @@ -277,8 +280,9 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "exclamationmark.shield": IconShieldQuestion, "bubble.left.and.bubble.right": IconMessage, "bubble.left": IconMessage, - sparkles: IconSparkles, "plus.bubble": IconMessagePlus, + "ellipsis.bubble": IconMessageDots, + robot: IconRobot, "text.word.spacing": IconLetterSpacing, "textformat.size": IconTypography, "textformat.size.larger": IconTextIncrease, @@ -288,23 +292,33 @@ const ANDROID_ICON_BY_SF_SYMBOL = { trash: IconTrash, wifi: IconWifi, "wifi.slash": IconWifiOff, + "wrench.and.screwdriver": IconTool, xmark: IconX, "xmark.circle.fill": IconCircleXFilled, } satisfies Partial< Record< - SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", + | SFSymbol + | "git.pull-request.closed" + | "git.pull-request.draft" + | "git.commit" + | "file.diff" + | "robot", Icon > >; const SF_ICON_LOOKUP: Partial< Record< - SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", + | SFSymbol + | "git.pull-request.closed" + | "git.pull-request.draft" + | "git.commit" + | "file.diff" + | "robot", Icon > > = ANDROID_ICON_BY_SF_SYMBOL; const ANDROID_ICON_BY_MATERIAL_NAME = { - auto_awesome: IconSparkles, bolt: IconBolt, build: IconTool, chat: IconMessage, diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 9e4915890e75b..146a9ccb305e7 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -6,6 +6,7 @@ import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullReque import type { SFSymbol } from "expo-symbols"; import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; +import IconRobot from "@tabler/icons-react-native/IconRobot"; import { SymbolView as ExpoSymbolView } from "expo-symbols"; import { withUniwind } from "uniwind"; import type { AppSymbolViewProps } from "./AppSymbol.android"; @@ -22,6 +23,7 @@ const CUSTOM_ICONS = { "git.pull-request.closed": IconGitPullRequestClosed, "git.pull-request.draft": IconGitPullRequestDraft, "git.commit": IconGitCommit, + robot: IconRobot, }; function AppSymbolView(props: AppSymbolViewProps) { diff --git a/apps/mobile/src/components/ui/FilePreviewSession.test.tsx b/apps/mobile/src/components/ui/FilePreviewSession.test.tsx index 07b75a58c1f76..a5b51aa5cf4b3 100644 --- a/apps/mobile/src/components/ui/FilePreviewSession.test.tsx +++ b/apps/mobile/src/components/ui/FilePreviewSession.test.tsx @@ -1,10 +1,16 @@ import { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; -import { EnvironmentId, SOURCE_CONTROL_MEDIA_ASSET } from "@t3tools/contracts"; +import { + AssetWorkspaceAssetNotFoundError, + EnvironmentId, + SOURCE_CONTROL_MEDIA_ASSET, + ThreadId, +} from "@t3tools/contracts"; const harness = vi.hoisted(() => ({ - refreshAssetUrl: vi.fn<(environmentId: string, resource: { url: string }) => Promise>(), + refreshAssetUrl: + vi.fn<(environmentId: string, resource: { url: string }) => Promise>(), alert: vi.fn(), previews: [] as unknown[], mounts: 0, @@ -144,3 +150,24 @@ it("closes the preview when the selected image cannot be resolved", async () => expect(preview.onRequestClose).toHaveBeenCalledOnce(); expect(harness.alert).toHaveBeenCalledOnce(); }); + +it("names the checked path when the server cannot find the file", async () => { + const resource = { + _tag: "media-file" as const, + threadId: ThreadId.make("thread"), + path: "v2/extended/duo-color-3-extended-FINAL.png", + }; + const checkedPath = "/repo/v2/extended/duo-color-3-extended-FINAL.png"; + harness.refreshAssetUrl.mockRejectedValue( + new AssetWorkspaceAssetNotFoundError({ resource, checkedPath }), + ); + const selected: FilePreviewItem = { kind: "image", environmentId, resource }; + await using preview = await openGallery(selected, [selected], false); + + expect(preview.onRequestClose).toHaveBeenCalledOnce(); + expect(harness.alert).toHaveBeenCalledWith( + "Could not open preview", + expect.stringContaining(checkedPath), + ); + expect(harness.alert.mock.calls[0]?.[1]).not.toMatch(/reconnect/i); +}); diff --git a/apps/mobile/src/components/ui/FilePreviewSession.tsx b/apps/mobile/src/components/ui/FilePreviewSession.tsx index 53c09b4d01392..5df8acf001e03 100644 --- a/apps/mobile/src/components/ui/FilePreviewSession.tsx +++ b/apps/mobile/src/components/ui/FilePreviewSession.tsx @@ -1,3 +1,4 @@ +import { AssetWorkspaceAssetNotFoundError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Dialog from "./Dialog"; import { useContext, useEffect, useEffectEvent, useState, type ComponentType } from "react"; @@ -36,6 +37,15 @@ class PreviewResolutionError extends Schema.TaggedError( } const isPreviewResolutionError = Schema.is(PreviewResolutionError); +const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); + +function remotePreviewFailureMessage(error: unknown): string { + if (!isAssetNotFoundError(error)) { + return "Reconnect to this environment and try again. The file may have been moved or deleted."; + } + const path = error.checkedPath ?? ("path" in error.resource ? error.resource.path : undefined); + return path ? `The file was not found at ${path}.` : error.message; +} interface ResolvedPreviewUri { readonly uri: string; @@ -64,8 +74,7 @@ async function resolvePreviewUri( .catch((error: unknown) => { throw new PreviewResolutionError({ cause: error, - fallbackMessage: - "Reconnect to this environment and try again. The file may have been moved or deleted.", + fallbackMessage: remotePreviewFailureMessage(error), }); }); return { uri: url + (item.srcFragment ?? "") }; diff --git a/apps/mobile/src/components/ui/ListRow.tsx b/apps/mobile/src/components/ui/ListRow.tsx index de8bba51beb35..8f2ce11259bc9 100644 --- a/apps/mobile/src/components/ui/ListRow.tsx +++ b/apps/mobile/src/components/ui/ListRow.tsx @@ -1,17 +1,27 @@ -import { Platform } from "react-native"; +import { Platform, View } from "react-native"; import { useResolveClassNames } from "uniwind"; import { SymbolView } from "./AppSymbol"; import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout"; +import { RadioIndicator } from "./RadioIndicator"; import { RowPressable } from "./RowPressable"; -export function ListRow(props: ListRowContentProps & { readonly onPress: () => void }) { +export function ListRow( + props: ListRowContentProps & { + readonly onPress: () => void; + /** Marks the row as the chosen option while it still navigates. */ + readonly selected?: boolean; + }, +) { const rippleStyle = useResolveClassNames("bg-subtle-strong"); return ( v + + {props.selected === undefined ? null : ( + + {Platform.OS === "android" ? ( + + ) : props.selected ? ( + + ) : null} + + )} + + } /> diff --git a/apps/mobile/src/components/ui/VideoPreviewModal.tsx b/apps/mobile/src/components/ui/VideoPreviewModal.tsx index 89158669bbeed..89701afe6fba1 100644 --- a/apps/mobile/src/components/ui/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/ui/VideoPreviewModal.tsx @@ -50,7 +50,7 @@ function NativeVideoPreview(props: { const resolvePlaybackUrl = useEffectEvent(async (): Promise => { if (source.type !== "media") throw new Error("Could not load this video."); if (!("resource" in source)) return source.uri; - const url = await refreshAssetUrl(source.environmentId, source.resource); + const url = await refreshAssetUrl(source.environmentId, source.resource).catch(() => null); const playbackUrl = mediaVideoPreviewUri(source, url); if (playbackUrl !== null) return playbackUrl; throw new Error( @@ -61,7 +61,7 @@ function NativeVideoPreview(props: { }); const reauthorize = useEffectEvent(() => { if (source.type === "media" && "resource" in source) { - void refreshAssetUrl(source.environmentId, source.resource); + void refreshAssetUrl(source.environmentId, source.resource).catch(() => {}); } }); diff --git a/apps/mobile/src/components/ui/useBarInset.ts b/apps/mobile/src/components/ui/useBarInset.ts index 2292f29412546..f00b64c8e0b70 100644 --- a/apps/mobile/src/components/ui/useBarInset.ts +++ b/apps/mobile/src/components/ui/useBarInset.ts @@ -38,9 +38,9 @@ export function useBarOverlap(): number { } /** - * The content's top safe area under the AppBar: a translucent bar's height, zero under a solid - * one or on Android, where the bar sits in flow. For scroll content insets, which must not - * double-count a solid bar. + * The content's top safe area under the AppBar: the height of the iOS bar its column lays content + * under, glass or solid, and zero on Android, where the bar sits in flow. For manual scroll + * content insets. */ export function useContentTopInset(): number { const insets = useSafeAreaInsets(); diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index a4da7ee42bfbf..279269b8a13c3 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -215,6 +215,7 @@ export function FileTreeBrowser(props: { return ( + // Material drags target the first scroll view, so place content before the header. + + {fileTree} - {fileTree} ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 678933b1c3fc1..708cb568c1c2c 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -19,7 +19,7 @@ import { useThreadListPages } from "../threads/use-thread-list-pages"; import { useThreadListOrganisations } from "./use-thread-list-organisations"; import { useThreadListFilterFacets } from "./use-thread-list-filter-facets"; import { environmentThreadShells } from "../../state/threads"; -import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; +import { useLaunchingThreads, usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { @@ -51,9 +51,8 @@ export function HomeRouteScreen() { const projects = useProjects(); // Stop rebuilding the streaming thread list while Thread covers Home. const visible = useHomeRouteVisible(); - const threads = useAtomValueWhileVisible( - environmentThreadShells.navigationThreadShellsAtom, - visible, + const threads = useLaunchingThreads( + useAtomValueWhileVisible(environmentThreadShells.navigationThreadShellsAtom, visible), ); const { hiddenTabThreads, groupNames: tabGroupNames } = useHiddenTabThreads(threads); const visibleThreads = (() => diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 92d6be2d3309e..19ffe072fb58e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -362,8 +362,8 @@ function AdaptiveWorkspaceLayoutContent( floatingTabs.row, floatingTabsEnabled, ]); - // A floating sidebar brings the rail with it. - const width = viewportWidth - (sidebarFloats ? 0 : navigationRailWidth); + // The rail keeps its room even while the sidebar floats; only the list pane overlays the page. + const width = viewportWidth - navigationRailWidth; // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( @@ -721,7 +721,7 @@ function AdaptiveWorkspaceLayoutContent( // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content // until it is gone. const contentLeftTarget = sidebarFloats - ? 0 + ? navigationRailWidth : navigationRailWidth + (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) @@ -794,7 +794,9 @@ function AdaptiveWorkspaceLayoutContent( workspacePage, ]); const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); - const floatingSidebarAnimatedStyle = useAnimatedStyle(() => ({ left: renderedRailWidth.value })); + const floatingSidebarAnimatedStyle = useAnimatedStyle(() => ({ + left: sidebarFloats ? renderedRailWidth.value : 0, + })); const floatingSidebarOpen = sidebarFloats && panes.primarySidebarVisible; // Its own value, so a sidebar that was beside a list doesn't dim the page as it leaves. const scrimOpacity = useSharedValue(0); @@ -850,7 +852,7 @@ function AdaptiveWorkspaceLayoutContent( Math.max( 0, viewportWidth - - (sidebarFloats ? 0 : renderedRailWidth.value) - + renderedRailWidth.value - (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) - renderedInspectorWidth.value, ), @@ -974,10 +976,7 @@ function AdaptiveWorkspaceLayoutContent( > {shouldRenderNavigationRail ? ( - + ) : null} {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( diff --git a/apps/mobile/src/features/layout/inspector-host.android.tsx b/apps/mobile/src/features/layout/inspector-host.android.tsx index 9537811916f07..be1f400d4d935 100644 --- a/apps/mobile/src/features/layout/inspector-host.android.tsx +++ b/apps/mobile/src/features/layout/inspector-host.android.tsx @@ -53,7 +53,8 @@ export function InspectorPanes(props: { position: "absolute", inset: 0, opacity: visible ? 1 : 0, - zIndex: visible ? 1 : 0, + // A lower zIndex puts the visible pane first in native order, where Material finds its scroll view. + zIndex: visible ? 0 : 1, }} > diff --git a/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts b/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts index ae7048bcdba04..52b8a3407158e 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts @@ -2,12 +2,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; -/** A workspace list scrolls under its bar where the bar is glass; under a solid bar it starts below. */ -export const WORKSPACE_LIST_SCROLL_INSETS = { - automaticallyAdjustsScrollIndicatorInsets: NATIVE_LIQUID_GLASS_SUPPORTED, - contentInsetAdjustmentBehavior: NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never", -} as const; - /** How far an empty workspace list keeps its message below a glass bar and its search. */ export function useWorkspaceListEmptyTopInset(): number { const insets = useSafeAreaInsets(); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 93680aa38159e..c41e1e20ba678 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1424,12 +1424,11 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - const projectId = ProjectId.make(uuidv4()); const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId, input: { - projectId, + projectId: ProjectId.make(uuidv4()), title, createdAt: new Date().toISOString(), remoteUrl, @@ -1440,6 +1439,7 @@ export function AddProjectDestinationScreen(props: { setError(errorMessage(Cause.squash(startResult.cause))); } else { void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl); + const { projectId } = startResult.value; // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. const project = await waitForProject( { environmentId: environment.environmentId, projectId }, diff --git a/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx index a8e8e86176fc6..0ab365fa52ad9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestAgentRow.tsx @@ -17,7 +17,7 @@ export function PullRequestAgentRow(props: { const row = ( @@ -237,7 +237,7 @@ export function PullRequestActionToolbar(props: { const agentControl = showAgentControls ? ( thread.id === params.discussionId); + const thread = + activity.data && + pullRequestConversationThreads(activity.data).find( + (thread) => thread.id === params.discussionId, + ); + const filePath = thread?.path ?? null; const [body, setBody] = useState(""); const completions = usePullRequestCompletions({ environmentId: params.environmentId, @@ -138,11 +148,27 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss [...extra, ...(thread?.comments ?? [])].map((comment) => [comment.id, comment]), ).values(), ].sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + const scrollRef = useRef(null); + const pendingTop = useRef(null); + const positionedComment = useRef(null); + const location = parsePullRequestAnchor(params.navigationUrl); + const anchor = + location?.kind === "timeline" + ? resolvePullRequestReviewAnchor(location.anchor, [], [{ comments }]) + : null; const canReply = permissions.canReply && Boolean(thread); const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; return ( - + { + pendingTop.current = null; + }} + > { + if (pendingTop.current === null) return; + scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false }); + pendingTop.current = null; + }} className="flex-1" contentInsetAdjustmentBehavior="automatic" keyboardShouldPersistTaps="handled" @@ -173,26 +205,48 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss {activity.isPending && !thread ? : null} {thread ? ( - - {thread.path} - {thread.line ? `:${thread.line}` : ""} - + {thread.path === null ? null : ( + + {thread.path} + {thread.line ? `:${thread.line}` : ""} + + )} {thread.isResolved ? "Resolved" : "Unresolved"} {thread.isOutdated ? " · Outdated" : ""} - + ))} +

+ ), +})); +vi.mock("../../state/entities", () => ({ + useLiveThreadLinkLabels: (markdown: string) => markdown, +})); +vi.mock("../../native/useOpenPullRequest", () => ({ + useOpenPullRequest: () => async (url: string) => { + harness.openedUrls.push(url); + return true; + }, +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ + navigate: (name: string, params?: object) => harness.navigate.push([name, params]), + }), +})); + +import { PendingUserInputCard } from "./PendingUserInputCard"; + +function setup(questionMarkdown: string) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + harness.navigate = []; + harness.openedUrls = []; + + const pendingUserInput = { + requestId: "request-1" as RuntimeRequestId, + responseCapability: "resumable", + questions: [ + { + id: "question-1", + header: "Review", + question: questionMarkdown, + options: [], + allowCustomAnswer: false, + }, + ], + } as unknown as PendingUserInput; + const container = document.createElement("div"); + const root = createRoot(container); + act(() => + root.render( + {} } as never} + cardCoverage={{ value: 0, set: () => {} } as never} + collapsed={false} + onToggleCollapsed={() => {}} + drafts={{}} + answers={null} + respondingUserInputId={null} + onSelectOption={() => {}} + onChangeCustomAnswer={() => {}} + onSubmit={async () => {}} + onDismiss={async () => {}} + />, + ), + ); + return { + async pressLink(label: string) { + const link = [...container.querySelectorAll("button")].find( + (button) => button.textContent === label, + ); + if (!link) throw new Error(`No link labelled ${label}`); + await act(async () => link.click()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + }, + }; +} + +it("opens a workspace file link in a question in the thread's file viewer", async () => { + using screen = setup("Should I update [probe.md](docs/probe.md:12)?"); + + await screen.pressLink("probe.md"); + + expect(harness.openedUrls).toEqual([]); + expect(harness.navigate).toEqual([ + [ + "ThreadFile", + { environmentId: "env-1", threadId: "thread-1", path: ["docs", "probe.md"], line: "12" }, + ], + ]); +}); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 3a2127ea2931c..70e5ac5c9b07a 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,12 +1,13 @@ import { Button } from "../../components/ui/Button"; import { QuestionAttachments } from "./QuestionAttachments"; -import type { EnvironmentId, RuntimeRequestId } from "@t3tools/contracts"; +import type { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; import { markdownProseDirection, textDirection, type TextDirection, } from "@t3tools/shared/textDirection"; +import { useNavigation } from "@react-navigation/native"; import { useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { @@ -20,9 +21,15 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { ControlPill } from "../../components/ui/ControlPill"; import { cn } from "../../lib/cn"; +import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; +import { + fileRoutePathSegments, + resolveFileLinkPath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { isPendingUserInputOptionSelected, @@ -32,6 +39,8 @@ import type { PendingUserInput } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null; readonly canOperateThread: boolean; readonly pendingUserInput: PendingUserInput; readonly maxHeight: DerivedValue; @@ -69,11 +78,31 @@ function proseDirectionStyle(text: string, setDirection: TextDirection | undefin function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null; readonly setDirection: TextDirection | undefined; }) { const styles = useMarkdownPreviewStyles(); const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); - const openLink = useOpenPullRequest(); + const navigation = useNavigation(); + const openPullRequest = useOpenPullRequest(); + const openLink = (href: string) => { + const link = resolveMarkdownLinkPresentation(href); + if (link.kind !== "file") { + void openPullRequest(href); + return; + } + const path = resolveFileLinkPath(props.workspaceRoot, link.path); + if (path === null) return; + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments( + resolveWorkspaceRelativeFilePath(props.workspaceRoot, path) ?? path, + ), + ...(link.line ? { line: String(link.line) } : {}), + }); + }; return ( void openLink(href)} + onLinkPress={openLink} /> ); } @@ -239,6 +268,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4a79111747c85..1bfc4ea20d812 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1519,7 +1519,6 @@ function ThreadDetailBody( keyboardOffset={composerKeyboardOffset} sendingWithKeyboard={sendingWithKeyboard} fitShortFeedToViewport={questionInFlow && !isKeyboardVisible} - contentTopInset={0} contentBottomInset={ questionInFlow ? 0 @@ -1804,6 +1803,8 @@ function ThreadDetailBody( {props.activePendingUserInput ? ( - + - - - @@ -1742,6 +1735,19 @@ function renderFeedEntry( ); } + if (entry.type === "viewed-images") { + const thumbnailWidth = Math.floor((props.contentWidth - VIEWED_IMAGE_GAP) / 2); + return ( + + {entry.paths.map((path) => ( + + {props.renderViewedImage({ href: path, alt: null, title: null })} + + ))} + + ); + } + if (entry.type === "mcp-app") { return ( reference.contextId), ); @@ -2841,10 +2850,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const barInset = useBarInset(); - const topContentInset = props.contentTopInset ?? barInset; + const columnTopInset = useContentTopInset(); const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; + // Without the native contentInset, a list-header spacer clears the bar the column lays content under. + const topContentInset = usesNativeAutomaticInsets ? 0 : columnTopInset; const anchorTopInset = usesNativeAutomaticInsets ? barInset : topContentInset; const theme = useUniwindTheme(); @@ -2963,17 +2974,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.workspaceRoot && relativePath && !/[\\/]$/.test(presentation.path) && - needsWorkspaceBasenameLookup(relativePath) && + 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: props.workspaceRoot, - query: relativePath, + cwd: workspaceRoot, + query: workspaceLinkLookupQuery(relativePath, workspaceRoot) ?? relativePath, kind: "file", limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, }, @@ -2983,7 +2995,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (result) => { const match = result._tag === "Success" - ? pickWorkspaceBasenameMatch(relativePath, result.value.entries) + ? pickWorkspaceLinkMatch(relativePath, workspaceRoot, result.value.entries) : null; if (!match || match === relativePath) { openAt(relativePath); @@ -3000,7 +3012,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ).then( (metadata) => openAt( - metadata._tag === "Success" && metadata.value?.kind !== "directory" + metadata._tag === "Success" && + (needsWorkspaceBasenameLookup(relativePath) + ? metadata.value?.kind !== "directory" + : metadata.value === null) ? match : relativePath, ), @@ -3286,7 +3301,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (state) onIsAtEndChange?.(state.isAtEnd); }; const handleScroll = (event: NativeSyntheticEvent) => { - reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); + reportHeaderMaterialVisibility( + event.nativeEvent.contentOffset.y + (usesNativeAutomaticInsets ? barInset : 0) > 6, + ); handleMetricsChange(); }; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the list's end state to report it to the parent diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e8ecadbf08374..402809f5bdba8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -37,7 +37,11 @@ import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-pref import { useThreadListNow } from "./use-thread-list-now"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/presentation"; -import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; +import { + useLaunchingThreads, + usePendingNewTasks, + type PendingNewTask, +} from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useMessageDraftCounts } from "../../state/messageDrafts"; import { useWorkspaceState } from "../../state/workspace"; @@ -55,7 +59,6 @@ import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; import { useFabScroll } from "../home/FabScrollContext"; import { WorkspaceListHeader } from "../layout/workspace/WorkspaceListHeader"; -import { WORKSPACE_LIST_SCROLL_INSETS } from "../layout/workspace/workspace-list-insets"; import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColumn"; import { ThreadListV2GroupSectionHeader, @@ -164,6 +167,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { regenerateThreadTitle, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); + const launchingThreads = useLaunchingThreads(threads); const activeNewTask = useActiveNewTask(); const queuedThreadKeys = useQueuedThreadKeys(); const draftCounts = useMessageDraftCounts(); @@ -330,7 +334,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { contentOnlyThreadKeys, olderSettledShelf, } = useThreadListV2({ - threads, + threads: launchingThreads, pendingTasks, environmentId: options.selectedEnvironmentId, projectRefs: scopedProjectRefs, @@ -702,7 +706,9 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} - {...WORKSPACE_LIST_SCROLL_INSETS} + automaticallyAdjustsScrollIndicatorInsets + // The column lays the list under its bar, glass or solid; UIKit insets it past both. + contentInsetAdjustmentBehavior="automatic" contentContainerStyle={[ styles.threadListContent, { diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts index e77e76472b34e..2b32f84fcb9cc 100644 --- a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts +++ b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts @@ -22,7 +22,7 @@ describe("subagent card", () => { expect(subagentCardElapsed([unfinished], DateTime.toEpochMillis(later))).toBeNull(); expect( subagentCardElapsed([{ ...unfinished, status: "running" }], DateTime.toEpochMillis(later)), - ).toBe("2m"); + ).toBe("2m 0s"); expect( subagentCardElapsed([{ ...unfinished, status: "idle" }], DateTime.toEpochMillis(later)), ).toBeNull(); diff --git a/apps/mobile/src/features/threads/thread-command-output.tsx b/apps/mobile/src/features/threads/thread-command-output.tsx index 4d3f89f21b9d9..dc496d32b1e47 100644 --- a/apps/mobile/src/features/threads/thread-command-output.tsx +++ b/apps/mobile/src/features/threads/thread-command-output.tsx @@ -53,9 +53,6 @@ export function ThreadCommandOutput(props: { } return ( - {data?.output.truncated ? ( - Earlier output not shown - ) : null} + {data?.output.truncated ? ( + Earlier output not shown + ) : null} {spans.map((span, index) => span.style === null ? ( diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index d03fc99a6dcae..86b3fcc2d7e1c 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -94,7 +94,7 @@ function SubagentAvatar(props: { showBadge /> ) : driver === undefined ? ( - + ) : ( )} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index c33130ca5e4fd..d1ae55a6b5098 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -31,6 +31,7 @@ import { useEffect, useId, useLayoutEffect, + useMemo, useRef, useState, type ComponentProps, @@ -42,6 +43,7 @@ import { Pressable, ScrollView, StyleSheet, + Text as RNText, useAnimatedValue, View, } from "react-native"; @@ -60,6 +62,11 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { INTEGRATION_ICONS, toolActivityBrandMark } from "@t3tools/shared/brandMarks"; import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; +import { highlightCommand, type HighlightedLines } from "../../lib/highlights"; +import { + commandHighlightLanguage, + withVisibleControlCharacters, +} from "@t3tools/client-runtime/work-log/command-label"; import { AppText as Text } from "../../components/ui/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; @@ -385,7 +392,7 @@ export function ShimmeringWorkContent(props: { function workRowSymbolName(icon: ThreadFeedActivity["icon"]): WorkContentIcon { switch (icon) { case "agent": - return { ios: "sparkles", android: "auto_awesome" }; + return "robot"; case "alert": return { ios: "exclamationmark.triangle", android: "error" }; case "browser": @@ -821,6 +828,31 @@ function ToolCodeText({ readonly theme: "light" | "dark"; }) { const lines = useMarkdownCodeHighlight({ code, language, theme, enabled: true }); + return ; +} + +function CommandCodeText({ + command, + theme, +}: { + readonly command: string; + readonly theme: "light" | "dark"; +}) { + const code = withVisibleControlCharacters(command.trim()); + const lines = useMemo( + () => highlightCommand(code, commandHighlightLanguage(code), theme), + [code, theme], + ); + return ; +} + +function HighlightedCodeText({ + code, + lines, +}: { + readonly code: string; + readonly lines: HighlightedLines | null; +}) { let offset = 0; const highlighted = lines?.flatMap((line, lineIndex) => { const separator = lineIndex > 0 ? "\n" : ""; @@ -830,10 +862,11 @@ function ToolCodeText({ ...line.map((token) => { const start = offset; offset += token.content.length; + // Plain RN Text inherits the mono font; AppText would reset it to sans. return ( - + {token.content} - + ); }), ]; @@ -1271,7 +1304,13 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( props.renderReasoning(reasoning.text) ) : call ? ( [ - call.command, + call.command ? ( + + ) : null, ...(call.args ?? []).map(([key, value]) => { const language = key === "html" @@ -1543,7 +1582,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { case "reasoning": return { ios: "brain", android: "psychology" }; case "agent-tool": - return { ios: "sparkles", android: "auto_awesome" }; + return "robot"; case "tone-tool": return { ios: "bolt", android: "bolt" }; case "dynamic-tool": diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.ts index f6fc518f0c774..09aa3c88c89d9 100644 --- a/apps/mobile/src/features/threads/userMessageIntentBadge.ts +++ b/apps/mobile/src/features/threads/userMessageIntentBadge.ts @@ -7,7 +7,15 @@ export interface UserMessageIntentBadgePresentation { export function resolveUserMessageIntentBadge( intent: OrchestrationV2UserMessageInputIntent | undefined, + unread = false, ): UserMessageIntentBadgePresentation | null { + if (unread && (intent === "steer" || intent === "promoted_queued_to_steer")) { + return { + label: "Steer · waiting to be read", + accessibilityLabel: + "Steer waiting for the agent to read it after the running command finishes", + }; + } switch (intent) { case "steer": return { diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 50b56fa3f7b30..37600d64f4a11 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { EnvironmentId } from "@t3tools/contracts"; +import { providerClients } from "@t3tools/client-runtime/provider-clients"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; import { collectDegradedServices, @@ -30,17 +31,11 @@ import { environmentPresentations } from "../../state/presentation"; import { ResetCredits } from "./UsageLimitsSection"; import { usageProviderKind, useProviderColors } from "./usageProviders"; -const DRIVER_LABEL: Partial> = { - codex: "Codex", - claudeAgent: "Claude", - grok: "Grok", - antigravity: "Antigravity", -}; const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; function accountName(account: LimitAccount) { if (account.displayName) return account.displayName; - if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); + if (!account.email) return providerClients.get(account.driver)?.label ?? account.driver; return emailInitials(account.email) || "Account"; } @@ -448,7 +443,7 @@ export function UsageLimitsSection({ tintColorClassName="accent-warning-foreground" /> - {DRIVER_LABEL[warning.driver] ?? warning.driver}: {warning.description} + {providerClients.get(warning.driver)?.label ?? warning.driver}: {warning.description}
))} @@ -473,7 +468,7 @@ export function UsageLimitsSection({ - {DRIVER_LABEL[pool.driver] ?? pool.driver} + {providerClients.get(pool.driver)?.label ?? pool.driver} {windows.map((window) => { @@ -589,7 +584,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { - {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} + {account.displayName ?? + providerClients.get(account.driver)?.label ?? + account.driver} {account.email ? ( diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 7f9c6bf28ff3c..25967bc520248 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -11,7 +11,8 @@ import { type ServerProviderUsageWindow, } from "@t3tools/contracts"; import { - formatDuration, + type ResetCreditChoice, + resetCreditChoices, formatResetsIn, limitsNotice, paceOf, @@ -243,21 +244,11 @@ export function ResetCredits(props: { const [status, setStatus] = useState(null); if (dense && credits.availableCount === 0 && status === null) return null; - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; - - const redeem = async () => { + const redeem = async (choice: ResetCreditChoice) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); - const result = await consume({ environmentId, input }); + const result = await consume({ environmentId, input: choice.input }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); @@ -270,44 +261,44 @@ export function ResetCredits(props: { ); }; - const confirm = () => { + const confirm = (choice: ResetCreditChoice) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; - Dialog.alert( - "Use a reset credit?", - "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Use credit", onPress: () => void redeem() }, - ], - ); + Dialog.alert("Use a reset credit?", choice.confirmDescription, [ + { text: "Cancel", style: "cancel" }, + { text: "Use credit", onPress: () => void redeem(choice) }, + ]); }; return ( - - {summary} - {credits.availableCount > 0 ? ( - - - {busy ? "Using…" : "Use reset"} - - - ) : null} + + {resetCreditChoices(credits, input, now).map((choice) => ( + + {choice.summary} + {choice.availableCount > 0 ? ( + confirm(choice)} + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]" + } + > + + {busy ? "Using…" : choice.actionLabel} + + + ) : null} + + ))} {!canManageProviders ? ( This connection cannot manage provider accounts. diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts index 0f6ffc98b3abe..14241c84545f5 100644 --- a/apps/mobile/src/lib/attachmentDocument.ts +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -44,7 +44,7 @@ async function readAttachmentText(key: AttachmentTextKey, signal: AbortSignal) { const refreshed = key.environmentId === null ? null - : await refreshAssetUrl(key.environmentId, key.resource, signal); + : await refreshAssetUrl(key.environmentId, key.resource, signal).catch(() => null); if (!refreshed) throw new Error("Reconnect to this environment and try again."); target = refreshed; } diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 54dfa536ff366..0097ab7ed77c0 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -321,12 +321,27 @@ describe("prepareTurnAttachments", () => { // regression: #9713 read file-backed image drafts before enabling them it("uploads a file-backed image from its owned copy without staging base64", async () => { + mocks.upload.mockImplementation( + async ( + _uri: string, + _url: string, + options: { onProgress?: (event: { bytesSent: unknown; totalBytes: unknown }) => void }, + ) => { + // expo-modules-jsi delivers native Int64 progress counts as BigInt. + options.onProgress?.({ bytesSent: BigInt(1), totalBytes: BigInt(4) }); + return { status: 204, body: "", headers: {} }; + }, + ); + const progress: Array = []; const prepared = await prepareTurnAttachments({ environmentId, attachments: [fileBackedImage], supportsImageUploads: true, + onUploadProgress: (_, value) => progress.push(value), }); + expect(progress).toEqual([0.25]); + expect(mocks.upload).toHaveBeenCalledWith( fileBackedImage.fileUri, "https://environment.example/api/attachments/upload/signed", diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index aef8dd4447513..57699af22819e 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -232,8 +232,10 @@ async function uploadFileBytes( signal, ...(onProgress ? { + // Native events carry Int64 counts, which expo-modules-jsi delivers as BigInt despite the `number` type. onProgress: ({ bytesSent, totalBytes }) => { - if (totalBytes > 0) onProgress(bytesSent / totalBytes); + const total = Number(totalBytes); + if (total > 0) onProgress(Number(bytesSent) / total); }, } : {}), diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 61261b84a79f7..301a64b2f8969 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -21,12 +21,15 @@ describe("Enriched link presentation", () => { "/tmp/report.md", "C:\\repo\\app.tsx", "app.tsx:12:3", + "src/http.ts:42-64", + "http.ts:42-64", ]) { expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true); } for (const text of [ "npm run dev", "port:3000", + "port:3000-3010", "TODO:12", "example.com", "https://example.com/app.tsx", diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 2c13b76192709..1c02581717f03 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -101,7 +101,7 @@ export function enrichedLinkVariantPattern(url: string) { } export const ENRICHED_INLINE_FILE_LINK_REGEX = - /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+)?)$/; + /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+(?::\d+|-\d+)?)?|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+|-\d+)?)$/; export function enrichedSkillLinkRegex(skills?: ReadonlyArray) { const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean); diff --git a/apps/mobile/src/lib/highlights.test.ts b/apps/mobile/src/lib/highlights.test.ts index 58604e8b8f8b1..3a259abecb42c 100644 --- a/apps/mobile/src/lib/highlights.test.ts +++ b/apps/mobile/src/lib/highlights.test.ts @@ -3,7 +3,7 @@ import { pierreDark, pierreLight } from "@pierre/highlights/themes"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveHighlightLanguage } from "./highlightLanguage"; -import { highlightCode, tokenizeCode } from "./highlights"; +import { highlightCode, highlightCommand, tokenizeCode, type HighlightedLines } from "./highlights"; vi.mock("react-native-nitro-modules", async () => { const { createWasmHighlightsModule } = await import("./highlights.testing"); @@ -37,6 +37,19 @@ describe("highlightCode", () => { } }); + it("colors a command's embedded script with that script's grammar", () => { + const body = "import json\r\nprint(json.dumps({'ok': True}))"; + const code = `python3 - <<'PY'\r\n${body}\r\nPY`; + const colors = (lines: HighlightedLines) => + lines.map((line) => line.map(({ content, color }) => [content, color])); + const lines = highlightCommand(code, "shellscript", "dark"); + + expect(lines.map((line) => line.map((token) => token.content).join(""))).toEqual( + code.split("\r\n"), + ); + expect(colors(lines.slice(1, 3))).toEqual(colors(highlightCode(body, "python", "dark"))); + }); + it("keeps an over-long line as one plain token", () => { const longLine = `const data = "${"x".repeat(1_000)}";`; const [first, second] = highlightCode(`let a = 1;\n${longLine}`, "typescript", "dark"); diff --git a/apps/mobile/src/lib/highlights.ts b/apps/mobile/src/lib/highlights.ts index 2fe982ef3fccf..7a56e03aa0e19 100644 --- a/apps/mobile/src/lib/highlights.ts +++ b/apps/mobile/src/lib/highlights.ts @@ -1,3 +1,8 @@ +import { + embeddedScripts, + withEmbeddedScripts, + type SyntaxToken, +} from "@t3tools/client-runtime/work-log/embedded-scripts"; import type { T3Highlights } from "@t3tools/mobile-highlights-native"; import { NitroModules } from "react-native-nitro-modules"; @@ -82,6 +87,46 @@ export function tokenizeCode(code: string, language: string): TokenizedCode { }; } +/** Shell commands also color the scripts they embed, such as heredocs and `python -c`. */ +export function highlightCommand( + code: string, + language: "shellscript" | "powershell", + scheme: HighlightScheme, +): HighlightedLines { + const tokenize = (text: string, lang: string) => { + // A missing grammar keeps the shell colors, as on web. + if (highlightLanguageId(lang) === 0) throw new Error(`No grammar for ${lang}`); + return withOffsets(text, highlightCode(text, lang, scheme)); + }; + const embedded = language === "shellscript" ? embeddedScripts(code) : []; + return withEmbeddedScripts(code, tokenize(code, language), embedded, tokenize).map((line) => + line.map((token) => ({ + content: token.content, + color: token.color ?? null, + fontStyle: token.fontStyle ?? null, + })), + ); +} + +function withOffsets(code: string, lines: HighlightedLines): SyntaxToken[][] { + const endings = code.match(/\r?\n/gu) ?? []; + let offset = 0; + return lines.map((line, index) => { + const tokens = line.map((token) => { + const start = offset; + offset += token.content.length; + return { + content: token.content, + offset: start, + ...(token.color ? { color: token.color } : {}), + ...(token.fontStyle ? { fontStyle: token.fontStyle } : {}), + }; + }); + offset += endings[index]?.length ?? 0; + return tokens; + }); +} + /** LF and CRLF split lines; a trailing line break yields a final empty line. */ export function highlightCode( code: string, diff --git a/apps/mobile/src/lib/menu-placement.test.ts b/apps/mobile/src/lib/menu-placement.test.ts new file mode 100644 index 0000000000000..725a5c3ad879f --- /dev/null +++ b/apps/mobile/src/lib/menu-placement.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveMenuVerticalPlacement } from "./menu-placement"; + +const base = { gap: 6, margin: 12, minHeight: 110 }; + +describe("deriveMenuVerticalPlacement", () => { + it.each([ + { + name: "anchor taller than the keyboard-free area stays on screen", + input: { anchorY: -100, anchorHeight: 1000, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 476, top: 12 }, + }, + { + name: "room below opens down", + input: { anchorY: 100, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 342, top: 146 }, + }, + { + name: "room above opens up", + input: { anchorY: 400, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 382, bottom: 406 }, + }, + ])("$name", ({ input, expected }) => { + expect(deriveMenuVerticalPlacement({ ...base, ...input })).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/lib/menu-placement.ts b/apps/mobile/src/lib/menu-placement.ts index 8bd560e6a446b..eb7262a41eaa6 100644 --- a/apps/mobile/src/lib/menu-placement.ts +++ b/apps/mobile/src/lib/menu-placement.ts @@ -23,3 +23,31 @@ export function deriveMenuHorizontalPlacement(input: { const left = Math.min(Math.max(preferredLeft, start + input.margin), end - width - input.margin); return { left, width }; } + +/** In the overlay's coordinates; `bottom` is measured up from the overlay's bottom edge. */ +export function deriveMenuVerticalPlacement(input: { + readonly anchorY: number; + readonly anchorHeight: number; + readonly overlayHeight: number; + readonly keyboardInset: number; + readonly gap: number; + readonly margin: number; + readonly minHeight: number; +}) { + const usableBottom = input.overlayHeight - input.keyboardInset; + const spaceBelow = usableBottom - (input.anchorY + input.anchorHeight) - input.gap - input.margin; + const spaceAbove = input.anchorY - input.gap - input.margin; + const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; + const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); + if (maxHeight < input.minHeight) { + // Neither side fits (e.g. a code block taller than the screen): overlap the anchor instead. + const top = Math.max( + input.margin, + Math.min(input.anchorY, usableBottom - input.margin - input.minHeight), + ); + return { maxHeight: Math.min(usableBottom - input.margin - top, 480), top }; + } + return opensDown + ? { maxHeight, top: input.anchorY + input.anchorHeight + input.gap } + : { maxHeight, bottom: input.overlayHeight - input.anchorY + input.gap }; +} diff --git a/apps/mobile/src/lib/pullRequestNavigation.ts b/apps/mobile/src/lib/pullRequestNavigation.ts index ecc25375b37d4..40c593b3cab1c 100644 --- a/apps/mobile/src/lib/pullRequestNavigation.ts +++ b/apps/mobile/src/lib/pullRequestNavigation.ts @@ -1,3 +1,4 @@ +import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor"; import { resolveChangeRequestLinkTarget } from "@t3tools/client-runtime/change-request-links"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -24,6 +25,7 @@ export function nativePullRequestDestination( projectId: String(target.project.id), repository: target.repository, number: target.number, + ...(parsePullRequestAnchor(url) ? { navigationUrl: url } : {}), ...(target.host === undefined ? {} : { host: target.host }), }; } diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 217bbe4fe3b9a..3abccc251ce1b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2,6 +2,7 @@ import type { ThreadPendingApproval, ThreadPendingUserInput, } from "@t3tools/client-runtime/state/thread-requests"; +import { linkRequestTargetName } from "@t3tools/client-runtime/link-request"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; @@ -36,6 +37,7 @@ import { resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupSummaryKind, + workEntriesViewedImagePaths, type ToolGroupSummaryKind, type WorkLogPresentationEntry, type WorkLogToolLifecycleStatus, @@ -233,6 +235,14 @@ type ThreadFeedEntryContent = readonly shimmer: boolean; readonly thought?: string; } + | { + /** Images the agent opened in the tool group above, kept visible while the group is collapsed. */ + readonly type: "viewed-images"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + readonly paths: ReadonlyArray; + } | { readonly type: "run-fold"; readonly deferredWorkItemIds?: ReadonlyArray; @@ -669,7 +679,7 @@ function itemSummary( case "secret_request": return item.label; case "link_request": - return `Link ${item.label ?? item.url ?? "an environment"}`; + return `Link ${linkRequestTargetName(item)}`; case "dynamic_tool": { const classified = classifyToolActivity({ itemType: "dynamic_tool_call", @@ -1351,7 +1361,10 @@ const supersededReasoningGroups = new WeakMap(); function settleSupersededReasoning( - entry: Exclude, + entry: Exclude< + ThreadFeedEntry, + { readonly type: "run-fold" | "work-toggle" | "thinking" | "viewed-images" } + >, tail: boolean, ) { if (entry.type !== "activity-group") return entry; @@ -1626,7 +1639,10 @@ export function deriveThreadFeedPresentation( const lastEntryByRun = new Map(); const retainedFeed: Array< - Exclude + Exclude< + ThreadFeedEntry, + { readonly type: "run-fold" | "work-toggle" | "thinking" | "viewed-images" } + > > = []; const retainedFeedIndices: number[] = []; const { toolCallVisibility, revealedMessageId } = display; @@ -1639,7 +1655,12 @@ export function deriveThreadFeedPresentation( : null; for (let index = start; index < feed.length; index += 1) { let entry = feed[index]!; - if (entry.type === "run-fold" || entry.type === "work-toggle" || entry.type === "thinking") + if ( + entry.type === "run-fold" || + entry.type === "work-toggle" || + entry.type === "thinking" || + entry.type === "viewed-images" + ) continue; const runId = feedEntryRunId(entry); if (runId !== null) { @@ -1921,7 +1942,10 @@ function thinkingRow(createdAt: string, runId: RunId | null, label: string | nul function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude< + ThreadFeedEntry, + { readonly type: "run-fold" | "work-toggle" | "thinking" | "viewed-images" } + >, expandedWorkGroupIds: ReadonlySet, activeRunId: RunId | null, isWorking: boolean, @@ -1995,16 +2019,27 @@ function appendActivityGroupRows( let groupableRun: ThreadFeedActivity[] = []; const flushGroupableRun = (isTrailingRun: boolean) => { if (groupableRun.length === 0) return; + const groupId = `work-group:${groupAnchorIdByActivityId.get(groupableRun[0]!.id) ?? groupableRun[0]!.id}`; appendToolGroupRows( result, entry, groupableRun, - `work-group:${groupAnchorIdByActivityId.get(groupableRun[0]!.id) ?? groupableRun[0]!.id}`, + groupId, expandedWorkGroupIds, activeRunId, isWorking, activeTail && isTrailingRun, ); + const paths = workEntriesViewedImagePaths(groupableRun.map((activity) => activity.workEntry)); + if (paths.length > 0) { + result.push({ + type: "viewed-images", + id: `${groupId}:viewed-images`, + createdAt: groupableRun[0]!.createdAt, + runId: groupableRun[0]!.runId, + paths, + }); + } groupableRun = []; }; for (const activity of activities) { diff --git a/apps/mobile/src/native/CodeBlockFrame.tsx b/apps/mobile/src/native/CodeBlockFrame.tsx new file mode 100644 index 0000000000000..1c80e759d4dbd --- /dev/null +++ b/apps/mobile/src/native/CodeBlockFrame.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +/** + * The native code block's border and header, for a fence drawn in React once it closes. + * The block slot already applies the code block's outer margins. + */ +export function CodeBlockFrame(props: { + readonly label: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly actions: ReactNode; + readonly children: ReactNode; +}) { + const { textStyle } = props; + return ( + + + + {props.label} + + {props.actions} + + {props.children} + + ); +} + +/** A header icon; `active` draws it at full strength, as the native wrap toggle when on. */ +export function CodeBlockHeaderButton(props: { + readonly icon: AppSymbolName; + readonly label: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly active?: boolean; + readonly onPress: () => void; +}) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + block: { + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + overflow: "hidden", + }, + header: { + minHeight: 32, + paddingHorizontal: 12, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderBottomWidth: StyleSheet.hairlineWidth, + }, + label: { fontWeight: "500" }, + actions: { flexDirection: "row", alignItems: "center", gap: 14 }, +}); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 0d8118ea2475e..a9d3a6fa9769d 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -21,6 +21,8 @@ import { type MarkdownLinkOccurrence, type MarkdownStyle, } from "react-native-enriched-markdown"; +import { codeFenceRenderer } from "./codeBlockMarkdown"; +import { HighlightedCodeBlock } from "./HighlightedCodeBlock"; import { MermaidCodeBlock } from "./MermaidCodeBlock"; import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; @@ -30,6 +32,7 @@ import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; import { useRetryingImageSource } from "./useRetryingImageSource"; import * as Dialog from "../components/ui/Dialog"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import type { HighlightScheme } from "../lib/highlights"; import { ENRICHED_INLINE_FILE_LINK_REGEX, enrichedTextLinkRegex, @@ -167,19 +170,25 @@ function enrichedStyle( }; } -/** Diagram and math fences, as web draws them. A fence streams as native code until it closes. */ +/** + * Diagram and math fences, as web draws them, and code enriched-markdown cannot colour. + * Diagrams and math stay native until the library reports them complete; fallback code keeps one block throughout. + */ function codeBlockElement( block: MarkdownCodeBlock, textStyle: SelectableMarkdownTextProps["textStyle"], markdownStyle: MarkdownStyle, mathRenderingEnabled: boolean, + scheme: HighlightScheme, ): ReactElement | undefined { - if (!block.isComplete) return undefined; - if (block.language === "mermaid") { - return ; + const renderer = codeFenceRenderer(block.language, mathRenderingEnabled); + if (renderer.kind === "mermaid") { + return block.isComplete ? ( + + ) : undefined; } - if (block.language === "math" && mathRenderingEnabled) { - return ( + if (renderer.kind === "math") { + return block.isComplete ? ( + ) : undefined; + } + if (renderer.kind === "highlight" && markdownStyle.codeBlock) { + return ( + ); } return undefined; @@ -357,7 +379,13 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { } : undefined, code: (block: MarkdownCodeBlock) => - codeBlockElement(block, props.textStyle, baseMarkdownStyle, mathRenderingEnabled), + codeBlockElement( + block, + props.textStyle, + baseMarkdownStyle, + mathRenderingEnabled, + themeAppearance, + ), }; const resolveNativeImageSource = useRetryingImageSource(resolveImageSource); const renderMarkdown = (openAndroidMenu?: () => void) => { diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx new file mode 100644 index 0000000000000..b0c981d95e3fc --- /dev/null +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { Pressable, ScrollView, Text } from "react-native"; +import type { MarkdownStyle } from "react-native-enriched-markdown"; + +import { Menu } from "../components/ui/Menu"; +import { useMarkdownCodeHighlight } from "../features/threads/markdownCodeHighlightState"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import type { HighlightScheme } from "../lib/highlights"; +import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; +import { fencedCodeMarkdown } from "./codeBlockMarkdown"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +// Header names from enriched-markdown's CodeBlockLanguages.cpp that are not the fence capitalized. +const FENCE_DISPLAY_NAMES: Readonly> = { + cc: "C++", + cpp: "C++", + cxx: "C++", + cs: "C#", + csharp: "C#", + graphql: "GraphQL", + objc: "Objective-C", + objectivec: "Objective-C", + php: "PHP", + rb: "Ruby", + scss: "SCSS", + sql: "SQL", + toml: "TOML", + xml: "XML", +}; + +// The native block's long-press menu, with its labels and iOS symbols. +const CODE_MENU_ACTIONS = [ + { id: "copy-code", title: "Copy", image: "doc.on.doc" }, + { id: "copy-markdown", title: "Copy as Markdown", image: "doc.text" }, +]; + +/** A fence in a language enriched-markdown cannot colour, drawn like its native block. */ +export function HighlightedCodeBlock(props: { + readonly code: string; + readonly fence: string; + readonly language: string; + /** False only while the library withholds an unclosed fence (`streamingAnimation`, which T3 never enables, so always true today); highlighting waits for it. */ + readonly complete: boolean; + readonly scheme: HighlightScheme; + readonly textStyle: NativeMarkdownTextStyle; + readonly codeStyle: NonNullable; +}) { + const { code, codeStyle, textStyle } = props; + const [wrapped, setWrapped] = useState(false); + const lines = useMarkdownCodeHighlight({ + code, + language: props.language, + theme: props.scheme, + enabled: props.complete, + }); + const fence = props.fence.toLowerCase(); + let offset = 0; + const text = ( + // Not selectable: as in the native block, a long-press opens the copy menu instead. + + {lines?.flatMap((line, lineIndex) => { + const separator = lineIndex > 0 ? "\n" : ""; + offset += separator.length; + return [ + separator, + ...line.map((token) => { + const start = offset; + offset += token.content.length; + return ( + + {token.content} + + ); + }), + ]; + }) ?? code} + + ); + // Inside the scroller: on iOS an ancestor JS responder disables a ScrollView's panning. + const content = ( + { + copyTextWithHaptic( + nativeEvent.event === "copy-markdown" ? fencedCodeMarkdown(code, props.fence) : code, + ); + }} + > + {text} + + ); + return ( + + setWrapped((value) => !value)} + /> + copyTextWithHaptic(code)} + /> + + } + > + {wrapped ? ( + content + ) : ( + + {content} + + )} + + ); +} diff --git a/apps/mobile/src/native/MermaidCodeBlock.tsx b/apps/mobile/src/native/MermaidCodeBlock.tsx index 345f99b1b6355..154c7e11ee174 100644 --- a/apps/mobile/src/native/MermaidCodeBlock.tsx +++ b/apps/mobile/src/native/MermaidCodeBlock.tsx @@ -7,10 +7,10 @@ import { PixelRatio, Platform, Pressable, StyleSheet, Text, View } from "react-n import { parse, Svg, SvgXml } from "react-native-svg"; import { FilePreviewModal } from "../components/ui/FilePreviewModal"; -import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { mermaidSvgForNative, withDiagramBackground } from "../lib/mermaidSvg"; import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; type Pele = typeof import("pele/lazy"); @@ -191,43 +191,29 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram ); }; - // The header copies the native code block's, which this block replaces once the fence closes. return ( - - - - Mermaid - - + {svg ? ( - setShowCode((value) => !value)} /> ) : null} - copyTextWithHaptic(code)} /> - - + + } + > {diagram?.status === "error" ? ( @@ -296,45 +282,11 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram onRequestClose={() => setExpansion(null)} /> ) : null} - - ); -} - -function HeaderButton(props: { - readonly icon: AppSymbolName; - readonly label: string; - readonly color: string; - readonly onPress: () => void; -}) { - return ( - - - + ); } -// The block slot already applies the code block's outer margins. const styles = StyleSheet.create({ - block: { - borderRadius: 8, - borderWidth: StyleSheet.hairlineWidth, - overflow: "hidden", - }, - header: { - height: 32, - paddingHorizontal: 12, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - borderBottomWidth: StyleSheet.hairlineWidth, - }, - language: { fontWeight: "500" }, - actions: { flexDirection: "row", alignItems: "center", gap: 14 }, content: { padding: 12 }, diagramRow: { flexDirection: "row", justifyContent: "center" }, error: { marginBottom: 8 }, diff --git a/apps/mobile/src/native/codeBlockMarkdown.test.ts b/apps/mobile/src/native/codeBlockMarkdown.test.ts new file mode 100644 index 0000000000000..1b254ed420ed8 --- /dev/null +++ b/apps/mobile/src/native/codeBlockMarkdown.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { codeFenceRenderer, fencedCodeMarkdown } from "./codeBlockMarkdown"; + +describe("fencedCodeMarkdown", () => { + it.each([ + ["a plain fence", "fun main() {}", "kotlin", "```kotlin\nfun main() {}\n```"], + ["a longer fence than the code's", "```\nx\n```", "md", "````md\n```\nx\n```\n````"], + ["no language", "x", "", "```\nx\n```"], + ])("writes %s", (_, code, language, expected) => { + expect(fencedCodeMarkdown(code, language)).toBe(expected); + }); +}); + +describe("codeFenceRenderer", () => { + it.each([ + ["mermaid", true, { kind: "mermaid" }], + ["math", true, { kind: "math" }], + ["math", false, { kind: "native" }], + ["kotlin", true, { kind: "highlight", language: "kotlin" }], + ["KOTLIN", true, { kind: "highlight", language: "kotlin" }], + ["diff", true, { kind: "highlight", language: "diff" }], + ["ts", true, { kind: "native" }], + ["python", true, { kind: "native" }], + ["", true, { kind: "native" }], + ["text", true, { kind: "native" }], + ])("draws %j with math rendering %s as %j", (fence, mathRenderingEnabled, expected) => { + expect(codeFenceRenderer(fence, mathRenderingEnabled)).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/native/codeBlockMarkdown.ts b/apps/mobile/src/native/codeBlockMarkdown.ts new file mode 100644 index 0000000000000..b4be181a2ba8f --- /dev/null +++ b/apps/mobile/src/native/codeBlockMarkdown.ts @@ -0,0 +1,54 @@ +import { resolveHighlightLanguage } from "../lib/highlightLanguage"; + +// Fences enriched-markdown colours itself: cpp/highlight/CodeBlockLanguages.cpp entries with a default grammar. +const NATIVE_HIGHLIGHT_FENCES = new Set([ + "bash", + "c", + "css", + "go", + "golang", + "html", + "java", + "javascript", + "js", + "json", + "jsx", + "markdown", + "md", + "py", + "python", + "rs", + "rust", + "sh", + "shell", + "ts", + "tsx", + "typescript", + "yaml", + "yml", + "zsh", +]); + +/** A fenced code block for `code`, with a backtick fence longer than any backtick run inside it. */ +export function fencedCodeMarkdown(code: string, language: string): string { + let longestRun = 0; + for (const [run] of code.matchAll(/`+/g)) longestRun = Math.max(longestRun, run.length); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return `${fence}${language}\n${code}\n${fence}`; +} + +type CodeFenceRenderer = + | { readonly kind: "mermaid" } + | { readonly kind: "math" } + | { readonly kind: "highlight"; readonly language: string } + | { readonly kind: "native" }; + +/** Mermaid and math fences first: highlights knows `mermaid` too, and would claim it as code. */ +export function codeFenceRenderer(fence: string, mathRenderingEnabled: boolean): CodeFenceRenderer { + if (fence === "mermaid") return { kind: "mermaid" }; + if (fence === "math" && mathRenderingEnabled) return { kind: "math" }; + const name = fence.toLowerCase(); + if (NATIVE_HIGHLIGHT_FENCES.has(name)) return { kind: "native" }; + const language = resolveHighlightLanguage({ language: name }); + return language === "text" ? { kind: "native" } : { kind: "highlight", language }; +} diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index ca6edda06a3bc..6608b7b5493db 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -9,6 +9,9 @@ import { appAtomRegistry } from "../state/atom-registry"; import { environmentServerConfigsAtom } from "../state/server"; import { withKnownPullRequestSummary } from "../state/use-thread-pr"; +let linkNavigationId = 0; +const nextLinkNavigationId = () => ++linkNavigationId; + type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; // Avoid useRoute so layouts without route context can open any thread's pull requests. @@ -24,7 +27,10 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean serverConfigs.has(environmentId), ); if (!destination) return tryOpenExternalUrl(url, "pull-request"); - const params = withKnownPullRequestSummary(destination); + const params = withKnownPullRequestSummary({ + ...destination, + ...(destination.navigationUrl ? { navigationId: nextLinkNavigationId() } : {}), + }); // Android shows one page at a time; iOS opens it over the current page, so Back returns. if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); diff --git a/apps/mobile/src/state/assets.test.ts b/apps/mobile/src/state/assets.test.ts index e4ab077c3fc39..a51602c0bbc24 100644 --- a/apps/mobile/src/state/assets.test.ts +++ b/apps/mobile/src/state/assets.test.ts @@ -1,4 +1,5 @@ import { + AssetWorkspaceAssetNotFoundError, AuthFilesystemReadScope, EnvironmentAuthorizationError, EnvironmentId, @@ -6,7 +7,7 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AsyncResult } from "effect/reactivity"; import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ @@ -15,6 +16,8 @@ const state = vi.hoisted(() => ({ assetAtom: {}, mint: vi.fn(), assetQuery: vi.fn(), + executeQuery: vi.fn(), + preparedConnectionAtom: null as unknown, })); vi.mock("react", () => ({ useCallback:
(callback: A) => callback })); @@ -27,7 +30,10 @@ vi.mock("@effect/atom-react", () => ({ : AsyncResult.initial(false), })); vi.mock("./session", () => ({ - environmentSession: { sessionStateAtom: () => ({}) }, + environmentSession: { + sessionStateAtom: () => ({}), + preparedConnectionValueAtom: () => state.preparedConnectionAtom, + }, usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), })); vi.mock("./presentation", () => ({ @@ -48,9 +54,13 @@ vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({ ...(await importOriginal()), createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }), })); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + executeAtomQuery: state.executeQuery, +})); vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); -import { useRefreshAssetUrl, useAssetUrlState } from "./assets"; +import { refreshAssetUrl, useRefreshAssetUrl, useAssetUrlState } from "./assets"; const environmentId = EnvironmentId.make("asset-environment"); const threadId = ThreadId.make("asset-thread"); @@ -107,3 +117,19 @@ it("lets the server authorize an explicit refresh before the client grant loads" state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBeNull(); }); + +it("rejects with the server's error and resolves null only without a connection", async () => { + const missing = new AssetWorkspaceAssetNotFoundError({ + resource, + checkedPath: "/repo/missing.png", + }); + state.executeQuery.mockResolvedValue(AsyncResult.failure(Cause.fail(missing))); + state.preparedConnectionAtom = Atom.make({ + _tag: "Some", + value: { httpBaseUrl: "https://host.test" }, + }); + await expect(refreshAssetUrl(environmentId, resource)).rejects.toBe(missing); + + state.preparedConnectionAtom = Atom.make({ _tag: "None" }); + await expect(refreshAssetUrl(environmentId, resource)).resolves.toBeNull(); +}); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 62ae955e99259..8a2b4d9b4350a 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -8,7 +8,7 @@ import { EMPTY_ASSET_URL_ATOM, makeRevisionedAssetUrlFamily, } from "@t3tools/client-runtime/state/assets"; -import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; +import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -109,7 +109,10 @@ export function useRefreshAssetUrl( }; } -/** {@link useRefreshAssetUrl} for async tasks that resolve several resources at once. */ +/** + * {@link useRefreshAssetUrl} for async tasks that resolve several resources at once. Resolves + * null without a connection and rejects with the server's error when it refuses the URL. + */ export async function refreshAssetUrl( environmentId: EnvironmentId, resource: AssetResource, @@ -124,6 +127,7 @@ export async function refreshAssetUrl( reportFailure: false, ...(signal ? { signal } : {}), }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); // A route can change while the query runs; resolve against the connection now current. const connection = appAtomRegistry.get(connectionAtom); if (connection._tag === "None") return null; @@ -160,7 +164,7 @@ const freshAssetUrlFamily = Atom.family((key: FreshAssetUrlKey) => try: async (signal) => key.environmentId === null ? null - : await refreshAssetUrl(key.environmentId, key.resource, signal), + : await refreshAssetUrl(key.environmentId, key.resource, signal).catch(() => null), catch: (cause) => new AssetUrlUnavailableError({ message: cause instanceof Error ? cause.message : "The attachment is unavailable.", diff --git a/apps/mobile/src/state/composer-attachment-uploads.test.ts b/apps/mobile/src/state/composer-attachment-uploads.test.ts index 1809acc559be9..4a9c0d427276e 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.test.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.test.ts @@ -170,6 +170,7 @@ vi.mock("./use-thread-outbox", async () => { dispatchingQueuedMessageIdAtom: Atom.make>({}).pipe(Atom.keepAlive), useThreadOutboxMessages: () => appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + retryingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), threadOutboxShellStatusesAtom: Atom.make(new Map()), }; }); diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts index 6c039d7254c07..9e523972a63c3 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.test.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -1,11 +1,20 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + buildPendingNewTasks, type PendingDraftProjectDefaults, pendingDraftBranchNeedsRefs, resolvePendingDraftBranch, } from "./pending-new-tasks-model"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; import type { ComposerDraft, ComposerDraftWorkspaceSelection } from "./use-composer-drafts"; const projectCwd = "/repo"; @@ -82,3 +91,55 @@ describe("resolvePendingDraftBranch", () => { expect(resolve({ draft: chosen, branches, projectCwd })).toBe("release"); }); }); + +function queuedCreation(environmentId: string): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(`thread-${environmentId}`), + messageId: MessageId.make(`message-${environmentId}`), + commandId: CommandId.make(`command-${environmentId}`), + text: "Test", + attachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + createdAt: "2026-11-10T01:10:00.000Z", + }; +} + +describe("buildPendingNewTasks", () => { + it("lists only creations the outbox is not sending yet", () => { + const sending = EnvironmentId.make("sending"); + const syncing = EnvironmentId.make("syncing"); + const readOnly = EnvironmentId.make("read-only"); + const retrying = EnvironmentId.make("retrying"); + const { modelSelection: _model, ...modelless } = queuedCreation("sending"); + const tasks = buildPendingNewTasks({ + queuedMessages: [ + ...["sending", "syncing", "read-only", "retrying", "offline"].map(queuedCreation), + { ...modelless, messageId: MessageId.make("message-modelless") }, + ], + drafts: {}, + sendGate: { + connectedEnvironmentIds: new Set([sending, syncing, readOnly, retrying]), + operableEnvironmentIds: new Set([sending, syncing, retrying]), + shellStatuses: new Map([ + [sending, "live"], + [syncing, "synchronizing"], + [readOnly, "live"], + [retrying, "live"], + ]), + retryingMessageIds: { [MessageId.make("message-retrying")]: true }, + }, + }); + + expect(tasks).toHaveLength(5); + expect(new Set(tasks.map((task) => task.environmentId))).toEqual( + new Set(["syncing", "read-only", "retrying", "offline", "sending"]), + ); + }); +}); diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts index ce8923ce62ee1..d923009452c72 100644 --- a/apps/mobile/src/state/pending-new-tasks-model.ts +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -1,7 +1,13 @@ -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, MessageId, ProjectId } from "@t3tools/contracts"; import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; -import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model"; +import { + isQueuedThreadCreationSendable, + resolveThreadOutboxDeliveryAction, + type QueuedThreadCreation, + type QueuedThreadMessage, +} from "./thread-outbox-model"; import { isNewTaskDraftKey } from "./new-task-draft-key"; import type { ComposerDraft } from "./use-composer-drafts"; import { resolveNewTaskDraftWorkspace } from "../features/threads/new-task-context-presentation"; @@ -85,13 +91,41 @@ export function resolvePendingDraftBranch(input: { }).branch; } +export interface ThreadOutboxSendGate { + readonly connectedEnvironmentIds: ReadonlySet; + readonly operableEnvironmentIds: ReadonlySet; + readonly shellStatuses: ReadonlyMap; + readonly retryingMessageIds: Readonly>>; +} + +/** The outbox drain's gate for a creation: sendable, connected, shell live, operate granted, not backing off. */ +export function isQueuedCreationSending( + message: QueuedThreadMessage, + gate: ThreadOutboxSendGate, +): boolean { + const environmentId = message.environmentId; + return ( + isQueuedThreadCreationSendable(message) && + gate.retryingMessageIds[message.messageId] !== true && + gate.operableEnvironmentIds.has(environmentId) && + resolveThreadOutboxDeliveryAction({ + isCreation: true, + threadExists: false, + shellStatus: gate.shellStatuses.get(environmentId) ?? "empty", + environmentConnected: gate.connectedEnvironmentIds.has(environmentId), + }) === "send" + ); +} + export function buildPendingNewTasks(input: { readonly queuedMessages: ReadonlyArray; readonly drafts: Readonly>; + readonly sendGate: ThreadOutboxSendGate; }): ReadonlyArray { const tasks: PendingNewTask[] = []; for (const message of input.queuedMessages) { - if (!message.creation) { + // Listing a creation already being sent would flash an "Unsent" row. + if (!message.creation || isQueuedCreationSending(message, input.sendGate)) { continue; } tasks.push({ diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index dd1515be1d223..5c76c09bd5baa 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -4,13 +4,17 @@ import { MessageId, ProjectId, ProviderInstanceId, + RunId, ThreadId, } from "@t3tools/contracts"; +import { isThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import { describe, expect, it } from "vite-plus/test"; import { + pendingThreadCreationShell, resolvePendingThreadCreation, selectThreadCreationDetail, + withLaunchingThreads, type PendingThreadCreation, } from "./pending-thread-creation"; import type { QueuedThreadMessage } from "./thread-outbox-model"; @@ -186,3 +190,30 @@ describe("selectThreadCreationDetail", () => { ).toEqual({ pendingCreation: null, detailRef: threadRef }); }); }); + +describe("withLaunchingThreads", () => { + it("reads as working from Send until the thread's first run arrives", () => { + const launch = (threads: Parameters[0]["threads"]) => + withLaunchingThreads({ threads, launches: [creation], hiddenThreadKeys: new Set() }); + // Before the shell arrives, and after the server commits the thread without its run. + const created = { ...pendingThreadCreationShell(creation)!, title: "Server title" }; + for (const threads of [launch([]), launch([created])]) { + expect(threads).toHaveLength(1); + expect(isThreadWorking(threads[0]!)).toBe(true); + } + expect(launch([created])[0]!.title).toBe("Server title"); + + const started = { + ...created, + latestRun: { + runId: RunId.make("run-1"), + status: "completed" as const, + requestedAt: null, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }; + expect(launch([started])[0]).toBe(started); + }); +}); diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 22684ba0c264b..78c682ec7a2d4 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -4,6 +4,8 @@ import { } from "@t3tools/client-runtime/state/shell"; import type { LocalThreadMessage } from "../lib/threadActivity"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; +import { threadKey } from "@t3tools/client-runtime/state/entities"; +import { asLaunchingThread } from "@t3tools/client-runtime/state/thread-inbox"; import { Atom } from "effect/reactivity"; import * as DateTime from "effect/DateTime"; @@ -133,6 +135,33 @@ export function pendingThreadCreationMessage(message: QueuedThreadMessage): Loca }; } +/** Launches show as preparing from Send, before the server's shell exists, until their first run. */ +export function withLaunchingThreads(input: { + readonly threads: ReadonlyArray; + readonly launches: ReadonlyArray; + readonly hiddenThreadKeys: ReadonlySet; +}): ReadonlyArray { + if (input.launches.length === 0) return input.threads; + const launchByKey = new Map(); + for (const message of input.launches) { + if (!message.creation || input.hiddenThreadKeys.has(threadKey(message))) continue; + launchByKey.set(scopedThreadKey(message.environmentId, message.threadId), message); + } + if (launchByKey.size === 0) return input.threads; + const threads = input.threads.map((thread) => { + const key = scopedThreadKey(thread.environmentId, thread.id); + const launch = launchByKey.get(key); + if (!launch) return thread; + launchByKey.delete(key); + return asLaunchingThread(thread); + }); + for (const launch of launchByKey.values()) { + const shell = pendingThreadCreationShell(launch); + if (shell) threads.push(asLaunchingThread(shell)); + } + return threads; +} + export function pendingThreadCreationShell( message: QueuedThreadMessage, ): EnvironmentThreadShell | null { diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 3222101bf927a..8014e579197f9 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -136,6 +136,7 @@ vi.mock("./use-thread-outbox", async () => { return { editingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), dispatchingQueuedMessageIdAtom: Atom.make(null).pipe(Atom.keepAlive), + retryingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), threadOutboxShellStatusesAtom: Atom.make(new Map([["secondary", "live"]])), }; }); diff --git a/apps/mobile/src/state/threadOutboxDelivery.test.ts b/apps/mobile/src/state/threadOutboxDelivery.test.ts index 3f9797cc91f9c..f7d67f4349397 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.test.ts @@ -120,6 +120,7 @@ vi.mock("./use-thread-outbox", async () => { return { editingQueuedMessageIdsAtom: Atom.make>({}).pipe(Atom.keepAlive), dispatchingQueuedMessageIdAtom: Atom.make(null).pipe(Atom.keepAlive), + retryingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), threadOutboxShellStatusesAtom: Atom.make(new Map()), }; }); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 9b2da676eee44..19cd4ec3339e4 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -87,6 +87,7 @@ import { import { dispatchingQueuedMessageIdAtom, editingQueuedMessageIdsAtom, + retryingQueuedMessageIdsAtom, threadOutboxShellStatusesAtom, } from "./use-thread-outbox"; import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error"; @@ -155,7 +156,20 @@ function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: } } +function setQueuedMessageRetrying(queuedMessageId: MessageId, retrying: boolean): void { + const { [queuedMessageId]: current, ...others } = appAtomRegistry.get( + retryingQueuedMessageIdsAtom, + ); + if ((current === true) === retrying) return; + appAtomRegistry.set( + retryingQueuedMessageIdsAtom, + retrying ? { ...others, [queuedMessageId]: true } : others, + ); +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { + // An attempt in flight is sending, even while its retry count is kept. + setQueuedMessageRetrying(queuedMessageId, false); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -701,6 +715,7 @@ export function createThreadOutboxDelivery(): { notBefore: Date.now() + delayMs, timer: setTimeout(drain, delayMs), }); + setQueuedMessageRetrying(messageId, true); }; const clearRetry = (messageId: MessageId): void => { @@ -708,6 +723,7 @@ export function createThreadOutboxDelivery(): { if (retry === undefined) return; clearTimeout(retry.timer); retries.delete(messageId); + setQueuedMessageRetrying(messageId, false); }; // A delivery that throws backs off like any failure. Releasing the slot @@ -1412,6 +1428,7 @@ export function createThreadOutboxDelivery(): { stopCollectingOutcomes(); for (const retry of retries.values()) clearTimeout(retry.timer); retries.clear(); + appAtomRegistry.set(retryingQueuedMessageIdsAtom, {}); for (const blocked of blockedRecoveries.values()) blocked.unsubscribe(); blockedRecoveries.clear(); for (const release of familyMounts.values()) release(); diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts index 0d5e3f602e529..58fbf0508817c 100644 --- a/apps/mobile/src/state/use-pending-new-tasks.ts +++ b/apps/mobile/src/state/use-pending-new-tasks.ts @@ -1,10 +1,26 @@ import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; import { threadKey } from "@t3tools/client-runtime/state/entities"; -import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + buildPendingNewTasks, + isQueuedCreationSending, + type PendingNewTask, + type ThreadOutboxSendGate, +} from "./pending-new-tasks-model"; +import { pendingThreadCreationOutcomesAtom, withLaunchingThreads } from "./pending-thread-creation"; import { flattenQueuedThreadMessages } from "./thread-outbox-model"; import { environmentThreadShells } from "./threads"; import { newTaskComposerDraftsAtom } from "./use-composer-drafts"; -import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { + retryingQueuedMessageIdsAtom, + threadOutboxShellStatusesAtom, + useThreadOutboxMessages, +} from "./use-thread-outbox"; +import { environmentSession, sessionHasScope } from "./session"; +import { workspaceConnections } from "./workspace"; export type { PendingDraftTask, @@ -12,13 +28,58 @@ export type { PendingQueuedTask, } from "./pending-new-tasks-model"; +const threadOutboxSendGateAtom = Atom.make((get): ThreadOutboxSendGate => { + const connected = get(workspaceConnections.environmentsAtom).flatMap((environment) => + environment.connectionState === "connected" ? [environment.environmentId] : [], + ); + return { + connectedEnvironmentIds: new Set(connected), + operableEnvironmentIds: new Set( + connected.filter((environmentId) => + sessionHasScope( + get(environmentSession.sessionStateAtom(environmentId)), + AuthOrchestrationOperateScope, + ), + ), + ), + shellStatuses: get(threadOutboxShellStatusesAtom), + retryingMessageIds: get(retryingQueuedMessageIdsAtom), + }; +}).pipe(Atom.withLabel("mobile:pending-new-tasks:send-gate")); + export function usePendingNewTasks(): ReadonlyArray { const queuedMessagesByThreadKey = useThreadOutboxMessages(); const drafts = useAtomValue(newTaskComposerDraftsAtom); const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); + const sendGate = useAtomValue(threadOutboxSendGateAtom); // A delivered creation can stay queued after its thread is live and hidden. const queuedMessages = flattenQueuedThreadMessages(queuedMessagesByThreadKey).filter( (message) => !hiddenKeys.has(threadKey(message)), ); - return buildPendingNewTasks({ queuedMessages, drafts }); + return buildPendingNewTasks({ + queuedMessages, + drafts, + sendGate, + }); +} + +/** The list's threads, with creations the outbox is sending placed as working. */ +export function useLaunchingThreads( + threads: ReadonlyArray, +): ReadonlyArray { + const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const outcomes = useAtomValue(pendingThreadCreationOutcomesAtom); + const hiddenThreadKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); + const sendGate = useAtomValue(threadOutboxSendGateAtom); + return useMemo(() => { + const launches = [ + ...flattenQueuedThreadMessages(queuedMessagesByThreadKey).filter((message) => + isQueuedCreationSending(message, sendGate), + ), + ...Object.values(outcomes).flatMap((outcome) => + outcome.kind === "delivered" ? [outcome.message] : [], + ), + ]; + return withLaunchingThreads({ threads, launches, hiddenThreadKeys }); + }, [threads, queuedMessagesByThreadKey, outcomes, hiddenThreadKeys, sendGate]); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 010bdc81beeae..3e1fc25b93357 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -7,6 +7,7 @@ import { readEnvironmentScope } from "./session"; import { useAtomValue } from "@effect/atom-react"; import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { threadSteersDuringCompaction } from "@t3tools/client-runtime/state/thread-workflows"; import { pendingHandoffCoveredByModelChange, resolvePendingHandoff, @@ -95,6 +96,7 @@ import { } from "./use-composer-drafts"; import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; import { + compactionBlocksSteering, resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -407,7 +409,11 @@ export function useThreadComposerState(input: { ? deriveThreadActivityRun(selectedThreadProjection.projection) : (selectedThreadShell?.latestRun ?? null); - const isCompacting = (() => { + const activeRunId = + selectedThreadRuntime?.activeRunId && threadRuntimeIsActive(selectedThreadRuntime) + ? selectedThreadRuntime.activeRunId + : null; + const compactCommandIsActive = (() => { const queuedCompact = selectedThreadQueuedMessages.some( (message) => message.messageId === dispatchingQueuedMessageId && @@ -415,16 +421,7 @@ export function useThreadComposerState(input: { message.attachments.length === 0, ); if (queuedCompact) return true; - const activeRunId = selectedThreadRuntime?.activeRunId; - if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false; - if ( - selectedThreadVisibleTurnItems.some( - ({ item }) => - item.runId === activeRunId && item.type === "compaction" && item.status === "running", - ) - ) { - return true; - } + if (!activeRunId) return false; const compactMessage = selectedThreadVisibleTurnItems.findLast( ({ item }) => item.runId === activeRunId && @@ -443,8 +440,21 @@ export function useThreadComposerState(input: { item.status === "cancelled"), ); })(); - // A compacting turn cannot take steering input, so follow-ups queue meanwhile. - const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !isCompacting; + const compactionRunning = + activeRunId !== null && + selectedThreadVisibleTurnItems.some( + ({ item }) => + item.runId === activeRunId && item.type === "compaction" && item.status === "running", + ); + const isCompacting = compactCommandIsActive || compactionRunning; + const compactionBlocksSteer = compactionBlocksSteering({ + compactCommandIsActive, + compactionRunning, + steersDuringCompaction: threadSteersDuringCompaction(selectedThreadProjection?.projection), + run: selectedThreadProjection?.projection.runs.find((run) => run.id === activeRunId), + messages: selectedThreadMessages, + }); + const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !compactionBlocksSteer; // A stop or a restart holds the queue, completion wakes included, until it // is resumed; the queue sheet lists only the user's own messages. const queueHeld = queueWorkflow?.isHeld === true; diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts index caffa99f8cfa0..bc1f3ea149e1b 100644 --- a/apps/mobile/src/state/use-thread-outbox.ts +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -31,6 +31,11 @@ export const dispatchingQueuedMessageIdAtom = Atom.make(null). Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), ); +/** Messages waiting out a retry backoff, which can repeat for as long as the failure lasts. */ +export const retryingQueuedMessageIdsAtom = Atom.make>>>( + {}, +).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:retrying-message-ids")); + export function holdEditingQueuedMessage(messageId: MessageId): void { const current = appAtomRegistry.get(editingQueuedMessageIdsAtom); if (current[messageId]) { diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index abc4fb0ed0c95..2b88a45db8ff1 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -135,6 +135,65 @@ describe("subscription widget snapshots", () => { }); }); +describe("subscription widget expiry", () => { + const maxAgeDeadline = now + 15 * 60_000; + function single(resetsAt: string) { + return buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt }] } })]), + deepLink, + ).providers[0]; + } + + it.each(["2026-09-05T11:50:00.000Z", checkedAt])( + "keeps usage whose reset at %s already happened when observed", + (resetsAt) => { + expect(single(resetsAt)).toMatchObject({ + detail: "Subscription remaining", + expiresAt: maxAgeDeadline, + windows: [{ remaining: 60, reset: expect.stringMatching(/^Reset /) }], + }); + }, + ); + + it.each([ + { resetAccount: 1, expiresAt: maxAgeDeadline, label: /^Reset / }, + { resetAccount: 0, expiresAt: now + 2 * 60_000, label: /^Next reset / }, + ])( + "compares a pooled reset with its own account's observation (account $resetAccount)", + ({ resetAccount, expiresAt, label }) => { + // Account 0 was observed at 12:00, account 1 at 12:05; the pooled reset is at 12:02. + const snapshot = buildSubscriptionUsageSnapshot( + presentations( + [checkedAt, "2026-09-05T12:05:00.000Z"].map((accountCheckedAt, index) => + provider({ + instanceId: ProviderInstanceId.make(`codex-${index}`), + auth: { status: "authenticated", email: `account-${index}@example.com` }, + usageLimits: { + checkedAt: accountCheckedAt, + windows: [ + { + ...window, + resetsAt: + index === resetAccount + ? "2026-09-05T12:02:00.000Z" + : "2026-09-05T13:00:00.000Z", + }, + ], + }, + }), + ), + ), + deepLink, + ); + expect(snapshot.providers[0]).toMatchObject({ + detail: "2 accounts · pooled", + expiresAt, + windows: [{ remaining: 60, reset: expect.stringMatching(label) }], + }); + }, + ); +}); + describe("widget refresh probes", () => { it("does not overlap a slow probe even after the refresh interval", async () => { let finish!: () => void; diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index b96b5c919ec30..db99110c5d357 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -2,6 +2,7 @@ import { collectLimitAccounts, collectLimitPools, type LimitAccount, + type LimitPoolWindow, type LimitPresentations, } from "@t3tools/shared/usageLimits"; @@ -44,6 +45,10 @@ export function createWidgetRefresher(refresh: (id: Id) => Promise) }; } +// A reset at or before its account's observation already happened, so it cannot expire the snapshot. +const isUpcoming = (reset: LimitPoolWindow["resets"][number]) => + reset.at > Date.parse(reset.member.account.limits.checkedAt); + function subscriptionUsageProps( accounts: readonly LimitAccount[], now: number, @@ -75,7 +80,9 @@ function subscriptionUsageProps( const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); const expiresAt = Math.min( checkedAt + SNAPSHOT_MAX_AGE, - ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), + ...pool.windows.flatMap((window) => + window.resets.filter(isUpcoming).map((reset) => reset.at), + ), ); const fresh = Number.isFinite(expiresAt) && expiresAt > now; const sortedWindows = [...pool.windows].sort( @@ -106,7 +113,9 @@ function subscriptionUsageProps( label: window.label, remaining: Math.round(window.remainingPercent), reset: window.resets[0] - ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { + ? `${isUpcoming(window.resets[0]) ? "Next reset" : "Reset"} ${new Date( + window.resets[0].at, + ).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 046a2dc97b236..9f8e324629e5b 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -30,6 +30,7 @@ const reactTests = [ "src/features/threads/ThreadMarkdownImage.test.tsx", "src/components/ui/FilePreviewSession.test.tsx", "src/features/threads/UsageLimitRecoveryCard.test.tsx", + "src/features/threads/PendingUserInputCard.test.tsx", "src/features/threads/NewTaskDraftRouteScreen.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", "src/components/ComposerEditor.ios.test.tsx", diff --git a/apps/server/package.json b/apps/server/package.json index 284084065e61a..d16b8d4e19f2f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.8", + "version": "0.1.9", "license": "MIT", "repository": { "type": "git", @@ -67,7 +67,7 @@ "effect": "catalog:", "ffi-rs": "1.3.2", "node-pty": "^1.2.0-beta.15", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "proper-lockfile": "4.1.2", "saxes": "6.0.0", "stream-chain": "^4.2.5", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 44011bc7f2d4b..03f69beb57ccd 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -28,6 +28,7 @@ const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitV2Fidelity = process.env.T3_ACP_EMIT_V2_FIDELITY === "1"; const emitNotice = process.env.T3_ACP_EMIT_NOTICE === "1"; +const hangCloseSession = process.env.T3_ACP_HANG_CLOSE_SESSION === "1"; const vibeRetryOutcome = process.env.T3_ACP_VIBE_RETRY_OUTCOME; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitInTurnTaskOutputThenLateDuplicate = @@ -731,6 +732,9 @@ const program = Effect.gen(function* () { yield* agent.handleCloseSession(() => Effect.gen(function* () { yield* requireAuthentication(); + if (hangCloseSession) { + return yield* Effect.never; + } return {}; }), ); diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 9dd418a9486bd..b944bfcb42ef1 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -38,8 +38,33 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio '{"lineage":{"parentThreadId":"stopped-thread","relationshipToParent":"fork","rootThreadId":"stopped-thread"}}'; const subagentPayload = '{"lineage":{"parentThreadId":"subagent-parent","relationshipToParent":"subagent","rootThreadId":"subagent-parent"},"forkedFrom":{"type":"node","nodeId":"node-1"}}'; + const watchedPayload = JSON.stringify({ + pullRequests: [ + { + host: "github.com", + repository: "owner/repo", + number: 7, + url: "https://github.com/owner/repo/pull/7", + source: "agent", + linkedAt: "2026-08-01T00:00:00.000Z", + snapshot: null, + stack: null, + watch: { + startedAt: "2026-08-01T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: "2026-08-01T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }, + }, + ], + }); const threads = [ - ["stopped-thread", "project-kept", "completed", "{}", "2026-08-01"], + ["stopped-thread", "project-kept", "completed", watchedPayload, "2026-08-01"], ["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"], ["running-thread", "project-kept", "running", "{}", "2026-08-05"], ["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"], @@ -129,6 +154,11 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { SELECT stream_id FROM orchestration_events ORDER BY stream_id`; const sessions = yield* sql<{ provider_session_id: string }>` SELECT provider_session_id FROM orchestration_v2_projection_provider_sessions`; + const [pullRequest] = yield* sql<{ url: string | null; watch: string | null }>` + SELECT + json_extract(payload_json, '$.pullRequests[0].url') AS url, + json_extract(payload_json, '$.pullRequests[0].watch') AS watch + FROM orchestration_v2_projection_threads WHERE thread_id = 'stopped-thread'`; const [leftovers] = yield* sql<{ auth: number; tasks: number; @@ -143,7 +173,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { (SELECT COUNT(*) FROM scheduled_task_webhook_pending) AS webhooks, (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers, (SELECT COUNT(*) FROM peer_environment_links) AS links`; - return { threads, events, sessions, leftovers }; + return { threads, events, sessions, pullRequest, leftovers }; }), ); assert.deepStrictEqual( @@ -158,6 +188,11 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { kept.sessions.map((row) => row.provider_session_id), ["session-shared"], ); + assert.deepStrictEqual( + kept.pullRequest, + { url: "https://github.com/owner/repo/pull/7", watch: null }, + "copied pull request links keep their history but stop being watched", + ); assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 84dd8f2ccad3f..7909614d909d4 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -422,6 +422,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) OR (aggregate_kind = 'project' AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.pullRequests', ( + SELECT json_group_array(json_remove(link.value, '$.watch') ORDER BY link.key) + FROM json_each(payload_json, '$.pullRequests') AS link)) + WHERE EXISTS ( + SELECT 1 FROM json_each(payload_json, '$.pullRequests') AS link + WHERE json_extract(link.value, '$.watch') IS NOT NULL)`; for (const table of CLEARED_TABLES) { yield* sql`DELETE FROM ${sql(table)}`; } diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index ecf7180b13efb..ee211b2bdc36d 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -15,7 +15,7 @@ import { AssetWorkspaceRootNormalizationError, SOURCE_CONTROL_MEDIA_ASSET, ThreadId, - ToolActivityNativeAppReference, + NativeAppIconReference, TurnItemId, } from "@t3tools/contracts"; import { @@ -152,7 +152,7 @@ const AssetClaimsSchema = Schema.Union([ Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("native-app-icon"), - app: ToolActivityNativeAppReference, + app: NativeAppIconReference, expiresAt: Schema.Number, }), Schema.Struct({ diff --git a/apps/server/src/assets/NativeAppIconResolver.test.ts b/apps/server/src/assets/NativeAppIconResolver.test.ts new file mode 100644 index 0000000000000..ecfe08788af3b --- /dev/null +++ b/apps/server/src/assets/NativeAppIconResolver.test.ts @@ -0,0 +1,62 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; + +it.effect("resolves the exact bundle path without Spotlight and caches the converted icon", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bundle-icon-" }); + const bundle = path.join(root, "Build Output", "My App.app"); + const resources = path.join(bundle, "Contents", "Resources"); + yield* fs.makeDirectory(resources, { recursive: true }); + yield* fs.writeFileString(path.join(resources, "AppIcon.icns"), "icon"); + const commands: string[] = []; + const spawner = Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: (command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + if (command.command === "/usr/bin/plutil") return "AppIcon"; + if (command.command === "/usr/bin/sips") { + yield* fs.writeFileString(String(command.args.at(-1)), "converted icon"); + } + return ""; + }), + }); + yield* Effect.gen(function* () { + const resolver = yield* NativeAppIconResolver.make; + const reference = { _tag: "app-path" as const, path: bundle }; + const icon = yield* resolver.resolve(reference); + expect(icon).not.toBeNull(); + if (icon === null) return; + expect(yield* fs.readFileString(icon)).toBe("converted icon"); + expect(yield* resolver.resolve(reference)).toBe(icon); + expect(yield* resolver.resolve({ _tag: "app-path", path: "~/Build Output/My App.app" })).toBe( + icon, + ); + expect(commands.filter((command) => command === "/usr/bin/sips")).toHaveLength(1); + expect(commands).not.toContain("/usr/bin/mdfind"); + expect(yield* resolver.resolve({ _tag: "app-path", path: "relative.app" })).toBeNull(); + expect(yield* resolver.resolve({ _tag: "app-path", path: root })).toBeNull(); + }).pipe( + Effect.provide( + Layer.mergeAll( + spawner, + ServerConfig.ServerConfig.layerTest(root, { prefix: "t3-native-icon-config-" }), + ), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.HomeDirectory, root), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), +); diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts index 8a535724a058a..0d238effee4cc 100644 --- a/apps/server/src/assets/NativeAppIconResolver.ts +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -1,4 +1,5 @@ -import type { ToolActivityNativeAppReference } from "@t3tools/contracts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import type { NativeAppIconReference } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Digest from "effect/crypto/Digest"; @@ -28,16 +29,16 @@ const RESOLUTION_CACHE_MAX_ENTRIES = 256; export class NativeAppIconResolver extends Context.Service< NativeAppIconResolver, { - readonly resolve: (app: ToolActivityNativeAppReference) => Effect.Effect; + readonly resolve: (app: NativeAppIconReference) => Effect.Effect; } >()("t3/assets/NativeAppIconResolver") {} -function appCacheKey(app: ToolActivityNativeAppReference): string { +function appCacheKey(app: NativeAppIconReference): string { return JSON.stringify(app); } -function appFromCacheKey(key: string): ToolActivityNativeAppReference { - return JSON.parse(key) as ToolActivityNativeAppReference; +function appFromCacheKey(key: string): NativeAppIconReference { + return JSON.parse(key) as NativeAppIconReference; } const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { @@ -91,9 +92,13 @@ function containsControlCharacter(value: string): boolean { } const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* ( - app: ToolActivityNativeAppReference, + app: NativeAppIconReference, ) { const path = yield* Path.Path; + if (app._tag === "app-path") { + const appPath = expandHomePath(app.path, yield* HostProcess.HomeDirectory); + return path.isAbsolute(appPath) && appPath.endsWith(".app") ? appPath : null; + } const query = app._tag === "app-id" ? `kMDItemCFBundleIdentifier == '${app.appId}'` @@ -131,7 +136,7 @@ const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicati }); const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* ( - app: ToolActivityNativeAppReference, + app: NativeAppIconReference, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -242,7 +247,7 @@ export const make = Effect.gen(function* () { ); const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* ( - app: ToolActivityNativeAppReference, + app: NativeAppIconReference, ) { if ( hostPlatform !== "darwin" || diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 9c80680b6d670..7895e683ebcba 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -1481,7 +1481,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(EnvironmentAuthPolicy.layer), ); -const layerStorage = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistence.layerConfig); +const layerStorage = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistence.layerCliConfig); export const layerRuntime = layer.pipe( Layer.provideMerge(ManagedPolicy.layer), diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index fb25815e51836..e4190dfdd745f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -510,10 +510,14 @@ it.effect("separates host file URLs from readable attachment URLs", () => yield* client[WS_METHODS.assetsCreateUrl]({ resource: { _tag: "attachment", attachmentId: "image" }, }); + yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "native-app-icon", app: { _tag: "app-id", appId: "com.apple.calculator" } }, + }); for (const resource of [ { _tag: "workspace-file", threadId: ThreadId.make("thread"), path: "file.txt" }, { _tag: "media-file", threadId: ThreadId.make("thread"), path: "/repo/image.png" }, { _tag: "draft-workspace-file", cwd: "/repo", path: "file.txt" }, + { _tag: "native-app-icon", app: { _tag: "app-path", path: "/private/Secret.app" } }, ] as const) { expect( yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip), @@ -522,7 +526,7 @@ it.effect("separates host file URLs from readable attachment URLs", () => requiredPermission: AuthFilesystemReadScope, }); } - expect(handled).toBe(1); + expect(handled).toBe(2); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 488517e3951fa..91522c319e02d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -365,7 +365,8 @@ const requiredScopesForRpcCall = ( return [ resource._tag === "workspace-file" || resource._tag === "media-file" || - resource._tag === "draft-workspace-file" + resource._tag === "draft-workspace-file" || + (resource._tag === "native-app-icon" && resource.app._tag === "app-path") ? AuthFilesystemReadScope : AuthOrchestrationReadScope, ]; diff --git a/apps/server/src/cli/deferredImports.test.ts b/apps/server/src/cli/deferredImports.test.ts index a585e52b6f73e..c5d55afe7f94a 100644 --- a/apps/server/src/cli/deferredImports.test.ts +++ b/apps/server/src/cli/deferredImports.test.ts @@ -95,7 +95,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CLI deferred imp assert.isTrue(Exit.hasDies(exit)); assert.isTrue(yield* fs.exists(runtimeStatePath)); - assert.isFalse(yield* fs.exists(NodePath.join(baseDir, "userdata", "server-owner.sqlite"))); }), ); diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index 869c085cbad2b..51ce21339e8b0 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -38,7 +38,7 @@ const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* ({ contended: (_destination: string) => {} })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: (...args: Parameters) => { + try { + return actual.renameSync(...args); + } catch (error) { + if (["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) { + leaseObserver.contended(String(args[1])); + } + throw error; + } + }, + }; +}); + it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], @@ -442,6 +464,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { current: false, installedVersion: "1.2.4-nightly.1", }); + yield* service.install({ allowDowngrade: true }); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + }); } }), ); @@ -575,6 +602,168 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* newer.restart).toBe("restarted"); expect((yield* newer.status).problems).not.toContain("restart-pending"); expect((yield* newer.status).current).toBe(true); + + const latest = yield* makeService(undefined, "1.2.5"); + yield* latest.install({ start: true }); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.5", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.5/t3"); + expect((yield* latest.status).current).toBe(true); + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(false); + }), + ); + + it.effect( + "serializes a local install with another process reconciling an older unit snapshot", + () => + Effect.gen(function* () { + const { service, fs, statePath, makeService, runtime } = yield* makeHarness(); + const path = yield* Path.Path; + const plan = yield* service.install(); + const unit = yield* fs.readFileString(plan.unitPath); + yield* fs.writeFileString( + plan.unitPath, + unit.replace("versions/1.2.3/t3", "versions/1.2.2/t3"), + ); + yield* fs.writeFileString( + statePath, + JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "committed", + fromVersion: "1.2.2", + targetVersion: "1.2.3", + status: "committed", + }, + }), + ); + const rejection = path.join(plan.baseDir, "rejected-update"); + const databasePath = path.join(plan.baseDir, "state.sqlite"); + yield* fs.writeFileString(databasePath, "database"); + yield* fs.writeFileString( + runtime.entryPath, + `#!${process.execPath} +import { writeFileSync } from "node:fs"; +process.on("message", message => { + if (message === "request-update") process.send({ type: "request-update", targetVersion: "1.2.5", dbPath: ${JSON.stringify(databasePath)} }); + if (message.type === "update-rejected") { + writeFileSync(${JSON.stringify(rejection)}, message.reason); + process.exit(0); + } +}); +`, + ); + yield* fs.chmod(runtime.entryPath, 0o755); + const newer = yield* makeService(undefined, "1.2.4"); + yield* makeService(undefined, "1.2.5"); + const snapshot = Promise.withResolvers(); + const milestone = Promise.withResolvers(); + const script = path.join(plan.baseDir, "launcher-contention.mjs"); + yield* fs.writeFileString( + script, + ` +import fs from "node:fs/promises"; +import cp from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +const server = Promise.withResolvers(); +const originalSpawn = cp.spawn; +cp.spawn = (...args) => { const child = originalSpawn(...args); server.resolve(child); return child; }; +process.on("message", async message => { + if (message === "installed") (await server.promise).send("request-update"); +}); +const originalRead = fs.readFile; +fs.readFile = async (...args) => { + const contents = await originalRead(...args); + if (args[0] === ${JSON.stringify(plan.unitPath)}) { + fs.readFile = originalRead; + syncBuiltinESMExports(); + const resume = new Promise(resolve => process.once("message", resolve)); + process.send("snapshot"); + await resume; + } + return contents; +}; +syncBuiltinESMExports(); +const { Launcher, readServiceState } = await import(${JSON.stringify(import.meta.resolve("../serviceLauncher.ts"))}); +const launcher = new Launcher(${JSON.stringify(plan.baseDir)}, await readServiceState(${JSON.stringify(statePath)}), { + serviceUnit: { path: ${JSON.stringify(plan.unitPath)}, reload: async () => {} }, +}); +try { await launcher.run(); throw Error("unexpected completion"); } +catch (error) { + if (!error.message.includes("Active child exited unexpectedly (0)")) throw error; +} +const { acquireServiceSelectionLock } = await import(${JSON.stringify(import.meta.resolve("./serviceSelectionLock.ts"))}); +// Exit owning the real lease, as a crashed process would. +await acquireServiceSelectionLock(${JSON.stringify(plan.unitPath)}); +process.disconnect(); +`, + ); + const child = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.fork(script, [], { + execArgv: [], + stdio: ["ignore", "inherit", "inherit", "ipc"], + }), + ), + (child) => + Effect.sync(() => { + if (child.exitCode === null && child.signalCode === null) child.kill(); + }), + ); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`launcher exited ${code}`)), + ); + }); + child.on("message", (message) => { + if (message === "snapshot") snapshot.resolve(); + }); + yield* Effect.promise(() => + Promise.race([ + snapshot.promise, + exited.then(() => { + throw new Error("launcher exited before snapshot"); + }), + ]), + ); + leaseObserver.contended = (destination) => { + if (destination === plan.unitPath + ".selection-lock") milestone.resolve(); + }; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + leaseObserver.contended = () => {}; + }), + ); + const installing = yield* newer + .install({ start: false }) + .pipe(Effect.ensuring(Effect.sync(() => milestone.resolve())), Effect.forkChild); + // Resume the launcher once the installer contends for the unit lease, or once an + // uncoordinated installer has finished, which makes the stale snapshot win. + yield* Effect.promise(() => milestone.promise); + child.send("resume"); + yield* Fiber.join(installing); + child.send("installed"); + yield* Effect.promise(() => exited); + expect(yield* fs.readFileString(rejection)).toContain("Service selection changed"); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.4", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + expect( + yield* fs.readFileString( + path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE), + ), + ).toBe("1.2.4\n"); + // The next installer reclaims the dead owner and releases its own lease. + yield* newer.install({ start: false }); + expect(yield* fs.exists(plan.unitPath + ".selection-lock")).toBe(false); }), ); @@ -617,6 +806,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install({ start: false }).pipe(Effect.flip); expect(error._tag).toBe("BootServiceUpdatePendingError"); expect(yield* fs.readFileString(statePath)).toBe(pendingState); + yield* fs.remove((yield* service.status).unitPath); + expect((yield* service.install({ start: false }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(yield* fs.readFileString(statePath)).toBe(pendingState); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 88d477974ae0a..e7de29ad8953d 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; +import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts"; import { ensurePinnedRuntimeInstalled, launcherOwnsVersionsDir, @@ -28,20 +29,26 @@ import { SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, + SERVICE_UNIT_ENV, compareExactServiceVersions, + serviceUnitBaseDirOf as bootServiceBaseDirOf, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; +export { + serviceUnitBaseDirOf as bootServiceBaseDirOf, + serviceUnitProgramOf as bootServiceProgramOf, +} from "./serviceProtocol.ts"; + const BOOT_SERVICE_NAME = "t3code"; export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; -const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ @@ -56,32 +63,6 @@ function quoteSystemdValue(value: string): string { : escaped; } -const unquoteSystemdValue = (raw: string) => - (raw.startsWith('"') && raw.endsWith('"') - ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : raw - ).replaceAll("%%", "%"); - -const unescapeXmlText = (value: string) => - value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); - -export function bootServiceBaseDirOf(contents: string): string | undefined { - const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; - if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); - const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; - return plist === undefined ? undefined : unescapeXmlText(plist); -} - -/** The executable an installed unit or plist starts: the launcher's runtime. */ -export function bootServiceProgramOf(contents: string): string | undefined { - const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; - if (systemd !== undefined) return unquoteSystemdValue(systemd); - const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( - contents, - )?.[1]; - return plist === undefined ? undefined : unescapeXmlText(plist); -} - export interface BootServicePlan { readonly program: ReadonlyArray; readonly baseDir: string; @@ -104,7 +85,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, - `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, + `Environment=${SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. @@ -162,7 +143,7 @@ export function renderBootServicePlist( ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, - ` ${BOOT_SERVICE_UNIT_ENV}`, + ` ${SERVICE_UNIT_ENV}`, ` ${BOOT_SERVICE_PLIST_FILE}`, ` `, ` WorkingDirectory`, @@ -496,9 +477,12 @@ export class BootServicePrerequisiteError extends Schema.TaggedError()( "BootServiceUpdatePendingError", - {}, + { recoveryCommand: Schema.optional(Schema.String) }, ) { override get message(): string { + if (this.recoveryCommand !== undefined) { + return `A remote server update is still pending, but its service definition is missing. With the service stopped, run this on the server machine: ${this.recoveryCommand}\nWait for the update to finish or roll back, stop the foreground launcher with Ctrl-C, then retry the install.`; + } return "A remote server update is still pending. Wait for it to finish, then retry."; } } @@ -606,7 +590,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const xdgConfigHome = yield* Config.String("XDG_CONFIG_HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.String("PATH").pipe(Config.withDefault("")); const runningInsideService = - (yield* Config.String(BOOT_SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === + (yield* Config.String(SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === BOOT_SERVICE_UNIT_FILE; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -799,6 +783,16 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] }); }); + const withSelectionLock = (selectionPath: string, operation: Effect.Effect) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => acquireServiceSelectionLock(selectionPath), + catch: (cause) => new BootServiceInstallError({ cause }), + }), + () => operation, + (release) => Effect.sync(release), + ); + const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; readonly start?: boolean; @@ -876,8 +870,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - // With start=false, changing startup-only files is safe except during a remote update, - // when the launcher also writes its state file. + // Stop outside the selection lock: the launcher may be waiting to acquire it. const start = options?.start !== false; if (installed && start && runningInsideService && manager.kind === "systemd") { // Stopping the unit from inside it kills this process before the start. @@ -890,7 +883,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // Transient properties reach systemd literally, unlike unit-file specifiers. `--property=StandardOutput=append:${logPath}`, `--property=StandardError=append:${logPath}`, - `--setenv=${BOOT_SERVICE_UNIT_ENV}=`, + `--setenv=${SERVICE_UNIT_ENV}=`, runtimePaths.entryPath, "service", "install", @@ -905,58 +898,83 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* runSteps(manager.stop); } - yield* Effect.gen(function* () { - if (installed) { - const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); - if (Option.isSome(previousStateText)) { - if (serviceStateHasPendingUpdate(previousStateText.value)) { - return yield* new BootServiceUpdatePendingError(); + // State first, then unit. The launcher acquires only one at a time, and a + // failure to lock the unit must never prevent its durable update commit. + yield* withSelectionLock( + statePath, + withSelectionLock( + unitPath, + Effect.gen(function* () { + const currentlyInstalled = yield* fs.exists(unitPath); + const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); + if (Option.isSome(previousStateText)) { + const installedVersion = serviceStateActiveVersion(previousStateText.value); + if (serviceStateHasPendingUpdate(previousStateText.value)) { + const quote = (word: string) => `'${word.replaceAll("'", "'\\''")}'`; + return yield* new BootServiceUpdatePendingError( + !currentlyInstalled && installedVersion !== undefined + ? { + recoveryCommand: `T3CODE_HOME=${quote(input.baseDir)} ${quote(pinnedRuntimePaths(path, input.baseDir, installedVersion, platform).entryPath)} __service-launcher`, + } + : {}, + ); + } + // Read the final version after stopping the launcher; a remote update may finish after the status check. + if ( + installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(input.cliVersion, installedVersion) < 0 + ) { + return yield* new BootServiceDowngradeRefusedError({ + installedVersion, + targetVersion: input.cliVersion, + }); + } } - // Read the final version after stopping the launcher; a remote update may finish after the status check. - const installedVersion = serviceStateActiveVersion(previousStateText.value); - if ( - installedVersion !== undefined && - options?.allowDowngrade !== true && - compareExactServiceVersions(input.cliVersion, installedVersion) < 0 - ) { - return yield* new BootServiceDowngradeRefusedError({ - installedVersion, - targetVersion: input.cliVersion, - }); + yield* fs + .makeDirectory(path.dirname(unitPath), { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // Files can match the new version even when activation fails; keep that install retryable. + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); + yield* writeDurably( + statePath, + // @effect-diagnostics-next-line globalJsonInEffect:off - fixed launcher-owned document. + `${JSON.stringify( + { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: input.cliVersion, + } satisfies ServiceState, + null, + 2, + )}\n`, + ); + if (!start && (currentlyInstalled || Option.isSome(previousStateText))) { + const written = yield* fs.readFileString(statePath); + if (serviceStateActiveVersion(written) !== input.cliVersion) { + return yield* new BootServiceUpdatePendingError(); + } } - } - } - yield* fs - .makeDirectory(path.dirname(unitPath), { recursive: true }) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - // Files can match the new version even when activation fails; keep that install retryable. - yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); - yield* writeDurably( - statePath, - // @effect-diagnostics-next-line globalJsonInEffect:off - fixed launcher-owned document. - `${JSON.stringify( - { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: input.cliVersion, - } satisfies ServiceState, - null, - 2, - )}\n`, - ); - if (!start && installed) { - // A concurrent remote update can overwrite launcher state; detect it before repointing the unit. - const written = yield* fs.readFileString(statePath); - if (serviceStateActiveVersion(written) !== input.cliVersion) { - return yield* new BootServiceUpdatePendingError(); - } - } - yield* writeDurably(unitPath, manager.render(plan)); - - if (start) { - yield* runSteps(manager.activate); - yield* fs.remove(restartPendingPath, { force: true }); - } - }).pipe( + yield* writeDurably(unitPath, manager.render(plan)); + }), + ), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + if (start) { + yield* runSteps(manager.activate); + yield* withSelectionLock( + statePath, + Effect.gen(function* () { + // A concurrent deferred install may now be waiting for a newer restart. + const selected = parseServiceState(yield* fs.readFileString(statePath)); + if (selected?.activeVersion === input.cliVersion && selected.update === undefined) { + yield* fs.remove(restartPendingPath, { force: true }); + } + }), + ); + } + }), + ), Effect.mapError((cause) => cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, ), diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 3b8ea548d93da..8958c945aab84 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -1,15 +1,46 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; -// Bump for incompatible runtime layout changes; launchers survive self-updates. Protocol 3 requires standalone executables. -export const SERVICE_LAUNCHER_PROTOCOL = 3 as const; +// Protocol 4 requires coordinated selection writes and unit reconciliation. An +// installed protocol-3 launcher needs one local update or service install with a restart: +// remote updates replace its child, so cannot add these launcher guarantees. +export const SERVICE_LAUNCHER_PROTOCOL = 4 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; export const SERVICE_ENV_FILE = "service.env"; export const SERVICE_STATE_FILE = "service-state.json"; +/** Set by the unit or launch agent to its own file name, so the launcher can + find the definition that started it. */ +export const SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** Written before explicit stop to distinguish shutdown from update handoff. */ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; /** Marks a repointed unit whose service still runs the old version; launcher startup clears it. */ export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; +const unquoteSystemdValue = (raw: string) => + (raw.startsWith('"') && raw.endsWith('"') + ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") + : raw + ).replaceAll("%%", "%"); + +const unescapeXmlText = (value: string) => + value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + +export function serviceUnitBaseDirOf(contents: string): string | undefined { + const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); + const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + +/** The executable an installed unit or plist starts: the launcher's runtime. */ +export function serviceUnitProgramOf(contents: string): string | undefined { + const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd); + const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( + contents, + )?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + export interface PendingServiceUpdate { readonly id: string; readonly fromVersion: string; @@ -143,7 +174,8 @@ export function decodeServiceState(value: unknown): ServiceState | undefined { if (!isRecord(value)) return undefined; const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update); if ( - value.protocol !== SERVICE_LAUNCHER_PROTOCOL || + // Protocol 3 has the same durable format; its installer can select a protocol-4 runtime. + (value.protocol !== 3 && value.protocol !== SERVICE_LAUNCHER_PROTOCOL) || typeof value.activeVersion !== "string" || !isExactServiceVersion(value.activeVersion) || (value.update !== undefined && update === undefined) || diff --git a/apps/server/src/cloud/serviceSelectionLock.test.ts b/apps/server/src/cloud/serviceSelectionLock.test.ts new file mode 100644 index 0000000000000..d2567de4c0bc5 --- /dev/null +++ b/apps/server/src/cloud/serviceSelectionLock.test.ts @@ -0,0 +1,30 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts"; + +it.layer(NodeServices.layer)("service selection lock", (it) => { + it.effect("reclaims a lease from an earlier boot even when its PID is live again", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-selection-lock-" }); + const selectionPath = path.join(root, "service-state.json"); + const lock = `${selectionPath}.selection-lock`; + const stale = `${process.pid}.1.00000000-0000-4000-8000-000000000000`; + yield* fs.makeDirectory(lock, { recursive: true }); + yield* fs.writeFileString(path.join(lock, stale), ""); + + const release = yield* Effect.promise(() => acquireServiceSelectionLock(selectionPath)); + const owners = yield* fs.readDirectory(lock); + assert.equal(owners.length, 1); + assert.notEqual(owners[0], stale); + assert.isTrue(owners[0]!.startsWith(`${process.pid}.`)); + release(); + assert.isFalse(yield* fs.exists(lock)); + }), + ); +}); diff --git a/apps/server/src/cloud/serviceSelectionLock.ts b/apps/server/src/cloud/serviceSelectionLock.ts new file mode 100644 index 0000000000000..4cbd123c22f7e --- /dev/null +++ b/apps/server/src/cloud/serviceSelectionLock.ts @@ -0,0 +1,88 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Shared with the standalone launcher, which has no Effect runtime. +// @effect-diagnostics globalTimers:off -- Bounded cross-process lease acquisition in the standalone launcher. +// @effect-diagnostics globalDate:off -- The standalone launcher has no Effect clock for its acquisition deadline. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +// Coarse boot time: a lease from an earlier boot is dead even when its PID is live again. +const bootEpochSeconds = () => Math.round(Date.now() / 1000 - NodeOS.uptime()); + +/** Serialize durable selection and unit writes, never manager stop/start commands. */ +export async function acquireServiceSelectionLock(selectionPath: string): Promise<() => void> { + const directory = NodePath.dirname(selectionPath); + NodeFS.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const lock = selectionPath + ".selection-lock"; + const owner = `${process.pid}.${bootEpochSeconds()}.${NodeCrypto.randomUUID()}`; + const candidate = `${lock}.${owner}`; + const deadline = Date.now() + 30_000; + const removeEmptyLock = () => { + try { + NodeFS.rmdirSync(lock); + } catch (error) { + if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + }; + NodeFS.mkdirSync(candidate, { mode: 0o700 }); + try { + NodeFS.writeFileSync(NodePath.join(candidate, owner), "", { mode: 0o600 }); + while (true) { + try { + // Publish the owner atomically; rename cannot replace another populated directory. + NodeFS.renameSync(candidate, lock); + return () => { + NodeFS.unlinkSync(NodePath.join(lock, owner)); + removeEmptyLock(); + }; + } catch (error) { + if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + let owners: string[]; + try { + owners = NodeFS.readdirSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (owners.length === 1) { + const previous = owners[0]!; + const [pidText, bootText] = previous.split("."); + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) + throw new Error(`Invalid service selection lock owner at ${lock}.`); + const ownerBoot = Number(bootText); + const fromEarlierBoot = + Number.isSafeInteger(ownerBoot) && Math.abs(ownerBoot - bootEpochSeconds()) > 60; + let dead = fromEarlierBoot; + if (!dead) { + try { + process.kill(pid, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") throw error; + dead = code === "ESRCH"; + } + } + if (dead) { + // Remove only this dead owner's unique file, never a replacement owner's. + try { + NodeFS.unlinkSync(NodePath.join(lock, previous)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + } + removeEmptyLock(); + if (Date.now() >= deadline) + throw new Error( + `Service selection is locked at ${lock}. Retry when the other operation finishes.`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } finally { + NodeFS.rmSync(candidate, { recursive: true, force: true }); + } +} diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index b50f4dfbf2a40..e5b42557a1e10 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -12,7 +12,7 @@ const STALE_RUNTIME = const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; -/** agent-device 0.21.31's flags that take a value, so the launcher can find the command past them. */ +/** agent-device 0.21.36's flags that take a value, so the launcher can find the command past them. */ const VALUE_FLAGS: ReadonlyArray = [ "--activity", "--all", @@ -53,6 +53,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--header", "--hold-ms", "--host", + "--image-tag", "--in-app", "--include", "--interval-ms", @@ -79,6 +80,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--metro-port", "--network-profile", "--on-error", + "--os", "--os-version", "--out", "--pattern", @@ -101,6 +103,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--provider-geo-location", "--provider-language", "--provider-locale", + "--provider-network-hosts", "--provider-network-profile", "--provider-os-version", "--provider-project", diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index ef0b48eefc80f..eafa3b6319de3 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -162,7 +162,12 @@ export interface DeviceHostReady { readonly run: ( command: string, args: ReadonlyArray, - options?: { readonly timeoutMs?: number; readonly stdin?: string }, + options?: { + readonly timeoutMs?: number; + readonly stdin?: string; + /** Local output cap; SSH output is not capped. */ + readonly maxOutputBytes?: number; + }, ) => Effect.Effect<{ readonly stdout: string; readonly stderr: string; readonly code: number }>; readonly helpers: { readonly serveSimAxSettings: string | null; diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 2725e7ac4fe29..def65c957a439 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -19,6 +19,11 @@ const LIST_TIMEOUT_SECONDS = 40; // expo-device-hub 0.19.1 reports a failed simulator listing only as text; its error ids are hashes. const SIMULATOR_LIST_FAILED = /^\[apple-utils\] (?:Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)|`xcrun simctl list devices --json` did not respond within )/; +// The hub passes numeric display IDs that Xcode 26 rejects; iOS 27 needs them for multiple displays. +const runsOnXcode26Runtime = (version: string) => Number(/^iOS (\d+)/.exec(version)?.[1]) <= 26; +const DEFAULT_DISPLAY_SCREENSHOT = `f=$(mktemp -t t3-screenshot) || exit 1 +trap 'rm -f "$f"' 0 +xcrun simctl io "$1" screenshot --type=png "$f" >/dev/null && base64 -i "$f"`; const HubDevice = Schema.Struct({ id: Schema.String, @@ -265,6 +270,24 @@ export const makeDeviceHubOperations = (options: { }, ); + const defaultDisplayScreenshot = Effect.fn("DeviceHubOperations.defaultDisplayScreenshot")( + function* (udid: string) { + const capture = yield* run("sh", ["-c", DEFAULT_DISPLAY_SCREENSHOT, "sh", udid], { + timeoutMs: Duration.toMillis(SCREENSHOT_TIMEOUT), + maxOutputBytes: 64 * 1024 * 1024, + }); + if (capture.code !== 0) { + return yield* new DeviceOperationError({ + operation: "screenshot", + reason: "command_failed", + exitCode: capture.code, + cause: capture.stderr.trim(), + }); + } + return new Uint8Array(Buffer.from(capture.stdout, "base64")); + }, + ); + const screenshot: DeviceOperations["screenshot"] = (device) => httpClient .execute( @@ -289,6 +312,13 @@ export const makeDeviceHubOperations = (options: { cause, }), ), + Effect.catchIf( + (error) => + device.platform === "ios" && + runsOnXcode26Runtime(device.version) && + error.reason === "hub_rejected", + () => defaultDisplayScreenshot(device.id), + ), Effect.withSpan("DeviceHubOperations.screenshot"), ); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 06105032a5a3f..ff7d292564fba 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -213,6 +213,14 @@ const handler = Effect.gen(function* () { upstreamSearch.delete("wsTicket"); upstreamSearch.delete("hostId"); const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : ""; + const screenshotDevice = upstreamSearch.get("device"); + if (!ready.upstream && hubPath === "/vendor/serve-sim/api/screenshot" && screenshotDevice) { + const { png } = yield* devices.screenshot({ hostId: ready.hostId, deviceId: screenshotDevice }); + return HttpServerResponse.uint8Array(png, { + contentType: "image/png", + headers: { "cache-control": "no-store, no-transform" }, + }); + } // Hosts with several upstreams (one per cloud session) route and authenticate requests themselves. const routed = ready.upstream ? yield* ready.upstream(hubPath, upstreamSearch) : undefined; if (routed === null) { diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 64054630f0d9c..a34dbc534f59a 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -48,7 +48,7 @@ const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBoo const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError)); it.effect.each([400, 500])( - "distinguishes screenshot HTTP rejection (%s) from transport failure", + "retries a hub-rejected (%s) screenshot with simctl only on iOS 26 and older", (status) => Effect.gen(function* () { const operations = makeDeviceHubOperations({ @@ -59,22 +59,26 @@ it.effect.each([400, 500])( HttpClientResponse.fromWeb(request, new Response("private hub output", { status })), ), ), - run: () => Effect.die("Screenshot does not run a command"), + run: () => Effect.succeed({ code: 0, stdout: "iVBORw==", stderr: "" }), platformAvailable: () => Effect.succeed(false), }); - const result = yield* operations - .screenshot({ - id: "phone", - name: "Phone", - version: "iOS 26.3", - platform: "ios", - booted: true, - physical: false, - }) - .pipe(Effect.result); - expect(result).toMatchObject({ + const screenshot = (version: string) => + operations + .screenshot({ + id: "phone", + name: "Phone", + version, + platform: "ios", + booted: true, + physical: false, + }) + .pipe(Effect.result); + expect(yield* screenshot("iOS 27.0")).toMatchObject({ failure: { _tag: "DeviceOperationError", reason: "hub_rejected" }, }); + expect(yield* screenshot("iOS 26.3")).toMatchObject({ + success: new Uint8Array([137, 80, 78, 71]), + }); }), ); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 250e6cf14f3be..eb299f9e29cdd 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -209,6 +209,19 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); +/** Entry of the first installed agent-device among `versions`; never installs. */ +export const installedAgentDeviceEntry = Effect.fn("DeviceToolchain.installedAgentDeviceEntry")( + function* (baseDir: string, versions: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const version of versions) { + const paths = toolPaths(path, baseDir, { ...AGENT_DEVICE_SPEC, version }); + if (yield* isInstalled(fs, paths, version)) return paths.entryPath; + } + return null; + }, +); + export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( baseDir: string, running: { hub?: string; agent?: string } = {}, diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b86a4d032610..e133b4f9db36c 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -40,6 +40,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( stateDir: string, ) => Effect.Effect, avdOutput: ProcessRunner.ProcessRunOutput = processOutput(0), + previousDaemon?: typeof LocalDeviceHost.__testing.AgentDeviceDaemonFile.Type, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -47,6 +48,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); + const runs: Array = []; for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -57,6 +59,10 @@ const agentFixture = Effect.fn("agentFixture")(function* ( yield* fs.writeFileString(entryPath, ""); yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!); } + if (previousDaemon) { + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(path.join(stateDir, "daemon.json"), JSON.stringify(previousDaemon)); + } const host = yield* LocalDeviceHost.make().pipe( Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }), @@ -92,10 +98,12 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ), Effect.provideService(ProcessRunner.ProcessRunner, { - run: (input) => - input.args.includes("devices") + run: (input) => { + runs.push(input); + return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)), + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + }, }), Effect.provideService( HttpClient.HttpClient, @@ -114,7 +122,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; }); describe("AgentDevice startup", () => { @@ -276,6 +284,75 @@ describe("AgentDevice startup", () => { expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops a previous server's daemon with the agent-device version it recorded", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, fs, baseDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + const olderInstall = path.join(baseDir, "tools", "agent-device", "0.0.1"); + const olderEntry = path.join( + olderInstall, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ); + yield* fs.makeDirectory(path.dirname(olderEntry), { recursive: true }); + yield* fs.writeFileString(olderEntry, ""); + yield* fs.writeFileString(path.join(olderInstall, ".install-complete"), "0.0.1"); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe(olderEntry); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // regression: #33552 a daemon left by a previous server survived every later graceful quit + it.effect("stops a previous server's daemon with the current pin when its version is gone", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, baseDir, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args).toEqual([ + path.join( + baseDir, + "tools", + "agent-device", + AGENT_DEVICE_VERSION, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ), + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("drops a dead daemon's record without running agent-device when the host stops", () => + Effect.gen(function* () { + // Above the Linux and macOS PID limits, so never a live process. + const deadPid = 4_194_305; + const path = yield* Path.Path; + const { host, fs, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: deadPid, version: AGENT_DEVICE_VERSION }, + ); + yield* host.stop; + expect(runs.filter((run) => run.args.includes("daemon"))).toEqual([]); + expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 6643e689328d1..c5097509dcade 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -47,7 +47,9 @@ import { isAgentDeviceInstalled, isDeviceHubInstalled, deviceToolVersions, + AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION, + installedAgentDeviceEntry, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -624,28 +626,44 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); - const stopAgentDeviceDaemon = ( - agentTool: { readonly entryPath: string; readonly nodePath: string } | null, - ) => - agentTool - ? runner - .run({ - command: agentTool.nodePath, - args: [ - agentTool.entryPath, - "daemon", - "stop", - "--state-dir", - agentDeviceStateDir(path, config.stateDir), - ], - env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, - timeout: Duration.seconds(10), - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.ignore) - : Effect.void; - - let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; + // The daemon may predate this process (an earlier server was killed, possibly on an + // older pin), so stop it with the CLI of its recorded version when that is installed. + const stopAgentDeviceDaemon = Effect.gen(function* () { + if (!(yield* fs.exists(daemonFilePath()))) return; + const recorded = yield* readDaemonFile().pipe(Effect.option); + if ( + Option.isSome(recorded) && + recorded.value.pid && + !(yield* isProcessAlive(recorded.value.pid)) + ) { + return yield* fs.remove(daemonFilePath(), { force: true }); + } + const entryPath = yield* installedAgentDeviceEntry(config.baseDir, [ + ...(Option.isSome(recorded) && recorded.value.version ? [recorded.value.version] : []), + AGENT_DEVICE_VERSION, + ]); + if (!entryPath) return; + const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( + Effect.provideService(HostProcess.Platform, hostPlatform), + ); + yield* runner.run({ + command: nodePath, + args: [ + entryPath, + "daemon", + "stop", + "--state-dir", + agentDeviceStateDir(path, config.stateDir), + ], + env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, + timeout: Duration.seconds(10), + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.ignore, + ); const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, @@ -760,7 +778,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }), ), ); - agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( @@ -806,6 +823,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { timeout: Duration.millis(options?.timeoutMs ?? 20_000), timeoutBehavior: "timedOutResult", ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), + ...(options?.maxOutputBytes === undefined + ? {} + : { maxOutputBytes: options.maxOutputBytes }), }) .pipe( Effect.map((result) => ({ @@ -840,7 +860,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)( Effect.gen(function* () { - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; yield* Ref.update(runningRef, (running) => running ? { ...running, agentDevice: null } : running, ); @@ -852,7 +872,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const running = yield* Ref.getAndSet(runningRef, null); yield* stopHub(running?.hub); yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; }), ); diff --git a/apps/server/src/device/deviceToolVersions.json b/apps/server/src/device/deviceToolVersions.json index e441cc55a9d34..ce61669b26573 100644 --- a/apps/server/src/device/deviceToolVersions.json +++ b/apps/server/src/device/deviceToolVersions.json @@ -1,4 +1,4 @@ { - "agent-device": "0.21.31", - "expo-device-hub": "0.19.1" + "agent-device": "0.21.36", + "expo-device-hub": "0.22.1" } diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 47e9772e20fe1..5b9ecc98299c7 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -16,16 +16,14 @@ import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { isTailnetHost } from "@t3tools/shared/hostClassification"; - -import * as ServerConfig from "../config.ts"; import { formatHostForUrl, - isLoopbackHost, - isPrivateNetworkAddress, + isTailnetHost, isWildcardHost, - VIRTUAL_INTERFACE, -} from "../startupAccess.ts"; +} from "@t3tools/shared/hostClassification"; + +import * as ServerConfig from "../config.ts"; +import { isLoopbackHost, isPrivateNetworkAddress, VIRTUAL_INTERFACE } from "../startupAccess.ts"; type NetworkInterfacesMap = ReturnType; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c1ce8a043fe40..dd53e23138945 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2644,6 +2644,46 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect( + "status does not inherit a release PR from a worktree's non-default base upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["push", "origin", "main:dev"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/from-dev", "origin/dev"]); + yield* runGit(repoDir, ["config", "branch.feature/from-dev.t3-merge-base", "dev"]); + + const { manager, providerCalls } = yield* makeManager({ + providerScenario: { + prListByHeadSelector: { + dev: [ + { + number: 49, + title: "Release dev to main", + url: "https://github.com/pingdotgg/codething-mvp/pull/49", + baseRefName: "main", + headRefName: "dev", + state: "merged", + updatedAt: "2026-08-20T10:00:00Z", + }, + ], + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/from-dev"); + expect(status.pr).toBeNull(); + expect(listedSelectors(providerCalls).includes("dev")).toBe(false); + }), + ); + it.effect("status finds a PR pushed under the branch's own name despite a default upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d9e1d7d4c09e1..c8f107ee7060b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -114,6 +114,7 @@ export type GitBranchPullRequest = NonNullable & { interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; + readonly projectId: ProjectId | null; readonly style: SourceControlWritingStyleSettings; } @@ -1786,7 +1787,7 @@ export const make = Effect.gen(function* () { return remoteUrl === null ? null : remoteName; }); - // worktree add can set origin/main as upstream; this base is not the branch's published PR head. + // worktree add can set the branch's base as upstream; that is not its published PR head. const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* ( cwd: string, details: { @@ -1808,15 +1809,16 @@ export const make = Effect.gen(function* () { }; } } - const upstreamHeadIsDefault = + if (headContext.headBranch === details.branch || headContext.isCrossRepository) { + return { headContext, lookup: true }; + } + const upstreamHeadIsBase = headContext.headBranch === details.defaultBranch || (details.defaultBranch === null && - (headContext.headBranch === "main" || headContext.headBranch === "master")); - if ( - headContext.headBranch === details.branch || - !upstreamHeadIsDefault || - headContext.isCrossRepository - ) { + (headContext.headBranch === "main" || headContext.headBranch === "master")) || + headContext.headBranch === + (yield* gitCore.readBranchBase(cwd, details.branch).pipe(Effect.orElseSucceed(() => null))); + if (!upstreamHeadIsBase) { return { headContext, lookup: true }; } const remoteName = yield* findRemoteTrackingRemote( @@ -2156,6 +2158,7 @@ export const make = Effect.gen(function* () { ...(input.includeBranch ? { includeBranch: true } : {}), ...(policy ? { policy } : {}), modelSelection: input.settings.modelSelection, + projectId: input.settings.projectId, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -2386,6 +2389,7 @@ export const make = Effect.gen(function* () { ...(threadContext ? { threadContext } : {}), ...(policy ? { policy } : {}), modelSelection: settings.modelSelection, + projectId: settings.projectId, }); const bodyFile = path.join( @@ -3231,6 +3235,7 @@ export const make = Effect.gen(function* () { settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, + projectId, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( @@ -3240,6 +3245,7 @@ export const make = Effect.gen(function* () { providers, projectId, ), + projectId, style: settings.sourceControlWritingStyle, })), ), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 58a23d6b986be..2d3ca8afb4ac1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -38,7 +38,7 @@ import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, { - readonly isRepository: (cwd: string) => Effect.Effect; + readonly repositoryRoot: (cwd: string) => Effect.Effect; readonly hasCommit: (input: { readonly cwd: string; readonly refName: string; @@ -273,13 +273,13 @@ export const make = Effect.gen(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ - isRepository: Effect.fn("GitWorkflowService.isRepository")(function (cwd) { + repositoryRoot: Effect.fn("GitWorkflowService.repositoryRoot")(function (cwd) { return registry.detect({ cwd }).pipe( - Effect.map((handle) => handle?.kind === "git"), + Effect.map((handle) => (handle?.kind === "git" ? handle.repository.rootPath : null)), Effect.mapError( (cause) => new GitManagerError({ - operation: "GitWorkflowService.isRepository", + operation: "GitWorkflowService.repositoryRoot", cwd, detail: "Failed to detect a VCS repository for this Git workflow.", cause, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index f6103b8c2a33f..a5d83ce16176e 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -891,10 +891,21 @@ it.effect("bounds large ARIA trees without dropping every server locator", () => { length: 2_000 }, (_, index) => `- button "删除 ${index}" [ref=t3-snapshot-e${index}]`, ).join("\n"); - yield* serveSnapshots("mcp-large-server-refs", { ...snapshotResult, accessibilityTree }); + const visibleText = Array.from({ length: 700 }, (_, index) => `Row ${index} value`).join( + "\n", + ); + yield* serveSnapshots("mcp-large-server-refs", { + ...snapshotResult, + visibleText, + accessibilityTree, + }); const result = yield* callSnapshot({ includeImage: false }); expect(result.isError).toBe(false); - const metadata = result.structuredContent as { accessibilityTree: string }; + const metadata = result.structuredContent as { + accessibilityTree: string; + visibleText: string; + }; + expect(metadata.visibleText.slice(0, 1_000)).toBe(visibleText.slice(0, 1_000)); expect(metadata.accessibilityTree).toContain("[ref=t3-snapshot-e0]"); expect(metadata.accessibilityTree.length).toBeLessThan(accessibilityTree.length); const body = result.content[1]; diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index d669463fe36dd..19284896bb500 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -256,6 +256,8 @@ const layerMcpAuthMiddleware = HttpRouter.middleware<{ /** Claude Code spills oversized results to files, losing locators; bound both text and its preferred structuredContent. */ export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; +// Page text and the ARIA tree both shrink to this before either drops below it. +const SNAPSHOT_TEXT_FLOOR_CHARS = 2_000; const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; const MAX_SNAPSHOT_LOG_ENTRIES = 40; const MAX_SNAPSHOT_LOG_TEXT_CHARS = 500; @@ -299,6 +301,11 @@ type SnapshotMetadata = { readonly [key: string]: unknown; }; +const halveToFloor = (chars: number) => + chars > SNAPSHOT_TEXT_FLOOR_CHARS + ? Math.max(SNAPSHOT_TEXT_FLOOR_CHARS, Math.floor(chars / 2)) + : Math.floor(chars / 2); + const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { const omitted: Array = []; const { accessibilityTree, ...withoutTree } = metadata; @@ -371,23 +378,27 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { }); let text = encodeJsonText(value()); while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) { - // Elements carry the locators, so they go last; logs shed newest-last. + // Logs shed first, oldest first. Below the floors, locator-bearing elements and tree go last. const key = shedOrder.find( (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0, ) ?? - (visibleTextChars > 0 + (visibleTextChars > SNAPSHOT_TEXT_FLOOR_CHARS ? "visibleText" - : lists.interactiveElements.length > 0 - ? "interactiveElements" - : ariaTreeChars > 0 - ? "accessibilityTree" - : undefined); + : ariaTreeChars > SNAPSHOT_TEXT_FLOOR_CHARS + ? "accessibilityTree" + : visibleTextChars > 0 + ? "visibleText" + : lists.interactiveElements.length > 0 + ? "interactiveElements" + : ariaTreeChars > 0 + ? "accessibilityTree" + : undefined); if (key === undefined) break; if (key === "visibleText") { - visibleTextChars = Math.floor(visibleTextChars / 2); + visibleTextChars = halveToFloor(visibleTextChars); } else if (key === "accessibilityTree") { - ariaTreeChars = Math.floor(ariaTreeChars / 2); + ariaTreeChars = halveToFloor(ariaTreeChars); } else { const keep = Math.floor(lists[key].length / 2); dropped[key] += lists[key].length - keep; diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 5437587728f13..cd9eb33f3936b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -96,15 +96,6 @@ describe("OrchestratorMcpService", () => { const parentProjection = { thread: { id: parentThreadId }, runs: [], - contextTransfers: [ - { - id: resultTransferId, - type: "subagent_result", - sourceThreadId: childThreadId, - targetThreadId: parentThreadId, - sourcePoint: { runId: childRunId }, - }, - ], subagents: [ { id: taskId, @@ -136,7 +127,15 @@ describe("OrchestratorMcpService", () => { completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), }, ], - contextTransfers: [], + contextTransfers: [ + { + id: resultTransferId, + type: "subagent_result", + sourceThreadId: childThreadId, + targetThreadId: parentThreadId, + sourcePoint: { runId: childRunId }, + }, + ], messages: [ { id: MessageId.make("message:mcp-ack-result"), @@ -165,6 +164,7 @@ describe("OrchestratorMcpService", () => { return hasNestedWork ? { ...childProjection, + contextTransfers: [], subagents: [ { ...parentProjection.subagents[0]!, status: "running" as const }, ], @@ -174,7 +174,6 @@ describe("OrchestratorMcpService", () => { const snapshot = hasNestedWork ? { ...parentProjection, - contextTransfers: [], subagents: parentProjection.subagents.map((task) => ({ ...task, result: null, @@ -603,6 +602,8 @@ describe("OrchestratorMcpService", () => { listAppOwnedChildThreadIds: () => Effect.succeed([]), delegatedTaskResultPending: () => Effect.succeed(false), stopDelegatedTasks: () => Effect.void, + cancelDelegatedTask: ({ command }) => + Ref.update(dispatched, (commands) => [...commands, command]), // The orchestrator's subagent.stop repair is covered in ThreadStop.test.ts. dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( @@ -706,6 +707,12 @@ describe("OrchestratorMcpService", () => { Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), ), + stopDelegatedTasks: () => Effect.void, + cancelDelegatedTask: ({ command, stopDescendants }) => + stopDescendants(() => Effect.die("No remote descendants in this test")).pipe( + Effect.andThen(Ref.update(dispatched, (commands) => [...commands, command])), + Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), + ), }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), @@ -796,6 +803,10 @@ describe("OrchestratorMcpService", () => { ), ), stopDelegatedTasks: () => Effect.void, + cancelDelegatedTask: ({ command, stopDescendants }) => + stopDescendants(() => Effect.die("No remote descendants in this test")).pipe( + Effect.andThen(Ref.update(dispatched, (commands) => [...commands, command])), + ), }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), @@ -887,6 +898,10 @@ describe("OrchestratorMcpService", () => { ), ), stopDelegatedTasks: () => Effect.void, + cancelDelegatedTask: ({ command, stopDescendants }) => + stopDescendants(() => Effect.die("No remote descendants in this test")).pipe( + Effect.andThen(Ref.update(dispatched, (commands) => [...commands, command])), + ), }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), @@ -1053,6 +1068,10 @@ describe("OrchestratorMcpService", () => { Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.as({} as never), ), + cancelDelegatedTask: ({ command, stopDescendants }) => + stopDescendants(() => Effect.die("No remote descendants in this test")).pipe( + Effect.andThen(Ref.update(dispatched, (commands) => [...commands, command])), + ), stopDelegatedTasks: () => Effect.gen(function* () { const limit = yield* DispatchModeLimit; @@ -3366,7 +3385,13 @@ describe("OrchestratorMcpService provider resolution", () => { Effect.gen(function* () { const saved = yield* Ref.make>([]); const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide(service(masterRepository, saved)), + Effect.provide( + service(masterRepository, saved, { + ...existing, + threadId: ThreadId.make("thread:scheduled-base"), + threadMode: "continue", + }), + ), ); yield* mcp.updateScheduledTask(client, { scheduledTaskId: existing.id, @@ -3378,6 +3403,20 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("keeps the saved workspace when an unbound task stays unbound", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(masterRepository, saved)), + ); + yield* mcp.updateScheduledTask(client, { + scheduledTaskId: existing.id, + bindToCurrentThread: false, + }); + assert.deepEqual(yield* Ref.get(saved), [{ type: "root" }]); + }), + ); + it.effect.each([ { name: "no remote, only master", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 992b8804f2449..4a07e95235a1b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -116,7 +116,7 @@ import type { ProjectionRecordField, ProjectionRecordFilter, } from "../orchestration-v2/ProjectionStore.ts"; -import { runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts"; +import { delegatedTaskResultRun, runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; @@ -641,8 +641,9 @@ function directAppOwnedChildTasks( ); } +// The child's transfers include the results it sent, so the parent's transfers are never read. function taskResultTransfer( - parent: Pick, + parentThreadId: ThreadId, child: Pick, task: OrchestrationV2Subagent, ) { @@ -657,18 +658,18 @@ function taskResultTransfer( .map((run) => run.id), ); if (originalRun !== undefined) resultRunIds.add(originalRun.id); - return parent.contextTransfers.find( + return child.contextTransfers.find( (transfer) => transfer.type === "subagent_result" && transfer.sourceThreadId === task.childThreadId && - transfer.targetThreadId === parent.thread.id && + transfer.targetThreadId === parentThreadId && ((transfer.sourcePoint.runId !== undefined && resultRunIds.has(transfer.sourcePoint.runId)) || (transfer.sourcePoint.runId === undefined && originalRun?.delegatedTaskId === undefined)), ); } function pageIncludesTerminalTaskResult(input: { - readonly parent: Pick; + readonly parentThreadId: ThreadId; readonly page: ReadonlyArray; readonly task: OrchestrationV2Subagent; readonly target: Pick< @@ -677,7 +678,7 @@ function pageIncludesTerminalTaskResult(input: { >; readonly maxChars: number; }): boolean { - const transfer = taskResultTransfer(input.parent, input.target, input.task); + const transfer = taskResultTransfer(input.parentThreadId, input.target, input.task); if (transfer === undefined) return false; const run = transfer.sourcePoint.runId === undefined @@ -1085,8 +1086,8 @@ function turnItemText( return `Asked the user for ${item.label}: ${item.secretStatus}.`; case "link_request": return item.linkStatus === "linked" - ? `Linked ${item.linkedLabel ?? item.label ?? item.url ?? "an environment"} (${item.linkedAccess}).` - : `Asked the user to link ${item.label ?? item.url ?? "an environment"}: ${item.linkStatus}.`; + ? `Linked ${item.linkedLabel ?? item.url ?? "an environment"} (${item.linkedAccess}).` + : `Asked the user to link ${item.url ?? "an environment"}: ${item.linkStatus}.`; case "subagent": return item.result ?? item.progress ?? item.prompt; case "dynamic_tool": @@ -1252,20 +1253,11 @@ const make = Effect.gen(function* () { ), ); - const loadProjection = (threadId: ThreadId, includeUserMessages = true) => - loadThreadRecords( - threadId, - [ - "runs", - "messages", - "providerThreads", - "providerTurns", - "runtimeRequests", - "contextTransfers", - "turnItems", - ], - { turnItemTypes: [], messageRoles: includeUserMessages ? ["user"] : [] }, - ); + // Runs on every MCP call, and long threads hold thousands of records; callers need only active runs. + const loadCallerRecords = (threadId: ThreadId, fields: ReadonlyArray) => + loadThreadRecords(threadId, fields, { + runStatuses: ThreadManagementService.ACTIVE_RUN_STATUSES, + }); // A dispatcher thread holds thousands of tasks, each with its full prompt; read only the ones named. const loadTasks = (threadId: ThreadId, taskIds: ReadonlyArray) => @@ -1276,43 +1268,9 @@ const make = Effect.gen(function* () { ), ); - // Task waits read on every wake of a parent that may hold thousands of - // tasks, so they read only the task they watch and what its status needs. - const loadTaskParent = (threadId: ThreadId) => - threadManagement - .getThreadRecords(threadId, ["runs", "contextTransfers"]) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to read thread ${threadId}: ${errorMessage(error)}`, - ), - ), - ); - - const loadProjectThread = ( - projectId: OrchestrationV2ThreadProjection["thread"]["projectId"], - threadId: ThreadId, - ) => - threadManagement - .getProjectThreadRecords( - { projectId, threadId }, - [ - "runs", - "messages", - "subagents", - "providerThreads", - "providerTurns", - "runtimeRequests", - "contextTransfers", - "turnItems", - ], - { turnItemTypes: [], messageRoles: ["user"] }, - ) - .pipe(Effect.mapError(threadManagementFailure)); - - const loadCaller = Effect.fn("OrchestratorMcpService.loadCaller")(function* ( + const loadCaller = Effect.fn("OrchestratorMcpService.loadCaller")(function* ( scope: McpInvocationScope, + fields: ReadonlyArray, ) { yield* requireCapability(scope); if (scope.thread === undefined) { @@ -1321,8 +1279,7 @@ const make = Effect.gen(function* () { limits: clientModeCeiling(scope.client), } as const; } - // No loadCaller consumer reads the caller's own prompts. - const parent = yield* loadProjection(scope.thread.threadId, false); + const parent = yield* loadCallerRecords(scope.thread.threadId, fields); return { parent, limits: { @@ -1338,7 +1295,7 @@ const make = Effect.gen(function* () { ) { yield* requireCapability(scope); const threadScope = yield* requireThreadScope(scope, operation); - const parent = yield* loadProjection(threadScope.thread.threadId); + const parent = yield* loadCallerRecords(threadScope.thread.threadId, ["runs"]); return { scope: threadScope, parent } as const; }); @@ -1359,7 +1316,6 @@ const make = Effect.gen(function* () { const loadTargetThread = Effect.fn("OrchestratorMcpService.loadTargetThread")(function* ( threadId: ThreadId, - includeHistory = true, ) { const shell = yield* threadManagement .getThreadShell(threadId) @@ -1367,24 +1323,21 @@ const make = Effect.gen(function* () { if (shell === null || shell.deletedAt !== null) { return yield* threadNotFound(threadManagement, threadId, `Thread ${threadId} was not found.`); } - // Native wait owns its history reads; keep legacy import and project validation here. - return yield* includeHistory - ? loadProjectThread(shell.projectId, threadId) - : threadManagement - .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) - .pipe(Effect.mapError(threadManagementFailure)); + // Keeps legacy import and project validation; callers read only the thread row. + return yield* threadManagement + .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) + .pipe(Effect.mapError(threadManagementFailure)); }); - const loadScopedThread = Effect.fn("OrchestratorMcpService.loadScopedThread")(function* ( - scope: McpInvocationScope, - threadId: ThreadId, - includeHistory = true, - ) { - const caller = yield* loadCaller(scope); - const target = + const loadScopedThread = Effect.fn("OrchestratorMcpService.loadScopedThread")(function* < + K extends "runs", + >(scope: McpInvocationScope, threadId: ThreadId, callerFields: ReadonlyArray) { + const caller = yield* loadCaller(scope, callerFields); + // Only the thread row: the caller's own record holds just its active runs. + const target: Pick = caller.parent !== undefined && threadId === caller.parent.thread.id ? caller.parent - : yield* loadTargetThread(threadId, includeHistory); + : yield* loadTargetThread(threadId); return { ...caller, target } as const; }); @@ -1438,7 +1391,7 @@ const make = Effect.gen(function* () { scope: McpInvocationScope, threadId: ThreadId, ) { - const { parent } = yield* loadCaller(scope); + const { parent } = yield* loadCaller(scope, []); const shell = yield* threadManagement .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); @@ -1690,7 +1643,6 @@ const make = Effect.gen(function* () { if (task.childThreadId === null) { return yield* readRemoteTask(scope, task, waitTimedOut, acknowledgeTerminal); } - const parentProjection = yield* loadTaskParent(scope.thread.threadId); const childControls = yield* threadManagement .getThreadRecords( task.childThreadId, @@ -1713,9 +1665,7 @@ const make = Effect.gen(function* () { const progress = delegatedTaskProgress(childControls); const taskResultRun = childRun?.delegatedTaskId === task.id - ? childControls.runs - .filter((run) => run.delegatedTaskId === task.id) - .toSorted((a, b) => b.ordinal - a.ordinal)[0] + ? delegatedTaskResultRun(childControls.runs, task.id) : progress.resultRun; const taskWorkState = childRun?.delegatedTaskId === task.id @@ -1760,7 +1710,11 @@ const make = Effect.gen(function* () { : childRun, ) : workState === "result_available" - ? taskStatusForRun(taskResultRun ?? childRun) + ? // A cancelled task's finished turn settles it as interrupted. + task.cancelledRunId !== undefined && + taskStatusForRun(taskResultRun ?? childRun) === "completed" + ? "interrupted" + : taskStatusForRun(taskResultRun ?? childRun) : taskStatusForRun(taskResultRun ?? childRun) === "queued" ? "queued" : "running"; @@ -1770,7 +1724,7 @@ const make = Effect.gen(function* () { : taskResultRun !== undefined && isTerminalTaskStatus(status) ? subagentResultForRun(childProjection, taskResultRun).text : null; - const resultTransfers = parentProjection.contextTransfers.filter( + const resultTransfers = childControls.contextTransfers.filter( (transfer) => transfer.type === "subagent_result" && transfer.sourceThreadId === task.childThreadId && @@ -1784,7 +1738,7 @@ const make = Effect.gen(function* () { ? resultTransfers.find((transfer) => transfer.sourcePoint.runId === undefined) : undefined) ?? null); - const resultTransfer = taskResultTransfer(parentProjection, childProjection, task) ?? null; + const resultTransfer = taskResultTransfer(scope.thread.threadId, childProjection, task) ?? null; const terminalStatus = terminalRun === undefined ? null : taskStatusForRun(terminalRun); const latestTerminalSummary = canExposeTaskRunResult(terminalRun) ? terminalRun.id === childRun?.id @@ -1853,6 +1807,7 @@ const make = Effect.gen(function* () { task.completionDelivery?.state !== "acknowledged" && task.completionDelivery?.state !== "disposed" ) { + const parentProjection = yield* loadCallerRecords(scope.thread.threadId, ["runs"]); const observingRun = ThreadManagementService.latestActiveRun({ runs: parentProjection.runs.filter( (run) => run.providerInstanceId === scope.thread.providerInstanceId, @@ -1938,17 +1893,7 @@ const make = Effect.gen(function* () { task.completionDelivery?.state !== "acknowledged" && task.completionDelivery?.state !== "disposed" ) { - const parentThreadId = scope.thread.threadId; - const parentProjection = yield* threadManagement - .getThreadRecords(parentThreadId, ["runs"]) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to read thread ${parentThreadId}: ${errorMessage(error)}`, - ), - ), - ); + const parentProjection = yield* loadCallerRecords(scope.thread.threadId, ["runs"]); const observingRun = ThreadManagementService.latestActiveRun({ runs: parentProjection.runs.filter( (run) => run.providerInstanceId === scope.thread.providerInstanceId, @@ -2191,7 +2136,7 @@ const make = Effect.gen(function* () { return OrchestratorMcpService.of({ scheduleTask: Effect.fn("OrchestratorMcpService.scheduleTask")(function* (scope, input) { - const { parent, limits } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope, ["runs"]); const projectId = yield* resolveProjectTarget(parent, input.projectId); yield* assertLiveCallerForOtherProject(scope, parent, projectId); const project = yield* requireProject(projectId); @@ -2252,7 +2197,7 @@ const make = Effect.gen(function* () { }), listScheduledTasks: Effect.fn("OrchestratorMcpService.listScheduledTasks")( function* (scope, input) { - const { parent, limits } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope, ["runs"]); const projectId = input.projectId ?? parent?.thread.projectId; const { tasks } = yield* scheduledTasks .list() @@ -2271,7 +2216,7 @@ const make = Effect.gen(function* () { ), updateScheduledTask: Effect.fn("OrchestratorMcpService.updateScheduledTask")( function* (scope, input) { - const { parent, limits } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope, ["runs"]); const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); if ( @@ -2303,11 +2248,11 @@ const make = Effect.gen(function* () { : input.bindToCurrentThread && parent !== undefined ? parent.thread.id : null; - // Update workspace strategy on rebinding so unbound root tasks cannot run in the shared checkout. + // Reset the workspace only when the binding flips, so unbinding cannot leave runs in the shared checkout. const workspaceStrategy = - input.bindToCurrentThread === undefined + (threadId === null) === (existing.threadId === null) ? existing.workspaceStrategy - : yield* scheduledTaskWorkspaceStrategy(input.bindToCurrentThread, existing.projectId); + : yield* scheduledTaskWorkspaceStrategy(threadId !== null, existing.projectId); const upsertInput: ScheduledTaskUpsertInput = { id: existing.id, title: input.title ?? existing.title, @@ -2347,7 +2292,7 @@ const make = Effect.gen(function* () { ), deleteScheduledTask: Effect.fn("OrchestratorMcpService.deleteScheduledTask")( function* (scope, input) { - const { parent, limits } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope, ["runs"]); const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); yield* scheduledTasks @@ -2513,7 +2458,7 @@ const make = Effect.gen(function* () { }), capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope, input = {}) { - const { parent, limits } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope, []); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); const settings = yield* serverSettings.getSettings.pipe( @@ -2909,13 +2854,25 @@ const make = Effect.gen(function* () { const stopChild = Effect.gen(function* () { const commandId = stableCommandId({ scope, requestKey: key, operation: "cancel-task" }); const reason = agentStopReason(input.reason); + const command = { + type: "thread.stop" as const, + commandId, + threadId: childThreadId, + stopDelegatedTasks: false, + reason, + }; yield* threadManagement - .dispatch({ - type: "thread.stop", - commandId, - threadId: childThreadId, - stopDelegatedTasks: false, - reason, + .cancelDelegatedTask({ + parentThreadId: scope.thread.threadId, + taskId: input.taskId, + command, + stopDescendants: (remoteDispatch) => + threadManagement.stopDelegatedTasks({ + threadId: childThreadId, + commandId, + reason, + remoteDispatch, + }), }) .pipe( Effect.mapError((error) => @@ -2925,16 +2882,6 @@ const make = Effect.gen(function* () { ), ), ); - yield* threadManagement - .stopDelegatedTasks({ threadId: childThreadId, commandId, reason }) - .pipe( - Effect.mapError((error) => - failure( - "task_not_cancellable", - `Stopped delegated task ${input.taskId}, but not every task it delegated: ${errorMessage(error)}`, - ), - ), - ); const stopped = yield* threadManagement .getThreadRecords(childThreadId, ["runs"]) .pipe(Effect.mapError(threadManagementFailure)); @@ -3179,7 +3126,7 @@ const make = Effect.gen(function* () { ), ); } - const projection = yield* loadProjection(threadId); + const projection = yield* loadThreadRecords(threadId, ["runs"]); const run = projection.runs.at(-1); yield* threadManagement .dispatch({ @@ -3225,7 +3172,7 @@ const make = Effect.gen(function* () { }, ), listThreads: Effect.fn("OrchestratorMcpService.listThreads")(function* (scope, input) { - const { parent } = yield* loadCaller(scope); + const { parent } = yield* loadCaller(scope, []); const projectId = input.allProjects ? null : yield* resolveProjectTarget(parent, input.projectId); @@ -3330,7 +3277,7 @@ const make = Effect.gen(function* () { (input.textOffset ?? 0) === 0 ) { for (const task of tasks) { - const transfer = taskResultTransfer(parent, target, task); + const transfer = taskResultTransfer(parent.thread.id, target, task); const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id; const resultRunIds = resultRunId === undefined ? [] : [resultRunId]; const resultRecords = yield* threadManagement @@ -3343,7 +3290,7 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError(threadManagementFailure)); if ( pageIncludesTerminalTaskResult({ - parent, + parentThreadId: parent.thread.id, page, task, target: { ...target, ...resultRecords }, @@ -3390,7 +3337,7 @@ const make = Effect.gen(function* () { sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")( function* (scope, input, attachContext) { yield* assertNotSelf(scope.thread?.threadId, input.threadId); - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); @@ -3465,7 +3412,7 @@ const make = Effect.gen(function* () { ), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { yield* assertNotSelf(scope.thread?.threadId, input.threadId); - const { parent, target } = yield* loadScopedThread(scope, input.threadId, false); + const { parent, target } = yield* loadScopedThread(scope, input.threadId, []); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -3524,7 +3471,7 @@ const make = Effect.gen(function* () { }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { yield* assertNotSelf(scope.thread?.threadId, input.threadId); - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); @@ -3612,7 +3559,7 @@ const make = Effect.gen(function* () { resumeUsageLimitedThread: Effect.fn("OrchestratorMcpService.resumeUsageLimitedThread")( function* (scope, input) { yield* assertNotSelf(scope.thread?.threadId, input.threadId); - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); // Resuming starts a turn in the target, so it must run within the caller's modes. yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); @@ -3686,11 +3633,11 @@ const make = Effect.gen(function* () { rollbackThread: Effect.fn("OrchestratorMcpService.rollbackThread")(function* (scope, input) { // Its own thread would have the files and history of its running turn rewritten. yield* assertNotSelf(scope.thread?.threadId, input.threadId); - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); - const projection = yield* loadProjectThread(target.thread.projectId, input.threadId); + const projection = yield* loadThreadRecords(input.threadId, ["runs"]); if (ThreadManagementService.latestActiveRun(projection) !== undefined) { return yield* failure( "invalid_request", diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index efb05aaa6570a..c27f363b44f41 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -221,13 +221,34 @@ describe("orchestrator MCP toolkit", () => { }, CodexOrchestratorReplayHarness, ); + // SQL spans carry no bound parameters, so this records which thread each tool read. + const threadRecordReads: Array<{ + readonly threadId: ThreadId; + readonly fields: ReadonlyArray; + }> = []; const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), + Layer.effect( + ThreadManagementService.ThreadManagementService, + Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + return ThreadManagementService.ThreadManagementService.of({ + ...service, + getThreadRecords: (threadId, fields, filter) => + Effect.sync(() => threadRecordReads.push({ threadId, fields })).pipe( + Effect.andThen(service.getThreadRecords(threadId, fields, filter)), + ), + }); + }), + ).pipe( Layer.provide( - CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), ), ), ); @@ -610,6 +631,9 @@ describe("orchestrator MCP toolkit", () => { ); const transferReads: Array = []; const messageReads: Array = []; + const runReads: Array = []; + const projectionTableReads: Array = []; + const acknowledgementReads: Array = []; const tracer = Tracer.make({ span(options) { const span = new Tracer.NativeSpan(options); @@ -618,11 +642,29 @@ describe("orchestrator MCP toolkit", () => { end(endTime, exit); const query = span.attributes.get("db.query.text"); if (typeof query !== "string") return; + let parent = Option.getOrUndefined(span.parent); + while (parent?._tag === "Span") { + if ( + parent.name === "Orchestrator.dispatchDelegatedTaskCompletionDeliveryResolution" + ) { + acknowledgementReads.push(query); + break; + } + parent = Option.getOrUndefined(parent.parent); + } + for (const [, table] of query.matchAll( + /FROM orchestration_v2_projection_(\w+)/gi, + )) { + projectionTableReads.push(table!); + } if ( /SELECT[\s\S]*FROM orchestration_v2_projection_context_transfers/i.test(query) ) { transferReads.push(query); } + if (/SELECT\s+payload_json\s+FROM orchestration_v2_projection_runs/i.test(query)) { + runReads.push(query); + } if ( /SELECT\s+payload_json\s+FROM orchestration_v2_projection_messages/i.test(query) ) { @@ -695,6 +737,18 @@ describe("orchestrator MCP toolkit", () => { expect(read.isError).not.toBe(true); expect(transferReads.length).toBeGreaterThan(0); expect(read.structuredContent).toEqual(parentlessRead.structuredContent); + // The acknowledgement reads the task and its spawning run by id, not the parent's history. + expect(acknowledgementReads.length).toBeGreaterThan(0); + expect( + acknowledgementReads.filter( + (query) => + /FROM orchestration_v2_projection_(nodes|run_attempts)\b/.test(query) || + (/FROM orchestration_v2_projection_runs\b/.test(query) && + !query.includes("AND run_id IN")) || + (/FROM orchestration_v2_projection_subagents\b/.test(query) && + !query.includes("AND subagent_id IN")), + ), + ).toEqual([]); const afterRead = yield* orchestrator.getThreadProjection(parentThreadId); expect( afterRead.subagents.find((task) => task.id === queuedFollowup.taskId) @@ -702,11 +756,34 @@ describe("orchestrator MCP toolkit", () => { ).toBe("acknowledged"); const messageReadsBeforeCapabilities = messageReads.length; + const tableReadsBeforeCapabilities = projectionTableReads.length; const capabilities = yield* invoke("orchestrator_capabilities", {}).pipe( Effect.withTracer(tracer), ); expect(capabilities.isError).not.toBe(true); expect(messageReads).toHaveLength(messageReadsBeforeCapabilities); + // The caller check reads only the thread row, never its history. + expect(new Set(projectionTableReads.slice(tableReadsBeforeCapabilities))).toEqual( + new Set(["threads"]), + ); + const runReadsBeforeStatus = runReads.length; + const transferReadsBeforeStatus = transferReads.length; + const threadRecordReadsBeforeStatus = threadRecordReads.length; + const statusAgain = yield* invoke("task_status", { taskId: delegated.taskId }).pipe( + Effect.withTracer(tracer), + ); + expect(statusAgain.isError).not.toBe(true); + // A task wait reads result transfers and runs from the child; the parent's stay unread. + expect(runReads).toHaveLength(runReadsBeforeStatus + 1); + expect(transferReads).toHaveLength(transferReadsBeforeStatus + 1); + expect( + threadRecordReads + .slice(threadRecordReadsBeforeStatus) + .filter( + ({ fields }) => fields.includes("runs") || fields.includes("contextTransfers"), + ) + .map(({ threadId }) => threadId), + ).toEqual([delegated.childThreadId]); }).pipe(Effect.provide(layerTest)); }), ), diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 105d53da27050..c36fc4363e620 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -4,6 +4,7 @@ import { assert, it } from "@effect/vitest"; import { CommandId, EnvironmentId, + EventId, MessageId, NodeId, ProjectId, @@ -11,6 +12,8 @@ import { ProviderInstanceId, ProviderThreadId, ProviderTurnId, + RunId, + RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2DomainEvent, @@ -18,13 +21,16 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -52,14 +58,25 @@ const driver = ProviderDriverKind.make("claudeAgent"); const instanceId = ProviderInstanceId.make("claudeAgent"); const modelSelection = { instanceId, model: "claude-test" }; -it.effect("task_cancel ends a delegated child's background native subagent", () => +// Cancellation races with turn completion and checkpoint capture; "recorded" restarts before the Stop outbox runs. +const cancelBackgroundOnlyTask = ( + background: "subagent" | "command", + { + cancelAt = "settled", + remote = false, + }: { + readonly remote?: boolean; + readonly cancelAt?: "settled" | "checkpoint" | "turn" | "recorded" | "monitor" | "refused"; + } = {}, +) => Effect.scoped( Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("task-cancel-native-subagent"); + const cwd = yield* checkpointWorkspace(`task-cancel-background-${background}`); const turns: Array<{ readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; readonly events: Queue.Queue; }> = []; + const interrupted: Array | undefined> = []; const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { instanceId, driver, @@ -125,7 +142,10 @@ it.effect("task_cancel ends a delegated child's background native subagent", () }); }), steerTurn: () => Effect.die("unused"), - interruptTurn: () => Effect.void, + interruptTurn: ({ providerThread }) => + Effect.sync(() => { + interrupted.push(providerThread.pendingBackgroundTasks?.map((t) => t.taskId)); + }), respondToRuntimeRequest: () => Effect.die("unused"), readThreadSnapshot: () => Effect.die("unused"), rollbackThread: () => Effect.die("unused"), @@ -134,14 +154,54 @@ it.effect("task_cancel ends a delegated child's background native subagent", () }), }; const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "task-cancel-native-subagent" }, - layerAdapters, - { runEffectWorker: false }, + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-task-cancel-" }); + const databaseLayer = SqlitePersistence.layerFromPath( + path.join(directory, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); + // Migrate the file first, as an existing install's database already is. + yield* Effect.provide(Effect.void, databaseLayer); + // Each build is a server on the same database; the second runs startup recovery. + let restarted = false; + const layerOrchestrator = Layer.suspend(() => + ProviderReplayHarness.layerWithRegistry( + { name: "task-cancel-native-subagent" }, + layerAdapters, + restarted + ? { databaseLayer, recoverOnStartup: true } + : { databaseLayer, runEffectWorker: false }, + ), ); const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe( + Layer.effect( + ThreadManagementService.ThreadManagementService, + Effect.map(ThreadManagementService.ThreadManagementService, (service) => + cancelAt === "refused" + ? { + ...service, + stopDelegatedTasks: () => + Effect.fail( + new Orchestrator.OrchestratorDispatchError({ + commandId: CommandId.make("refused-descendant-stop"), + commandType: "thread.stop", + cause: "descendant refused stop", + }), + ), + } + : service, + ), + ).pipe( + Layer.provide( + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), + ), Layer.provide(layerOrchestrator), Layer.provide(ServerSettings.layerTest()), Layer.provide( @@ -167,17 +227,25 @@ it.effect("task_cancel ends a delegated child's background native subagent", () ), ); - yield* Effect.gen(function* () { + const stopped = yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const eventSink = yield* EventSink.EventSinkV2; const parentThreadId = ThreadId.make("thread:task-cancel-parent"); + // Read the cursor before forking: provider events offered right after a watch can + // commit before a live-tail subscriber has read its starting sequence. const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => - orchestrator.streamDomainEvents.pipe( - Stream.filter(predicate), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, + eventSink.latestSequence().pipe( + Effect.flatMap((afterSequence) => + eventSink.stream({ afterSequence }).pipe( + Stream.map((stored) => stored.event), + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ), + ), ); const turnRunning = () => watch( @@ -228,6 +296,41 @@ it.effect("task_cancel ends a delegated child's background native subagent", () createdBy: "agent", creationSource: "mcp", }); + if (cancelAt === "turn") { + const pendingTask = (yield* orchestrator.getThreadProjection(parentThreadId)) + .subagents[0]!; + const { runs } = yield* orchestrator.getThreadProjection(pendingTask.childThreadId!); + const now = yield* DateTime.now; + const current = runs[0]!; + const previous = { + ...current, + id: RunId.make("run:before-continuation"), + ordinal: 1, + status: "completed" as const, + startedAt: now, + completedAt: now, + }; + const at = { threadId: current.threadId, occurredAt: now }; + yield* eventSink.write({ + commandId: CommandId.make("command:runtime-reconcile:continuation"), + events: [ + { + ...at, + runId: current.id, + id: EventId.make("event:continuation"), + type: "run.updated", + payload: { ...current, ordinal: 2, restartContinuationOfRunId: previous.id }, + }, + { + ...at, + runId: previous.id, + id: EventId.make("event:previous"), + type: "run.updated", + payload: previous, + }, + ], + }); + } yield* worker.drain(); yield* Fiber.join(childRunning); const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; @@ -259,7 +362,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () event.payload.id === child.turn.runId && event.payload.status === "waiting", ); - for (const event of [ + const nativeSubagentEvents = [ { type: "node.updated", driver, @@ -308,6 +411,71 @@ it.effect("task_cancel ends a delegated child's background native subagent", () subagentId, }, }, + ] satisfies ReadonlyArray; + const shellEvents = [ + { + type: "provider_thread.updated", + driver, + providerThread: { + ...child.turn.providerThread, + status: "active", + pendingBackgroundTasks: [ + { taskId: "bg-sleep", description: "sleep 300", kind: "command", awaited: true }, + ], + updatedAt: now, + }, + }, + ] satisfies ReadonlyArray; + const started = yield* watch((event) => + background === "subagent" + ? event.type === "subagent.updated" && event.payload.id === subagentId + : event.type === "provider-thread.updated" && + event.threadId === task.childThreadId && + (event.payload.pendingBackgroundTasks?.length ?? 0) > 0, + ); + // An async question the child asked during its turn, and one no run of this task owns. + const question = (id: string, nodeId: NodeId) => + ({ + type: "runtime_request.updated", + driver, + runtimeRequest: { + id: RuntimeRequestId.make(id), + nodeId, + providerTurnId: childTurn.id, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + }) satisfies ProviderAdapter.ProviderAdapterV2Event; + for (const event of [ + question("request:owned", child.turn.rootNodeId), + question("request:unrelated", NodeId.make("node:unrelated")), + ...(background === "subagent" ? nativeSubagentEvents : shellEvents), + ]) { + yield* Queue.offer(child.events, event); + } + yield* Fiber.join(started); + if (remote) { + yield* orchestrator.dispatch({ + type: "delegated_task.remote.request", + commandId: CommandId.make("remote-descendant"), + parentThreadId: task.childThreadId!, + parentRunId: child.turn.runId, + parentNodeId: child.turn.rootNodeId, + task: "Work in a linked environment", + driver, + modelSelection, + remoteChild: { + environmentId: EnvironmentId.make("environment:remote-descendant"), + threadId: ThreadId.make("thread:remote-descendant"), + label: "Remote", + }, + }); + } + const turnEndEvents = [ { type: "provider_turn.updated", driver, @@ -323,11 +491,32 @@ it.effect("task_cancel ends a delegated child's background native subagent", () failure: null, threadDisposition: "reusable", }, - ] satisfies ReadonlyArray) { - yield* Queue.offer(child.events, event); + ] satisfies ReadonlyArray; + const endTurn = Effect.gen(function* () { + for (const event of turnEndEvents) yield* Queue.offer(child.events, event); + yield* Fiber.join(waiting); + }); + if (cancelAt !== "turn") yield* endTurn; + const turnSettled = + cancelAt !== "checkpoint" && cancelAt !== "turn" && cancelAt !== "recorded"; + if (turnSettled) yield* worker.drain(); + if (cancelAt === "monitor") { + // A monitor notification then ran and completed while the shell kept running. + const { runs, messages } = yield* orchestrator.getThreadProjection(task.childThreadId!); + const run = { ...runs[0]!, id: RunId.make("run:monitor"), ordinal: 2 }; + const source = { kind: "monitor" } as const; + const notification = { source, outcome: "updated", summary: "Ran" } as const; + const user = { ...messages[0]!, id: MessageId.make("message:monitor"), runId: run.id }; + const message = { ...user, notification }; + const at = { threadId: run.threadId, runId: run.id, occurredAt: now }; + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make("command:runtime-reconcile:monitor"), + events: [ + { ...at, id: EventId.make("event:m1"), type: "message.updated", payload: message }, + { ...at, id: EventId.make("event:m2"), type: "run.updated", payload: run }, + ], + }); } - yield* Fiber.join(waiting); - yield* worker.drain(); const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:task-cancel"), @@ -343,33 +532,166 @@ it.effect("task_cancel ends a delegated child's background native subagent", () }; const before = yield* service.taskStatus(scope, task.id); assert.equal(before.status, "running"); - assert.equal(before.workState, "waiting_for_children"); + assert.equal(before.workState, turnSettled ? "waiting_for_children" : "working"); + const parentTask = Effect.map(orchestrator.getThreadProjection(parentThreadId), (parent) => + parent.subagents.find((entry) => entry.id === task.id), + ); + if (cancelAt === "recorded") { + const commandId = CommandId.make("stop-recorded"); + yield* orchestrator.cancelDelegatedTask({ + parentThreadId, + taskId: task.id, + command: { + type: "thread.stop", + commandId, + threadId: task.childThreadId!, + stopDelegatedTasks: false, + }, + stopDescendants: () => Effect.void, + }); + const committed = yield* eventSink.readByCommandId({ commandId }).pipe(Stream.runCollect); + assert.isTrue( + committed.some( + ({ event }) => + event.type === "subagent.updated" && event.payload.cancelledRunId !== undefined, + ), + ); + assert.isTrue( + committed.some( + ({ event }) => + event.type === "turn-item.updated" && + event.payload.type === "run_interrupt_request", + ), + ); + assert.equal((yield* parentTask)?.status, "running"); + return { scope, taskId: task.id, settled: undefined }; + } + if (cancelAt === "refused") { + yield* Effect.flip( + service.cancelTask(scope, { taskId: task.id, clientRequestId: "refused-cancel" }), + ); + const after = yield* parentTask; + assert.equal(after?.status, "running"); + assert.equal(after?.result, null); + assert.isUndefined(after?.cancelledRunId); + return undefined; + } + // Settling the task ends the async question it left, after its own row. + const settled = yield* watch( + (event) => + event.type === "runtime-request.updated" && + event.payload.id === "request:owned" && + event.payload.status === "resolved", + ); const cancelled = yield* service.cancelTask(scope, { taskId: task.id, clientRequestId: "cancel-task", }); assert.equal(cancelled.status, "cancel_requested"); + assert.equal((yield* parentTask)?.cancelledRunId, child.turn.runId); + // The turn completes normally after Stop, before its interrupt reaches the provider. + if (cancelAt === "turn") yield* endTurn; yield* worker.drain(); + const owned = Effect.map(orchestrator.getThreadProjection(task.childThreadId!), (child) => + child.runtimeRequests.find((request) => request.id === "request:owned"), + ); + // task_cancel settles a finished turn itself; a running one settles when it ends. + if ((yield* owned)?.status !== "resolved") yield* Fiber.join(settled); const childAfter = yield* orchestrator.getThreadProjection(task.childThreadId!); + const nativeStatus = background === "subagent" ? "interrupted" : undefined; assert.equal( childAfter.subagents.find((entry) => entry.id === subagentId)?.status, - "interrupted", + nativeStatus, ); assert.equal( childAfter.nodes.find((entry) => entry.id === subagentId)?.status, - "interrupted", + nativeStatus, ); assert.equal( - childAfter.turnItems.find((item) => item.type === "subagent")?.status, - "interrupted", + childAfter.turnItems.find( + (item) => item.type === "subagent" && item.subagentId === subagentId, + )?.status, + nativeStatus, + ); + if (remote) { + const descendant = childAfter.subagents.find((entry) => entry.remoteChild !== undefined); + assert.equal(descendant?.status, "interrupted"); + assert.notEqual(descendant?.remoteChild?.stoppedThere, true); + } + // Stop reached the provider turn that owns the shell. + if (background === "command") assert.deepEqual(interrupted, [["bg-sleep"]]); + assert.equal(childAfter.runs.length, cancelAt === "monitor" || cancelAt === "turn" ? 2 : 1); + assert.equal(childAfter.runs[0]!.status, "completed"); + assert.deepEqual( + childAfter.runtimeRequests.map((request) => [request.id, request.status]), + [ + ["request:owned", "resolved"], + ["request:unrelated", "pending"], + ], ); const after = yield* service.taskStatus(scope, task.id); - assert.equal(after.status, "completed"); - assert.notEqual(after.workState, "waiting_for_children"); + assert.equal(after.status, "interrupted"); + assert.equal(after.workState, "result_available"); + const settledTask = yield* parentTask; + assert.equal(settledTask?.status, "interrupted"); + assert.notEqual(settledTask?.completedAt, null); + assert.equal(settledTask?.cancelledRunId, child.turn.runId); + + return { scope, taskId: task.id, settled: settledTask }; + }).pipe(Effect.provide(layerTest)); + if (stopped === undefined) return; + restarted = true; + + // A server restarted on the same database runs startup recovery and keeps the outcome. + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* (yield* EffectWorker.OrchestrationEffectWorkerV2).drain(); + yield* orchestrator.recoverDelegatedTasks; + const task = (yield* orchestrator.getThreadProjection( + stopped.scope.thread!.threadId, + )).subagents.find((entry) => entry.id === stopped.taskId); + const status = yield* service.taskStatus(stopped.scope, stopped.taskId); + assert.equal(status.status, "interrupted"); + assert.equal(status.summary, task?.result); + assert.equal(task?.status, "interrupted"); + assert.notEqual(task?.completedAt, null); + if (stopped.settled !== undefined) assert.deepEqual(task, stopped.settled); }).pipe(Effect.provide(layerTest)); }), - ), + ).pipe(Effect.provide(NodeServices.layer)); + +it.effect("task_cancel ends a delegated child's background native subagent", () => + cancelBackgroundOnlyTask("subagent"), +); + +it.effect("task_cancel ends a delegated child's background shell command", () => + cancelBackgroundOnlyTask("command"), +); + +it.effect("task_cancel ends background work while the child's run captures its checkpoint", () => + cancelBackgroundOnlyTask("command", { cancelAt: "checkpoint" }), +); + +it.effect("task_cancel ends background work left by a turn that finishes after Stop", () => + cancelBackgroundOnlyTask("command", { cancelAt: "turn" }), +); + +it.effect("task_cancel ends the shell's question though a monitor ran after the turn", () => + cancelBackgroundOnlyTask("command", { cancelAt: "monitor" }), +); + +it.effect("cancellation and Stop commit together and recover before the outbox ran", () => + cancelBackgroundOnlyTask("subagent", { cancelAt: "recorded" }), +); + +it.effect("a refused descendant stop does not settle or dispose its parent task", () => + cancelBackgroundOnlyTask("command", { cancelAt: "refused" }), +); + +it.effect("task_cancel stops a remote descendant without reacquiring its parent lock", () => + cancelBackgroundOnlyTask("command", { remote: true }), ); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 4bf90f0182fc4..48d972c5be2fd 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -48,7 +48,8 @@ export const dispatchFailure = ( error: OrchestratorV2Error | ThreadManagement.ThreadManagementProviderInstanceDisabledError, ) => error._tag === "ThreadManagementProviderInstanceDisabledError" || - error._tag === "OrchestratorRuntimeModeAboveCeilingError" + error._tag === "OrchestratorRuntimeModeAboveCeilingError" || + error._tag === "OrchestratorProviderSelectionBusyError" ? new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }) : (error._tag === "OrchestratorDispatchError" || error._tag === "OrchestratorCommandRejectedError") && diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 1fcbf8d8fc627..bb3415fb0e2ed 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -38,6 +38,7 @@ import { OrchestratorCommandRejectedError, OrchestratorDispatchError, OrchestratorProjectionError, + OrchestratorProviderSelectionBusyError, } from "../../orchestration-v2/Orchestrator.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; @@ -298,6 +299,11 @@ it("bounds public command rejections and redacts internal dispatch causes", () = new OrchestratorCommandRejectedError({ ...command, cause: "Run is not queued." }), ).message, ).toBe("Run is not queued."); + const busy = new OrchestratorProviderSelectionBusyError({ + commandId: command.commandId, + threadId, + }); + expect(dispatchFailure(busy)).toMatchObject({ code: "invalid_request", message: busy.message }); for (const cause of [ undefined, "", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 17b7f26d90cc5..e4394fe410251 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2620,11 +2620,30 @@ describe("AcpAdapterV2", () => { content: { type: "text", text: "after plan" }, }, }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "weighing it" }, + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_thought", + messageId: "whole-thought", + content: [{ type: "text", text: "settled on it" }], + }, + }); const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); assert.deepEqual( snapshot.messages.map((message) => message.text), - ["before plan", "after plan"], + ["before plan", "after plan", "weighing it", "settled on it"], + ); + assert.deepEqual( + snapshot.reasoningMessageIds, + snapshot.messages.slice(2).map((message) => message.id), ); }).pipe(Effect.provide(layerTest), Effect.scoped), ); @@ -5461,6 +5480,67 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("closes the ACP runtime when the agent never answers session/close", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const makeRuntime = makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + environment: { T3_ACP_HANG_CLOSE_SESSION: "1" }, + }); + let runtimeClosed = false; + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: (runtimeInput) => + Effect.addFinalizer(() => + Effect.sync(() => { + runtimeClosed = true; + }), + ).pipe(Effect.andThen(makeRuntime(runtimeInput))), + }, + selfInvocation, + }); + const sessionScope = yield* Scope.make(); + yield* adapter + .openSession({ + threadId: ThreadId.make("thread-acp-unanswered-close"), + providerSessionId: ProviderSessionId.make("provider-session-acp-unanswered-close"), + modelSelection: { instanceId, model: "default" }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }) + .pipe(Effect.provideService(Scope.Scope, sessionScope)); + const closing = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkDetach); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/close", + ), + Stream.runHead, + ); + yield* TestClock.adjust("10 seconds"); + const closed = yield* Fiber.await(closing).pipe( + Effect.timeoutOption("5 seconds"), + TestClock.withLive, + ); + assert.isTrue(Option.isSome(closed)); + assert.isTrue(runtimeClosed); + }).pipe(Effect.provide(layerTest)), + ); + // Oracle: Grok incident frames. Command completion must release the turn even // when the CLI keeps the prompt RPC and process alive. Command polls stay visible. it.live( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 35f705a978eee..d4b22dc119325 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -56,6 +56,7 @@ import { } from "@t3tools/contracts"; import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -2458,7 +2459,7 @@ describe("ClaudeAdapterV2 attachments", () => { }); describe("ClaudeAdapterV2 native fork", () => { - it.effect("forks at the source assistant cursor and resumes the forked session", () => + it.effect("forks a session from its original directory after the workspace root changes", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2466,24 +2467,66 @@ describe("ClaudeAdapterV2 native fork", () => { const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-v2-fork-attachments-", }); + const configDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-root-change-", + }); + const sourceSessionId = "11111111-1111-4111-8111-111111111111"; + const sourceCursor = "aaaaaaaa-0000-4000-8000-000000000002"; + const projectDir = NodePath.join(configDir, "projects", "-old-workspace"); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + const entry = (uuid: string, parentUuid: string | null, message: unknown) => ({ + type: parentUuid === null ? "user" : "assistant", + uuid, + parentUuid, + sessionId: sourceSessionId, + cwd: "/old-workspace", + isSidechain: false, + timestamp: "2026-10-01T00:00:00.000Z", + message, + }); + yield* fileSystem.writeFileString( + NodePath.join(projectDir, `${sourceSessionId}.jsonl`), + [ + entry("aaaaaaaa-0000-4000-8000-000000000001", null, { role: "user", content: "hello" }), + entry(sourceCursor, "aaaaaaaa-0000-4000-8000-000000000001", { + role: "assistant", + content: [{ type: "text", text: "inherited answer" }], + }), + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + const realRunner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner.pipe( + Effect.provide( + ClaudeAdapterV2.layerQueryRunner.pipe( + Layer.provide(PortScanner.layerTest), + Layer.provide( + Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { + native: undefined, + canonical: undefined, + }), + ), + ), + ), + ); const openedQueries: Array = []; const forkCalls: Array = []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, - environment: { CLAUDE_CONFIG_DIR: "/custom/claude-home" }, + environment: { CLAUDE_CONFIG_DIR: configDir }, attachmentsDir, fileSystem, path: yield* Path.Path, random: yield* SecureRandom.SecureRandom, idAllocator, queryRunner: { - allocateSessionId: Effect.succeed("source-native-session"), + allocateSessionId: Effect.succeed(sourceSessionId), open: (input) => Effect.sync(() => { openedQueries.push(input); return { - messages: Stream.empty, + messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, applyFlagSettings: () => Effect.void, @@ -2499,9 +2542,9 @@ describe("ClaudeAdapterV2 native fork", () => { }; }), forkSession: (input) => - Effect.sync(() => { + Effect.gen(function* () { forkCalls.push(input); - return { sessionId: "forked-native-session" }; + return yield* realRunner.forkSession(input); }), subagentLaunchToolUseId: () => Effect.succeed(null), assertComplete: Effect.void, @@ -2547,7 +2590,7 @@ describe("ClaudeAdapterV2 native fork", () => { runAttemptId: RunAttemptId.make("run-attempt-claude-source"), nativeTurnRef: { driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeId: "assistant-message-cursor", + nativeId: sourceCursor, strength: "weak", }, ordinal: 1, @@ -2562,17 +2605,22 @@ describe("ClaudeAdapterV2 native fork", () => { assert.deepEqual(forkCalls, [ { - sessionId: "source-native-session", + sessionId: sourceSessionId, options: { - dir: "/workspace", - upToMessageId: "assistant-message-cursor", + upToMessageId: sourceCursor, }, - environment: { CLAUDE_CONFIG_DIR: "/custom/claude-home" }, + environment: { CLAUDE_CONFIG_DIR: configDir }, threadId: targetThreadId, providerSessionId, }, ]); - assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "forked-native-session"); + const forkedSessionId = forkedProviderThread.nativeThreadRef?.nativeId; + assert.isString(forkedSessionId); + assert.notEqual(forkedSessionId, sourceSessionId); + const forkedTranscript = yield* fileSystem.readFileString( + NodePath.join(projectDir, `${forkedSessionId}.jsonl`), + ); + assert.include(forkedTranscript, "inherited answer"); assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id); assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId); @@ -2632,8 +2680,9 @@ describe("ClaudeAdapterV2 native fork", () => { }), }); - assert.equal(openedQueries[0]?.options.resume, "forked-native-session"); + assert.equal(openedQueries[0]?.options.resume, forkedSessionId); assert.equal(openedQueries[0]?.options.sessionId, undefined); + assert.equal(openedQueries[0]?.options.cwd, NodePath.resolve("/workspace")); }).pipe( Effect.provide( Layer.mergeAll( @@ -2825,7 +2874,7 @@ describe("ClaudeAdapterV2 native session identity", () => { Effect.sync(() => { openedQueries.push(input); return { - messages: Stream.empty, + messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, applyFlagSettings: () => Effect.void, @@ -7338,9 +7387,42 @@ describe("ClaudeAdapterV2 background wake turns", () => { expected: "Claude stopped: the prompt exceeds the model's context window. Run /compact or start a new thread.", }, + ...[ + { + name: "a model version error rewritten by a custom endpoint", + baseUrl: "https://gateway.example.test/v1", + installed: "2.1.322", + expected: + "The custom API endpoint gateway.example.test reported Claude Code 2.1.280, but this session runs Claude Code 2.1.322. Running claude update will not fix this; the endpoint or a proxy behind it rewrote the version.", + }, + { name: "a model version error without a custom endpoint", installed: "2.1.322" }, + { + name: "a model version error from a Claude Code still below the required version", + baseUrl: "https://gateway.example.test/v1", + installed: "2.1.285", + }, + ].map((row) => { + const result = + "API Error: 400 Claude Code 2.1.280 does not support this model; version 2.1.290 or newer is required. Run 'claude update', or update the Claude desktop app, then try again."; + return { + ...row, + isError: true, + result, + errors: undefined, + apiErrorStatus: undefined, + terminalReason: undefined, + environment: { + CLAUDE_CONFIG_DIR: "/synthetic/no-claude-config", + ...(row.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: row.baseUrl }), + }, + expected: row.expected ?? result, + }; + }), ])("reports terminal API error text for $name", (input) => Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const harness = yield* makeWakeHarnessWithOptions( + "environment" in input ? { environment: input.environment } : undefined, + ); const now = yield* DateTime.now; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -7353,6 +7435,17 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); const apiErrorStatus = input.apiErrorStatus ?? 400; + if ("installed" in input) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000698", + session_id: WAKE_NATIVE_SESSION, + claude_code_version: input.installed, + }), + ); + } yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -9090,6 +9183,70 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // The passive row is the frame Claude 2.1.303 emits for a Stop hook returning a systemMessage. + it.effect.each([ + { name: "hides a passive summary", level: "notice", visible: false }, + { name: "shows a blocking notice", level: "notice", preventContinuation: true, visible: true }, + { name: "shows a warning", level: "warning", visible: true }, + ])("Claude Stop-hook notices: $name", ({ level, preventContinuation, visible }) => + 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("claude-stop-hook-notice-attempt"), + text: "Continue.", + attachments: [], + }), + ); + const content = "Stop says: 40 scans, 0 findings"; + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "informational", + content, + level, + ...(preventContinuation === undefined + ? {} + : { prevent_continuation: preventContinuation }), + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000820", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000821", result: content }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem.message] + : [], + ), + visible ? [content] : [], + ); + assert.isFalse( + harness.events.some( + (event) => event.type === "message.updated" && event.message.role === "assistant", + ), + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), + ), + ), + ); + it.effect("shows a hook-dropped Claude prompt once and skips info-level lines", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -11958,6 +12115,134 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const openAdapterWhoseFirstQueryEndsBeforeItsFirstFrame = Effect.fnUntraced(function* ( + name: string, + ) { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3-claude-v2-${name}-`, + }); + const deadStreamEnded = yield* Deferred.make(); + let opened = 0; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + // startTurn reads the auto-compact window from settings between opening the query and + // attaching the turn; holding that read until the CLI's stream has ended pins the race. + fileSystem: { + ...fileSystem, + readFileString: (filePath, encoding) => + opened === 1 && filePath.endsWith("settings.json") + ? Deferred.await(deadStreamEnded).pipe( + Effect.andThen(fileSystem.readFileString(filePath, encoding)), + ) + : fileSystem.readFileString(filePath, encoding), + }, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(`native-${name}`), + open: () => + Effect.gen(function* () { + opened += 1; + const sdkMessages = yield* Queue.unbounded(); + return { + // The first CLI exits without writing a frame. + messages: + opened === 1 + ? Stream.empty.pipe(Stream.ensuring(Deferred.succeed(deadStreamEnded, undefined))) + : Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make(`thread-${name}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-${name}`), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const now = yield* DateTime.now; + const turnInput = (attempt: string, providerTurnOrdinal: number) => + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make(`attempt-${name}-${attempt}`), + text: "Continue with the plan.", + attachments: [], + providerTurnOrdinal, + }); + return { runtime, turnInput }; + }); + + it.effect("fails a turn whose Claude query ends before its first frame", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* openAdapterWhoseFirstQueryEndsBeforeItsFirstFrame( + "claude-query-dies-before-frame", + ); + const started = yield* harness.runtime + .startTurn(harness.turnInput("a", 1)) + .pipe(Effect.exit); + assert.isTrue( + Exit.isFailure(started), + "a turn whose Claude query ended before its first frame started running on the dead query", + ); + if (Exit.isFailure(started)) { + assert.include( + Cause.pretty(started.cause), + "Claude provider query ended before the turn could start.", + ); + } + const followUp = yield* harness.runtime + .startTurn(harness.turnInput("b", 2)) + .pipe(Effect.exit); + assert.isTrue( + Exit.isSuccess(followUp), + `the turn whose query ended before its first frame is still active: ${ + Exit.isFailure(followUp) ? Cause.pretty(followUp.cause) : "" + }`, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect( "clears the replaced sibling native thread roster when openQuery switches processes", () => @@ -17464,11 +17749,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => + it.effect("records only explicit effort for nested subagents", () => Effect.scoped( Effect.gen(function* () { const OUTER_TOOL_USE_ID = "toolu-effort-outer"; - const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; + const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -17534,14 +17819,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { type: "message", role: "assistant", content: [ - agentCall(INHERITING_TOOL_USE_ID), + agentCall(UNSPECIFIED_TOOL_USE_ID), agentCall(OVERRIDING_TOOL_USE_ID, "max"), ], }, uuid: "00000000-0000-4000-8000-000000000223", session_id: WAKE_NATIVE_SESSION, }), - taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000226", @@ -17566,7 +17851,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepEqual(Object.fromEntries(efforts), { [`task-${OUTER_TOOL_USE_ID}`]: "low", - [`task-${INHERITING_TOOL_USE_ID}`]: "low", + [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", }); const childEfforts = harness.events.flatMap((event) => @@ -17574,7 +17859,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] : [], ); - assert.deepEqual(childEfforts, ["low", "low", "max"]); + assert.deepEqual(childEfforts, ["low", undefined, "max"]); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 923aa7de509cb..abb4c22ac7806 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -2175,15 +2175,11 @@ async function recordClaudeForkSessionQuery(input: { type: "session.fork", sessionId: input.sessionId, options: { - dir: sanitizedReplayCwd(input.scenario), upToMessageId, }, }, }); - const forked = await forkSession(input.sessionId, { - dir: input.cwd, - upToMessageId, - }); + const forked = await forkSession(input.sessionId, { upToMessageId }); forkedNativeSessionIds.push(forked.sessionId); input.entries.push({ type: "emit_inbound", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bcfa9b77ab137..c7e51497c11a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -91,6 +91,7 @@ import { applyClaudePromptEffortPrefix, getModelSelectionBooleanOptionValue, } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, @@ -174,7 +175,9 @@ import { } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { claudeLaunchArgDirectories, + customClaudeApiEndpointHost, discoverClaudeSkills, + readClaudeSettingsEnv, } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection, @@ -4514,6 +4517,25 @@ function gatewayCooldownResetAt( : undefined; } +const CLAUDE_UNSUPPORTED_VERSION_ERROR = + /^API Error: 400 Claude Code (\d+\.\d+\.\d+) does not support this model; version (\d+\.\d+\.\d+) or newer is required\./; + +/** The version a gateway sent in place of the session's own, when the session itself meets the requirement. */ +function claudeRewrittenVersion( + message: SDKResultMessage, + sessionVersion: string | undefined, +): string | undefined { + if (message.subtype !== "success" || message.terminal_reason !== "api_error") return undefined; + const [, reported, required] = CLAUDE_UNSUPPORTED_VERSION_ERROR.exec(message.result.trim()) ?? []; + if (reported === undefined || required === undefined || sessionVersion === undefined) { + return undefined; + } + return compareSemverVersions(reported, sessionVersion) < 0 && + compareSemverVersions(sessionVersion, required) >= 0 + ? reported + : undefined; +} + function providerFailureFromResult( message: SDKResultMessage, receivedAt: DateTime.Utc, @@ -4872,6 +4894,7 @@ interface ClaudeLiveQueryContext { receivedInit: boolean; // From system/init; feature-detects control requests such as MCP Apps. capabilities: ReadonlySet; + claudeCodeVersion: string | undefined; permissionMode: PermissionMode; stopping: boolean; // The resumed session had no transcript, so the CLI exited without running anything. @@ -7447,13 +7470,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( workflow === undefined ? undefined : withWorkflowMemberThreadIds(workflow, childThreadId); - const effort = - existingSubagent === undefined - ? (input.effort ?? - input.owner?.effort ?? - compileClaudeModelSelection(input.context.input.modelSelection, modelCatalog) - .effort) - : existingSubagent.effort; + // Unstated effort stays unknown: a custom agent definition may override the owner's. + const effort = existingSubagent === undefined ? input.effort : existingSubagent.effort; const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { @@ -10140,9 +10158,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (message.subtype === "model_refusal_fallback" || (message.subtype === "informational" && message.level !== "info")) ) { + context.assistant.noticeTexts.add(message.content); + // A Stop hook's systemMessage arrives as a passive notice after every turn. + // Warnings and notices that prevent continuation explain a blocked turn. + if ( + message.subtype === "informational" && + message.level === "notice" && + message.content.startsWith("Stop says: ") && + message.prevent_continuation !== true + ) { + return; + } const now = yield* DateTime.now; const nativeItemId = message.uuid; - context.assistant.noticeTexts.add(message.content); yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -10986,12 +11014,30 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // The CLI also labels 403 authentication_failed; show its text because login cannot grant access. const permissionDenied = message.subtype === "success" && message.api_error_status === 403; + const rewrittenVersion = claudeRewrittenVersion(message, liveQuery.claudeCodeVersion); + const endpointHost = + rewrittenVersion === undefined + ? undefined + : customClaudeApiEndpointHost( + (yield* readClaudeSettingsEnv( + adapterOptions.settings, + context.input.runtimePolicy.cwd ?? undefined, + adapterOptions.environment, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + )).ANTHROPIC_BASE_URL ?? adapterOptions.environment.ANTHROPIC_BASE_URL, + ); + const rewrittenVersionError = + endpointHost === undefined + ? undefined + : `The custom API endpoint ${endpointHost} reported Claude Code ${rewrittenVersion}, but this session runs Claude Code ${liveQuery.claudeCodeVersion}. Running claude update will not fix this; the endpoint or a proxy behind it rewrote the version.`; const failureHint = permissionDenied ? message.result : (context.authenticationFailureMessage ?? (usageLimited ? "Claude usage limit reached. Send the message again once the limit resets." - : undefined)); + : rewrittenVersionError)); // Usage-limited /compact reports success; fail it so queued messages wait for the limit to clear. const limitedCompaction = usageLimited && message.subtype === "success" && message.local_command === "compact" @@ -12004,6 +12050,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( openedPermissionMode: queryOptions.permissionMode, receivedInit: false, capabilities: new Set(), + claudeCodeVersion: undefined, permissionMode: queryOptions.permissionMode, stopping: false, missingConversation: false, @@ -12045,6 +12092,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.permissionMode ?? context.openedPermissionMode; context.receivedInit = true; context.capabilities = new Set(message.capabilities ?? []); + context.claudeCodeVersion = message.claude_code_version; } if ( message.type === "system" && @@ -12297,8 +12345,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( executedModel: undefined, }; const attachTurn = Effect.fnUntraced(function* (live: ClaudeLiveQueryContext | null) { - if (live !== null) live.lastTurn = context; yield* Ref.set(activeTurn, context); + const attached = yield* Ref.modify(queryContext, (current) => { + if (live !== null && (current?.query !== live.query || live.stopping)) { + return [false, current]; + } + if (live !== null) live.lastTurn = context; + return [true, current]; + }); + if (!attached) { + yield* Ref.update(activeTurn, (current) => (current === context ? null : current)); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude provider query ended before the turn could start.", + }); + } if ( !isClaudeProviderContinuationTurn(turnInput) && permissionModeForClaudeRuntimePolicy(turnInput.runtimePolicy) === @@ -12397,7 +12458,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: "Claude exited before it could take the message.", }), }); - return; + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude provider query ended before the turn could start.", + }); } // Transcript reads suspend replay; hold SDK frames until the buffer is applied to preserve order. yield* sdkMessageLock.withPermits(1)( @@ -13350,10 +13414,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const sourceNativeThreadId = yield* getNativeThreadId(forkInput.sourceProviderThread); yield* closeLiveQueryForNativeThread(sourceNativeThreadId); const upToMessageId = yield* resolveClaudeForkUpToMessageId(forkInput); - const forkOptions: ForkSessionOptions = { - ...(input.runtimePolicy.cwd === null ? {} : { dir: input.runtimePolicy.cwd }), - ...(upToMessageId === undefined ? {} : { upToMessageId }), - }; + // A moved project still has its transcript under the original cwd. + // Let the SDK find the session by ID within this Claude home. + const forkOptions: ForkSessionOptions = + upToMessageId === undefined ? {} : { upToMessageId }; const forked = yield* queryRunner.forkSession({ sessionId: sourceNativeThreadId, options: forkOptions, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index bcb5a4c25fc02..002d336f86229 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1379,6 +1379,74 @@ describe("CodexAdapterV2 post-settle continuation", () => { }; }); + it.effect("keeps native image views available to the clients", () => + Effect.gen(function* () { + const nativeThreadId = "image-view-thread"; + const nativeTurnId = "image-view-turn"; + const imagePath = "file:///workspace/reference.png"; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "image-view", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Show the reference." }), + ...(["started", "completed"] as const).map((phase) => ({ + type: "emit_inbound" as const, + label: `image ${phase}`, + frame: { + method: `item/${phase}`, + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { type: "imageView", id: "image-view", path: imagePath }, + ...(phase === "started" ? { startedAtMs: 1 } : { completedAtMs: 2 }), + }, + }, + })), + { + type: "emit_inbound", + label: "turn completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("image-view-attempt"), + text: "Show the reference.", + }), + ); + yield* harness.firstTerminal; + const images = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + assert.lengthOf(images, 1); + assert.equal(images[0]?.viewedImagePath, imagePath); + assert.equal(images[0]?.status, "completed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each(["before", "after"] as const)( "rejects a previous run's native turn when it completes %s the new start response", (completionOrder) => @@ -4122,6 +4190,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { id: `image-${index}`, status: "completed", result, + revisedPrompt: "A tree shaped like T3", + transparentBackground: true, ...image, }, }, @@ -4142,6 +4212,42 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); yield* harness.firstTerminal; + const imageItems = harness.events + .flatMap((event) => (event.type === "turn_item.updated" ? [event.turnItem] : [])) + .filter((item) => item.type === "dynamic_tool" && item.toolName === "image_gen.imagegen"); + const latestItems = new Map( + harness.events.flatMap((event) => + event.type === "turn_item.updated" ? [[event.turnItem.id, event.turnItem] as const] : [], + ), + ); + assert.strictEqual( + [...latestItems.values()].filter((item) => item.type === "dynamic_tool").length, + images.length, + ); + assert.strictEqual(imageItems.length, images.length); + assert.deepEqual( + imageItems.map((item) => item.status), + images.map((image) => + ("status" in image && image.status === "failed") || "failure" in image + ? "failed" + : "completed", + ), + ); + for (const [index, item] of imageItems.entries()) { + assert.strictEqual(item.title, "Generate image"); + if (item.type !== "dynamic_tool") continue; + assert.deepEqual(item.input, { + prompt: "A tree shaped like T3", + transparent_background: true, + }); + const image = images[index]!; + assert.deepEqual( + item.output, + Object.fromEntries( + Object.entries({ status: "completed", ...image }).filter(([, value]) => value != null), + ), + ); + } const messages = assistantMessages(harness.events); assert.strictEqual(messages.at(-1)?.message.text, expected); assert.strictEqual(messages.at(-1)?.message.streaming, false); @@ -4185,6 +4291,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { item: { type: "imageGeneration", id: image, + revisedPrompt: "A friendly green tree", + transparentBackground: true, status: phase === "started" ? "inProgress" : "completed", result: "", ...(phase === "completed" ? { savedPath: `/tmp/${image}.png` } : {}), @@ -4256,6 +4364,31 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* Deferred.await(drained); assert.deepEqual(yield* pending, [false, false]); assert.lengthOf(harness.terminalEvents(), 1); + const imageItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "image_gen.imagegen" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + imageItems.slice(0, 2).map((item) => item.status), + ["running", "running"], + ); + for (const id of new Set(imageItems.map((item) => item.id))) { + const updates = imageItems.filter((item) => item.id === id); + assert.lengthOf(updates, 2); + assert.strictEqual( + updates[1]?.status, + outcome === "complete" ? "completed" : "cancelled", + ); + assert.strictEqual(updates[1]?.ordinal, updates[0]?.ordinal); + assert.deepEqual(updates[1]?.input, updates[0]?.input); + assert.deepEqual(updates[1]?.input, { + prompt: "A friendly green tree", + transparent_background: true, + }); + } // Run ingestion follows these updates, so they must bracket the terminal and the images. const indexOf = (predicate: (event: ProviderAdapter.ProviderAdapterV2Event) => boolean) => harness.events.findIndex(predicate); @@ -5781,8 +5914,15 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); it.effect.each([ + // regression: #27773 a first-request 429 only notifies the reset-less premium limit + { + name: "first-request-reset", + code: "usageLimitExceeded", + notification: false, + expectedClass: "usage_limit", + }, { - name: "usage", + name: "newer-reset-during-read", code: "usageLimitExceeded", notification: false, expectedClass: "usage_limit", @@ -5898,6 +6038,37 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }; + const readsReset = + scenario.name === "first-request-reset" || scenario.name === "newer-reset-during-read"; + // Usage limits with no known reset read the account before publishing their terminal. + const readsAccount = + scenario.expectedClass === "usage_limit" && + !("expectedCode" in scenario) && + scenario.name !== "deferred-reset"; + const premium = { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { rateLimits: { limitId: "premium", primary: null, secondary: null } }, + }, + }; + const newerSnapshot = { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 2000200000, windowDurationMins: 300 }, + }, + }, + }, + }; + const readRequest = { + type: "expect_outbound" as const, + label: "account/rateLimits/read", + frame: { id: 4, method: "account/rateLimits/read", params: null }, + }; const transcript = makeCodexReplayTranscript({ scenario: `codex-limit-${scenario.name}`, entries: [ @@ -5922,6 +6093,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, ] : []), + ...(readsReset ? [premium] : []), ...(scenario.name === "deferred-reset" ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ @@ -6006,7 +6178,36 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, - ...(scenario.name === "late-reset" || "resetAfter" in scenario ? [snapshot] : []), + ...(readsAccount ? [readRequest] : []), + ...(scenario.name === "newer-reset-during-read" ? [newerSnapshot] : []), + ...(readsReset + ? [ + { + type: "emit_inbound" as const, + label: "account/rateLimits/read", + frame: { + id: 4, + result: { + ...premium.frame.params, + rateLimitsByLimitId: { codex: snapshot.frame.params.rateLimits }, + }, + }, + }, + ] + : []), + ...(readsAccount && !readsReset + ? [ + { + type: "emit_inbound" as const, + label: "account/rateLimits/read", + frame: { id: 4, error: { code: -32600, message: "Not signed in." } }, + }, + ] + : []), + ...(scenario.name === "late-reset" + ? [{ ...snapshot, label: "account/rateLimits/updated/late" }] + : []), + ...("resetAfter" in scenario ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ { @@ -6045,17 +6246,25 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); const resetReceipt = yield* Deferred.make(); const snapshotReceipt = yield* Deferred.make(); + const terminalReceipt = yield* Deferred.make(); const harness = yield* makeCodexReplayHarness( transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt - ? Deferred.succeed(resetReceipt, undefined) - : Effect.void, + event.type === "turn.terminal" + ? Deferred.succeed(terminalReceipt, undefined) + : event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt + ? Deferred.succeed(resetReceipt, undefined) + : Effect.void, undefined, undefined, undefined, + // The late reset must land after the terminal its failed read released. + (entry) => + entry.label === "account/rateLimits/updated/late" + ? Deferred.await(terminalReceipt) + : Effect.void, () => Deferred.succeed(snapshotReceipt, undefined), ); yield* harness.runtime.startTurn( @@ -6073,7 +6282,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (terminal?.status !== "failed") return; assert.equal(terminal.failure.class, scenario.expectedClass); assert.equal(terminal.threadDisposition, "reusable"); - if (scenario.name === "deferred-reset") assert.equal(terminal.failure.resetAt, resetAt); + if (scenario.name === "deferred-reset" || scenario.name === "first-request-reset") + assert.equal(terminal.failure.resetAt, resetAt); + if (scenario.name === "newer-reset-during-read") + assert.equal(terminal.failure.resetAt, "2033-05-20T11:06:40.000Z"); if (scenario.name === "matching-details") assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); if ("resetAfter" in scenario) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 6ec9e12fa3cc0..52e52985cf2d3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -285,6 +285,7 @@ export const CodexProviderCapabilitiesV2 = { emitsTurnCompleted: true, supportsInterrupt: true, supportsActiveSteering: true, + activeSteeringDuringCompaction: true, supportsSteeringByInterruptRestart: true, supportsQueuedMessages: true, terminalStatusQuality: "strong", @@ -1408,6 +1409,11 @@ function codexModelCallUsage( return Object.keys(usage).length === 0 ? undefined : usage; } +function withCodexModelCall( + turnItem: T, + modelCallId: string, + modelCallUsage: OrchestrationV2ModelCallUsage | undefined, +): T; function withCodexModelCall( turnItem: OrchestrationV2TurnItem, modelCallId: string, @@ -1451,11 +1457,11 @@ function noteCodexModelCallTool( open.tools.push({ nativeItemId, ordinal }); } -function stampCodexModelCall( +function stampCodexModelCall( state: CodexModelCallState, providerTurnId: ProviderTurnId, - turnItem: OrchestrationV2TurnItem, -): OrchestrationV2TurnItem { + turnItem: T, +): T { if (!CODEX_MODEL_CALL_TOOL_TYPES.has(turnItem.type)) return turnItem; const nativeItemId = turnItem.nativeItemRef?.nativeId; if (nativeItemId === undefined || nativeItemId === null || nativeItemId.length === 0) { @@ -2292,7 +2298,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const imageTurns = new Map< string, { - readonly running: Set; + readonly running: Map< + string, + { + readonly revisedPrompt: string | null; + readonly transparentBackground: boolean | null; + } + >; readonly saved: Map; pending: boolean; answer?: { readonly id: string; readonly text: string }; @@ -2301,7 +2313,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const imageTurn = (nativeTurnId: string) => { let state = imageTurns.get(nativeTurnId); if (state === undefined) { - state = { running: new Set(), saved: new Map(), pending: false }; + state = { running: new Map(), saved: new Map(), pending: false }; imageTurns.set(nativeTurnId, state); } return state; @@ -2386,6 +2398,19 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const clearImageWork = Effect.fn("CodexAdapterV2.clearImageWork")(function* ( context: ActiveCodexTurnContext, ) { + for (const [id, input] of imageTurns.get(context.nativeTurnId)?.running ?? []) { + yield* emitImageGenerationItem( + context, + { + type: "imageGeneration", + id, + ...input, + status: "cancelled", + result: "", + }, + true, + ); + } yield* emitImageWorkState(context, false); imageTurns.delete(context.nativeTurnId); }); @@ -4276,6 +4301,56 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); + const emitImageGenerationItem = Effect.fn("CodexAdapterV2.emitImageGenerationItem")( + function* ( + context: ActiveCodexTurnContext, + item: Extract< + CodexSchema.V2ItemCompletedNotification__ThreadItem, + { type: "imageGeneration" } + >, + completed: boolean, + ) { + const cancelled = completed && item.status === "cancelled"; + const failed = item.status === "failed" || item.failure != null; + const artifacts = yield* buildDynamicToolArtifacts(context, { + type: "dynamicToolCall", + id: item.id, + namespace: "image_gen", + tool: "imagegen", + status: completed ? (failed ? "failed" : "completed") : "inProgress", + arguments: { + ...(item.revisedPrompt == null ? {} : { prompt: item.revisedPrompt }), + ...(item.transparentBackground == null + ? {} + : { transparent_background: item.transparentBackground }), + }, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: { ...artifacts.node, ...(cancelled ? { status: "cancelled" } : {}) }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: { + ...artifacts.turnItem, + title: "Generate image", + ...(cancelled ? { status: "cancelled" } : {}), + ...(completed + ? { + output: { + status: item.status, + ...(item.savedPath == null ? {} : { savedPath: item.savedPath }), + ...(item.failure == null ? {} : { failure: item.failure }), + }, + } + : {}), + }, + }); + }, + ); + /** * The item stays running until then, which keeps the run's event * ingestion open past turn/completed; it runs off the notification @@ -4921,6 +4996,26 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }).pipe(Effect.orDie), ); + // A run's first request can 429 before Codex pushes a codex snapshot. + const readUsageLimitReset = Effect.gen(function* () { + const sent = yield* Ref.get(rateLimitSnapshot); + const response = yield* client + .request("account/rateLimits/read", null) + .pipe(Effect.timeout("10 seconds")); + const read = response.rateLimitsByLimitId?.codex ?? response.rateLimits; + // A notification that landed during the read is newer than it. + yield* Ref.update(rateLimitSnapshot, (current) => + current === sent ? mergeCodexRateLimits(current, read) : current, + ); + }).pipe( + // The terminal waits on this read, so no failure may skip it; interruption ends both. + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("orchestration-v2.codex-usage-limit-reset-read-failed", { cause }), + ), + ); + yield* client.handleServerNotification("thread/tokenUsage/updated", (payload) => Effect.gen(function* () { accumulateCodexTurnTokenUsage( @@ -5337,7 +5432,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (payload.item.type === "imageGeneration") { if (!(yield* Ref.get(interruptingNativeTurns)).has(payload.turnId)) { - imageTurn(payload.turnId).running.add(payload.item.id); + imageTurn(payload.turnId).running.set(payload.item.id, { + revisedPrompt: payload.item.revisedPrompt ?? null, + transparentBackground: payload.item.transparentBackground ?? null, + }); + yield* emitImageGenerationItem(context, payload.item, false); yield* emitImageWorkState(context, true); } return; @@ -5623,6 +5722,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (payload.item.type === "imageGeneration") { if ((yield* Ref.get(interruptingNativeTurns)).has(payload.turnId)) return; + yield* emitImageGenerationItem(context, payload.item, true); const { id, savedPath, status, failure } = payload.item; const state = imageTurn(payload.turnId); if (savedPath?.trim() && status === "completed" && failure == null) { @@ -5640,6 +5740,27 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } + if (payload.item.type === "imageView") { + const artifacts = yield* buildDynamicToolArtifacts(context, { + type: "dynamicToolCall", + id: payload.item.id, + tool: "view_image", + arguments: { path: payload.item.path }, + status: "completed", + }); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: artifacts.node, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: { ...artifacts.turnItem, viewedImagePath: payload.item.path }, + }); + return; + } + if (payload.item.type !== "agentMessage") { return; } @@ -6312,7 +6433,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); - const emitRootTerminal = Effect.fnUntraced(function* ( + const publishRootTerminal = Effect.fnUntraced(function* ( context: ActiveCodexTurnContext, event: CodexRootTerminalEvent, ) { @@ -6329,8 +6450,38 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } : event; yield* emitProviderEvent(current); + if (current.status === "failed" && current.failure.class === "usage_limit") { + const item = makeProviderFailureTurnItem({ + driver: CODEX_PROVIDER, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: current.providerThreadId, + providerTurnId: current.providerTurnId, + itemOrdinal: current.failureItemOrdinal, + failure: current.failure, + occurredAt: yield* DateTime.now, + }); + yield* Ref.update(limitedTurnItems, (items) => + new Map(items).set(context.providerThread.id, item), + ); + } + }); + + const emitRootTerminal = Effect.fnUntraced(function* ( + context: ActiveCodexTurnContext, + event: CodexRootTerminalEvent, + ) { + const awaitsReset = + event.status === "failed" && + event.failure.class === "usage_limit" && + event.failure.resetAt == null && + // The account read only reports the ordinary allowance, never a sharing cap's reset. + !classifyCodexManagedError(event.failure.code)?.usageLimit && + codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)) === null; + if (!awaitsReset) yield* publishRootTerminal(context, event); const continued = continuedRuns.get(context.providerTurnId) ?? [context]; - if (current.status !== "completed") { + if (event.status !== "completed") { yield* Ref.update( terminalizedNonCompletedNativeTurns, (nativeTurns) => @@ -6346,20 +6497,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (nativeThreadId != null && rootProviderThread !== undefined) { rootProviderThreads.set(nativeThreadId, { ...rootProviderThread, status: "idle" }); } - if (current.status === "failed" && current.failure.class === "usage_limit") { - const item = makeProviderFailureTurnItem({ - driver: CODEX_PROVIDER, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: context.input.rootNodeId, - providerThreadId: current.providerThreadId, - providerTurnId: current.providerTurnId, - itemOrdinal: current.failureItemOrdinal, - failure: current.failure, - occurredAt: yield* DateTime.now, - }); - yield* Ref.update(limitedTurnItems, (items) => - new Map(items).set(context.providerThread.id, item), + // Notification handlers block response routing, so the read and terminal get a fiber. + if (awaitsReset) { + yield* readUsageLimitReset.pipe( + Effect.andThen( + turnTerminalizationPermit.withPermits(1)(publishRootTerminal(context, event)), + ), + Effect.forkIn(scope, { startImmediately: true }), ); } }); @@ -6535,7 +6679,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( state.saved.clear(); return; } - const message = { id: finalAnswer?.id ?? firstImageItemId, text }; + const message = { + id: finalAnswer?.id ?? `generated-image-message:${firstImageItemId}`, + text, + }; state.answer = message; const artifacts = yield* buildAgentMessageArtifacts(context, message, true); yield* emitProviderEvent({ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e4e0851e82c29..160bcc6f32699 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -82,6 +82,7 @@ const event = ( id: `evt_${type.replaceAll(".", "")}0000`, created: 1, type, + ...(type.startsWith("rpc.") ? { location: { directory: WORK } } : {}), data, durable: { ...durable.durable, version }, }, @@ -141,6 +142,11 @@ const promptAccepted = replyData("session.prompt", { delivery: "steer", }); +const emptyShellInventory: ReadonlyArray = [ + out("shell.list", ""), + reply("shell.list", { location: { directory: WORK }, data: [] }), +]; + const noOpenRequests: ReadonlyArray = [ out("permission.list", { sessionID: SESSION }), replyData("permission.list", []), @@ -163,6 +169,7 @@ const withInstructions = ( "session.compact", "command.list", "skill.list", + "rpc.call", ].includes(String(entry.frame.type)), ); const after = entries.findIndex( @@ -315,6 +322,10 @@ const colorForm = { const resumed = ( entries: ReadonlyArray, options?: { + readonly version?: "2.2.1" | "2.2.5" | "2.2.6"; + readonly shellRecovery?: ReadonlyArray; + readonly lastRunOrdinal?: number; + readonly goal?: OrchestrationV2ProviderThread["goal"]; readonly external?: boolean; readonly supervised?: boolean; readonly replayGate?: ProviderReplayGate; @@ -322,26 +333,42 @@ const resumed = ( readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; readonly fileSystem?: FileSystem.FileSystem; readonly onPrompt?: (input: unknown) => void; + readonly borrowers?: { current: number }; }, ) => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( - withInstructions([ + [ ...opening, out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), ...noOpenRequests, - ...(options?.supervised === true - ? [ - out("agent.list", ""), - reply("agent.list", agentList), - out("session.update", { sessionID: SESSION, permissions: supervisedRules }), - reply("session.update", null), - ] + ...(options?.version === "2.2.5" || options?.version === "2.2.6" + ? [out("rpc.call", ""), reply("rpc.call", { output: null })] : []), - ...entries, - ]), + ...(options?.shellRecovery ?? + (options?.version === undefined + ? [] + : [ + ...(options.version === "2.2.6" + ? [out("job.list", { kind: "shell" }), reply("job.list", { data: [] })] + : emptyShellInventory), + ])), + ...withInstructions([ + ...(options?.supervised === true + ? [ + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { sessionID: SESSION, permissions: supervisedRules }), + reply("session.update", null), + ] + : []), + ...entries, + ]), + ], { + ...(options?.version === undefined ? {} : { version: options.version }), + ...(options?.borrowers === undefined ? {} : { borrowers: options.borrowers }), ...(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), ...(options?.onPrompt === undefined ? {} : { onPrompt: options.onPrompt }), ...(options?.external === undefined ? {} : { external: options.external }), @@ -355,7 +382,13 @@ const resumed = ( }, ); const thread = yield* runtime.resumeThread({ - providerThread: providerThread(yield* DateTime.now), + providerThread: { + ...providerThread(yield* DateTime.now), + ...(options?.lastRunOrdinal === undefined + ? {} + : { lastRunOrdinal: options.lastRunOrdinal }), + ...(options?.goal === undefined ? {} : { goal: options.goal }), + }, threadId, modelSelection: bigPickle, runtimePolicy: policy(options?.supervised === true ? "approval-required" : "full-access"), @@ -422,6 +455,17 @@ const history = { cursor: {}, }; +const nativeGoal = (status: "active" | "paused" | "complete") => ({ + id: "goal-1", + objective: "Write proof.txt", + status, + rounds: 2, + maxRounds: 100, + boot: "boot-1", + updatedAt: 1, + reason: "Checked proof.txt", +}); + it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("switches the session's model and variant before a turn that changed them", () => Effect.gen(function* () { @@ -860,6 +904,448 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); return { attached: Deferred.await(attached), terminal: Fiber.join(terminal) }; }); + it.effect.each([false, true, "thread", "failed", "burst", "poll"] as const)( + "tracks native background shells through launch, retained output, stop=%s", + (stop) => + Effect.gen(function* () { + const stopped = stop === true || stop === "thread" || stop === "poll"; + const exitCode = stop === "failed" ? 7 : 0; + const shellID = "sh_background"; + const tool = { + sessionID: SESSION, + assistantMessageID: "msg_assistant", + id: "call-shell", + }; + const gate = makeProviderReplayGate(["shell-exit"]); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.tool.input.started", { ...tool, name: "shell" }), + event("session.tool.called", { + ...tool, + name: "shell", + input: { command: "echo retained", background: true }, + executed: false, + }), + event( + "session.tool.success", + { + ...tool, + content: [{ type: "text", text: "Launched" }], + metadata: { shellID }, + executed: false, + }, + undefined, + 2, + ), + out("shell.output", ""), + reply("shell.output", { + location: { directory: WORK }, + data: { output: "retained\n", cursor: 9, size: 9, truncated: false }, + }), + ...(stop === "poll" + ? [out("shell.output", ""), reply("shell.output", "")] + : []), + event("session.execution.succeeded", { sessionID: SESSION }), + ...(stopped + ? [] + : [ + event( + "shell.exited", + { id: shellID, exit: exitCode, status: "exited" }, + "shell-exit", + ), + ]), + ...(stop === "burst" + ? Array.from({ length: 5 }, (_, page) => [ + out("shell.output", { cursor: String(9 + page * 65536) }), + reply("shell.output", { + location: { directory: WORK }, + data: { + output: + page === 4 ? "x".repeat(65519) + "FINAL DIAGNOSTIC\n" : "x".repeat(65536), + cursor: 9 + (page + 1) * 65536, + size: 9 + 5 * 65536, + truncated: false, + }, + }), + ]).flat() + : [ + out("shell.output", ""), + reply("shell.output", { + location: { directory: WORK }, + data: { output: "", cursor: 9, size: 9, truncated: false }, + }), + ]), + ...(stopped + ? [ + out("shell.remove", { id: shellID, "location[directory]": WORK }), + event("shell.exited", { id: shellID, exit: 143, status: "exited" }), + reply("shell.remove", null), + ] + : []), + ], + { replayGate: gate }, + ); + const items: Array = []; + const shellStarted = yield* Deferred.make(); + const terminal = yield* runtime.events.pipe( + Stream.tap((e) => + Effect.gen(function* () { + if (e.type !== "turn_item.updated") return; + items.push(e.turnItem); + if (e.turnItem.type === "command_execution" && e.turnItem.output === "retained\n") + yield* Deferred.succeed(shellStarted, undefined); + }), + ), + Stream.filter((e) => e.type === "turn.terminal"), + Stream.runHead, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + if (stop === "poll") { + yield* Deferred.await(shellStarted); + yield* TestClock.adjust("2 seconds"); + } + yield* Fiber.join(terminal); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + const settled = yield* runtime.events.pipe( + Stream.tap((e) => + Effect.sync(() => { + if (e.type === "turn_item.updated") items.push(e.turnItem); + }), + ), + Stream.filter( + (e) => + e.type === "provider_thread.updated" && + e.providerThread.pendingBackgroundTasks?.length === 0, + ), + Stream.runHead, + Effect.forkScoped, + ); + if (stop === "thread") + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + else if (stopped) yield* runtime.stopTask!({ providerThread: thread, taskId: shellID }); + else gate.release("shell-exit"); + yield* Fiber.join(settled); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + assert.deepInclude(items.filter((i) => i.type === "command_execution").at(-1), { + type: "command_execution", + status: stopped ? "interrupted" : stop === "failed" ? "failed" : "completed", + ...(stop === "failed" ? { exitCode: 7 } : {}), + output: stop === "burst" ? "x".repeat(262127) + "FINAL DIAGNOSTIC\n" : "retained\n", + }); + assert.isFalse(items.some((i) => i.type === "subagent")); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + "shell", + "job", + "fallback", + "descendant", + "settled", + "exited", + "anchored", + ] as const)("recovers only owned native shells using %s inventory", (discovery) => + Effect.gen(function* () { + const idAllocator = yield* IdAllocator.IdAllocatorV2.pipe(Effect.provide(IdAllocator.layer)); + const id = "sh_recovered"; + const info = { + id, + command: "sleep 600", + status: discovery === "settled" ? "exited" : "running", + ...(discovery === "settled" ? { exit: 0 } : {}), + cwd: WORK, + shell: "/bin/sh", + file: "/remote/output", + metadata: { sessionID: SESSION }, + time: { started: 1 }, + }; + const response = (data: unknown) => ({ location: { directory: WORK }, data }); + const inventory: Array = + discovery === "shell" + ? [] + : [ + out("job.list", { sessionID: SESSION, kind: "shell", descendants: "true" }), + reply( + "job.list", + discovery === "fallback" + ? { status: 404, body: {} } + : { + data: [ + { + kind: "shell", + shellID: id, + sessionID: discovery === "descendant" ? "ses_descendant" : SESSION, + command: info.command, + status: discovery === "settled" ? "completed" : "running", + }, + ], + }, + ), + ]; + if (discovery === "shell" || discovery === "fallback") + inventory.push( + out("shell.list", ""), + reply( + "shell.list", + response([info, { ...info, id: "sh_other", metadata: { sessionID: "ses_other" } }]), + ), + ); + inventory.push( + out("shell.get", { id }), + reply("shell.get", response(info)), + out("shell.output", { id, cursor: "0" }), + reply( + "shell.output", + response({ output: "retained", cursor: 8, size: 8, truncated: false }), + ), + ); + const gate = makeProviderReplayGate(["recovered-exit"]); + const { runtime, thread } = yield* resumed( + discovery === "settled" + ? [] + : [ + ...(discovery === "exited" + ? [event("shell.exited", { id, exit: 0, status: "exited" }, "recovered-exit")] + : []), + out("shell.output", { id, cursor: "8" }), + reply( + "shell.output", + response({ + output: discovery === "exited" ? " finished" : "", + cursor: discovery === "exited" ? 17 : 8, + size: discovery === "exited" ? 17 : 8, + truncated: false, + }), + ), + ...(discovery === "exited" + ? [] + : [out("shell.remove", { id }), reply("shell.remove", null)]), + ], + { + version: discovery === "shell" ? "2.2.1" : "2.2.6", + shellRecovery: inventory, + external: true, + ...(discovery === "anchored" ? { lastRunOrdinal: 7 } : {}), + replayGate: gate, + }, + ); + const items: Array = []; + const shellEnded = yield* Deferred.make(); + const rosterEnded = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + if ( + event.type === "provider_thread.updated" && + event.sessionScoped === true && + event.providerThread.pendingBackgroundTasks?.length === 0 + ) + yield* Deferred.succeed(rosterEnded, undefined); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "command_execution") + return; + items.push(event); + if (event.turnItem.status !== "running") yield* Deferred.succeed(shellEnded, undefined); + }), + ), + Effect.forkScoped, + ); + assert.deepEqual( + thread.pendingBackgroundTasks ?? [], + discovery === "settled" ? [] : [{ taskId: id, kind: "command", description: info.command }], + ); + assert.equal( + yield* runtime.hasPendingBackgroundWorkForThread!(thread), + discovery !== "settled", + ); + yield* runtime.stopTask!({ providerThread: thread, taskId: "sh_other" }); + assert.equal( + yield* runtime.hasPendingBackgroundWorkForThread!(thread), + discovery !== "settled", + ); + if (discovery === "exited") gate.release("recovered-exit"); + else yield* runtime.stopTask!({ providerThread: thread, taskId: id }); + yield* Deferred.await(shellEnded); + yield* Deferred.await(rosterEnded); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + const item = items; + const projected = item.at(0); + const stopped = item.at(-1); + if (stopped?.type === "turn_item.updated" && stopped.turnItem.type === "command_execution") { + assert.equal( + stopped.turnItem.status, + discovery === "settled" || discovery === "exited" ? "completed" : "interrupted", + ); + assert.equal( + stopped.turnItem.output, + discovery === "exited" ? "retained finished" : "retained", + ); + assert.equal( + stopped.turnItem.id, + projected?.type === "turn_item.updated" ? projected.turnItem.id : undefined, + ); + } + assert.equal(projected?.type, "turn_item.updated"); + if (projected?.type === "turn_item.updated") { + assert.isTrue( + item.every( + (event) => + event.type === "turn_item.updated" && + event.turnItem.runId === + (discovery === "anchored" + ? idAllocator.derive.run({ threadId, ordinal: 7 }) + : null), + ), + ); + assert.deepInclude(projected.turnItem, { + type: "command_execution", + input: info.command, + output: "retained", + }); + } + }).pipe(Effect.scoped), + ); + + it.effect.each(["remove", "lookup", "output", "timeout"] as const)( + "continues recovering and stopping native work when shell %s fails", + (failure) => + Effect.gen(function* () { + const ids = ["sh_unreachable", "sh_stoppable"]; + const info = (id: string) => ({ + id, + command: "sleep 600", + status: "running", + cwd: WORK, + shell: "/bin/sh", + file: "/remote/output", + metadata: { sessionID: SESSION }, + time: { started: 1 }, + }); + const response = (data: unknown) => ({ location: { directory: WORK }, data }); + const output = (id: string) => [ + out("shell.output", { id }), + reply("shell.output", response({ output: "", cursor: 0, size: 0, truncated: false })), + ]; + const gate = makeProviderReplayGate(["recovery-pending", "polled"]); + const recovery = yield* resumed( + [ + ...backgroundLaunch(CHILD), + ...output(ids[0]!), + out("shell.remove", { id: ids[0] }), + reply("shell.remove", failure === "remove" ? { status: 500, body: {} } : null), + ...output(ids[1]!), + out("shell.remove", { id: ids[1] }), + reply("shell.remove", null), + out("session.interrupt", { sessionID: CHILD }), + reply("session.interrupt", { interrupted: true }), + out("session.interrupt", { sessionID: SESSION }), + reply("session.interrupt", { interrupted: true }), + event("session.execution.interrupted", { sessionID: SESSION }), + ...(failure === "remove" + ? [ + ...output(ids[0]!), + out("shell.remove", { id: ids[0] }), + reply("shell.remove", null), + ] + : []), + ], + { + version: "2.2.1", + replayGate: gate, + shellRecovery: [ + out("shell.list", ""), + reply("shell.list", response(ids.map(info))), + ...(failure === "timeout" + ? [ + out("shell.get", { id: ids[0] }), + reply("shell.get", ""), + event( + "session.execution.succeeded", + { sessionID: "ses_unrelated" }, + "recovery-pending", + ), + ...ids.map((id) => out("shell.output", { id })), + ...ids.map(() => + reply( + "shell.output", + response({ output: "", cursor: 0, size: 0, truncated: false }), + ), + ), + event("session.execution.succeeded", { sessionID: "ses_unrelated" }, "polled"), + ] + : ids.flatMap((id) => [ + out("shell.get", { id }), + reply( + "shell.get", + id === ids[0] && failure === "lookup" + ? { status: 500, body: {} } + : response(info(id)), + ), + ...(id === ids[0] && failure === "lookup" + ? [] + : id === ids[0] && failure === "output" + ? [ + out("shell.output", { id }), + reply("shell.output", { status: 500, body: {} }), + ] + : output(id)), + ])), + ], + }, + ).pipe(Effect.forkScoped); + if (failure === "timeout") { + yield* Effect.promise(() => gate.waitForReached("recovery-pending")); + yield* TestClock.adjust("5 seconds"); + } + const { runtime, thread } = yield* Fiber.join(recovery); + const recovered = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.length === 2, + ), + Stream.runCollect, + ); + assert.sameMembers( + recovered.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "command_execution" + ? [event.turnItem.nativeItemRef?.nativeId] + : [], + ), + ids, + ); + if (failure === "timeout") { + gate.release("recovery-pending"); + yield* Effect.promise(() => gate.waitForReached("polled")); + gate.release("polled"); + } + const watch = yield* watchBackgroundTurn(runtime); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.attached; + yield* runtime.interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + requestRuntimeRestart: true, + }); + assert.equal((yield* watch.terminal)?.status, "interrupted"); + assert.equal( + yield* runtime.hasPendingBackgroundWorkForThread!(thread), + failure === "remove", + ); + if (failure === "remove") + yield* runtime.stopTask!({ providerThread: thread, taskId: ids[0]! }); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + it.effect("keeps background subagents running when a turn is interrupted to restart it", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -1147,26 +1633,119 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { event("session.execution.started", { sessionID: CHILD }), ]; - it.effect("settles a background subagent that fails, so no work is left pending", () => - Effect.gen(function* () { - const { runtime, thread } = yield* resumed([ - ...launchedAndRunning, - // The parent turn ends, and the background child fails: it reports - // nothing back, so its call has to end with the failure. - event("session.execution.succeeded", { sessionID: SESSION }), - event("session.execution.failed", { - sessionID: CHILD, - error: { type: "provider.quota", message: "Rate limit exceeded." }, - }), - ]); - const watch = yield* watchCalls(runtime, thread); - yield* runtime.startTurn(withLineage(thread)); - yield* watch.until( - () => watch.calls.get("Sleep")?.status === "failed" && watch.state.roster?.length === 0, - ); - assert.deepEqual(watch.state.roster, []); - assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); - }).pipe(Effect.scoped), + it.effect.each([false, true])( + "settles a failed background subagent with recovered child=%s", + (recoveredChild) => + Effect.gen(function* () { + const ids = yield* IdAllocator.IdAllocatorV2.pipe(Effect.provide(IdAllocator.layer)); + const childThreadId = ids.derive.threadFromProviderThread({ + driver: OPENCODE_PROVIDER, + nativeThreadId: CHILD, + providerInstanceId: instanceId, + }); + const childProviderThread = { + ...providerThread(yield* DateTime.now), + id: ids.derive.providerThread({ + driver: OPENCODE_PROVIDER, + nativeThreadId: CHILD, + providerInstanceId: instanceId, + }), + appThreadId: childThreadId, + nativeThreadRef: { + driver: OPENCODE_PROVIDER, + nativeId: CHILD, + strength: "strong" as const, + }, + }; + const shell = { + id: "sh_old_child", + command: "echo done", + status: "exited", + exit: 0, + cwd: WORK, + shell: "/bin/sh", + file: "/remote/output", + metadata: { sessionID: CHILD }, + time: { started: 1, completed: 2 }, + }; + const response = (data: unknown) => ({ location: { directory: WORK }, data }); + const { runtime, thread } = yield* resumed( + [ + ...(recoveredChild + ? [ + out("session.get", { sessionID: CHILD }), + replyData("session.get", sessionInfo({ id: CHILD })), + out("permission.list", { sessionID: CHILD }), + replyData("permission.list", []), + out("session.form.list", { sessionID: CHILD }), + replyData("session.form.list", []), + out("shell.list", ""), + reply("shell.list", response([shell])), + out("shell.get", { id: shell.id }), + reply("shell.get", response(shell)), + out("shell.output", { id: shell.id }), + reply( + "shell.output", + response({ output: "done", cursor: 4, size: 4, truncated: false }), + ), + out("session.update", { sessionID: CHILD, permissions: "" }), + reply("session.update", null), + ] + : []), + ...launchedAndRunning, + // The parent turn ends, and the background child fails: it reports + // nothing back, so its call has to end with the failure. + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.failed", { + sessionID: CHILD, + error: { type: "provider.quota", message: "Rate limit exceeded." }, + }), + ], + recoveredChild ? { version: "2.2.1" } : undefined, + ); + if (recoveredChild) + yield* runtime.resumeThread({ + providerThread: childProviderThread, + threadId: childThreadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const childUpdates: Array< + Extract + > = []; + const watch = yield* watchCalls( + { + ...runtime, + events: runtime.events.pipe( + Stream.tap((event) => + Effect.sync(() => { + if ( + event.type === "provider_thread.updated" && + event.providerThread.id === childProviderThread.id && + event.providerThread.ownerNodeId !== null + ) + childUpdates.push(event); + }), + ), + ), + }, + thread, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => watch.calls.get("Sleep")?.status === "failed" && watch.state.roster?.length === 0, + ); + assert.includeMembers( + childUpdates.map((event) => event.providerThread.status), + ["active", "idle"], + ); + assert.isFalse( + childUpdates.some((event) => event.sessionScoped === true), + "reattached child status must route through the parent run", + ); + assert.deepEqual(watch.state.roster, []); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), ); // regression: #21979 a failed background subagent pinned its thread as busy @@ -2297,6 +2876,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { active: Record, ended?: "succeeded", assistant: Record = {}, + deliveredSteer?: string, ) => [ { type: "runtime_exit", status: "success" } as const, out("event.subscribe"), @@ -2333,6 +2913,9 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { finish: "stop", ...assistant, }, + ...(deliveredSteer === undefined + ? [] + : [{ id: deliveredSteer, time: { created: 2 }, text: "steered", type: "user" }]), { id: "msg_0eb735d41001NJee1EvVePJAK5", time: { created: 1 }, @@ -2358,6 +2941,91 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { event.type === "turn_item.updated" ? [`${event.turnItem.type}:${event.turnItem.status}`] : [], ); + it.effect.each(["delivered", "pending"] as const)( + "reconciles a %s steer after reconnect without losing its execution", + (delivery) => + Effect.gen(function* () { + const steerId = `msg_t3_steer_${SESSION}:message:opencode2-adapter:steer`; + const reconnect = reconnected( + {}, + "succeeded", + {}, + delivery === "delivered" ? steerId : undefined, + ); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...(delivery === "pending" + ? [ + event("rpc.opencode.goal.changed", { + sessionID: SESSION, + goal: nativeGoal("active"), + }), + ] + : []), + out("session.prompt", ""), + replyData("session.prompt", { + id: steerId, + sessionID: SESSION, + time: { created: 2 }, + type: "user", + payload: { text: "steered" }, + delivery: "steer", + }), + ...reconnect.slice(0, 2), + ...(delivery === "pending" + ? [out("rpc.call", ""), reply("rpc.call", { output: { unreadable: true } })] + : []), + ...(delivery === "pending" ? emptyShellInventory : []), + ...reconnect.slice(2), + ...(delivery === "pending" + ? [ + event("session.execution.started", { sessionID: SESSION }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: steerId }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_delayed_steer", + ordinal: 0, + text: "Steer processed", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ] + : [event("session.execution.interrupted", { sessionID: SESSION, reason: "user" })]), + ], + delivery === "pending" ? { version: "2.2.5" } : undefined, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + yield* runtime.steerTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + threadId, + runId: turnInput(thread).runId, + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:steer"), + text: "steered", + }, + }); + const collected = yield* Fiber.join(events); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + if (delivery === "pending") + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.type === "assistant_message" && + e.turnItem.text === "Steer processed", + ), + ); + }).pipe(Effect.scoped), + ); + it.effect("does not end a turn from an earlier turn's history when its prompt is not there", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -2779,30 +3447,621 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("stores a shell command that exits non-zero as failed", () => - Effect.gen(function* () { - const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-exit" }; - const { runtime, thread } = yield* resumed([ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.tool.input.started", { ...tool, name: "shell" }), - event("session.tool.called", { ...tool, input: { command: "vp test" }, executed: false }), - { - type: "emit_inbound", - frame: { - type: "sdk.event", - event: { - id: "evt_toolsuccess_call-exit", - created: 1, - type: "session.tool.success", - data: { - ...tool, - content: [{ type: "text", text: "1 failed\n" }], - metadata: { status: "completed", truncated: false, exit: 1 }, - executed: false, - }, - durable: { aggregateID: SESSION, seq: 1, version: 2 }, - }, + it.effect.each(["completed", "error", "cancelled"] as const)( + "retains native image job %s after its launch returns", + (outcome) => + Effect.gen(function* () { + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-image" }; + const notice = (sessionID: string) => + event("session.inbox.enqueued", { + sessionID, + inboxID: `msg_image_${sessionID}`, + item: { + type: "synthetic", + delivery: "steer", + payload: { + description: "Image generation", + text: + outcome === "completed" + ? "Image outcome retained. Saved image: /work/opencode2/image.png (test/image)." + : "Image outcome retained", + metadata: { + source: "experimental_image_generate", + jobID: "image_test", + state: outcome, + path: "/work/opencode2/image", + }, + }, + }, + }); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.tool.input.started", { ...tool, name: "experimental_image_generate" }), + event("session.tool.called", { ...tool, input: { prompt: "A tree" }, executed: false }), + event( + "session.tool.success", + { + ...tool, + content: [{ type: "text", text: "Job launched" }], + metadata: { + jobID: "image_test", + path: "/work/opencode2/image", + model: "test/image", + }, + executed: false, + }, + undefined, + 2, + ), + event("session.execution.succeeded", { sessionID: SESSION }), + notice("ses_other"), + notice(SESSION), + ...(outcome === "completed" + ? [ + out("image.read", { + path: "/api/fs/read/image.png", + "location[directory]": WORK, + }), + reply("image.read", { imageBase64: "iVBORw0KGgo=" }), + ] + : []), + ], + { external: true }, + ); + let launched = false; + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => { + if (e.type !== "provider_thread.updated") return false; + if ( + e.providerThread.pendingBackgroundTasks?.some((task) => task.taskId === "image_test") + ) + launched = true; + return launched && e.pendingBackgroundWork === false; + }), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const items = collected.flatMap((e) => + e.type === "turn_item.updated" && e.turnItem.nativeItemRef?.nativeId === "call-image" + ? [e.turnItem] + : [], + ); + assert.deepEqual( + items.map((item) => item.status), + [ + "running", + "running", + "running", + outcome === "error" ? "failed" : outcome === "cancelled" ? "interrupted" : "completed", + ], + ); + assert.include(JSON.stringify(items.at(-1)), "Image outcome retained"); + if (outcome === "completed") { + const item = items.at(-1); + assert.deepEqual(item?.type === "dynamic_tool" ? toolOutputImages(item.output) : [], [ + { mimeType: "image/png", data: "iVBORw0KGgo=" }, + ]); + } + const rosters = collected.flatMap((e) => + e.type === "provider_thread.updated" + ? [e.providerThread.pendingBackgroundTasks ?? []] + : [], + ); + assert.isTrue(rosters.some((tasks) => tasks.some((task) => task.taskId === "image_test"))); + assert.deepEqual(rosters.at(-1), []); + }).pipe(Effect.scoped), + ); + + it.effect.each(["history", "list", "late-preview"] as const)( + "recovers native image job outcomes from %s after reconnect", + (source) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...(() => { + const frames: Array = reconnected({}, "succeeded", { + content: [ + { + type: "tool", + id: "call-image-gap", + name: "experimental_image_generate", + executed: false, + state: { + status: "completed", + input: { prompt: "A tree" }, + content: [{ type: "text", text: "Job launched" }], + metadata: { jobID: "image_gap", path: "/tmp/image", model: "test/image" }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + }); + if (source !== "history") + frames.splice( + 2, + 0, + out("rpc.call", ""), + reply("rpc.call", { output: null }), + out("job.list", { kind: "shell" }), + reply("job.list", { data: [] }), + ); + return frames; + })(), + out("message.list", { sessionID: SESSION, type: "synthetic", limit: "100" }), + reply("message.list", { + data: + source !== "history" + ? [] + : [ + { + id: "msg_image_gap", + time: { created: 3 }, + type: "synthetic", + text: "Recovered image failure", + description: "Image generation", + metadata: { + source: "experimental_image_generate", + jobID: "image_gap", + state: "error", + path: "/tmp/image", + }, + }, + ], + cursor: {}, + }), + ...(source !== "history" + ? [ + out("imageJobs", { sessionID: SESSION, kind: "image" }), + reply("imageJobs", { + data: [ + { + kind: "image", + id: "image_gap", + sessionID: SESSION, + status: source === "late-preview" ? "completed" : "error", + path: "/tmp/image", + model: "test/image", + time: { started: 2, completed: 3 }, + }, + ], + }), + ] + : []), + ...(source === "late-preview" + ? [ + event("session.inbox.enqueued", { + sessionID: SESSION, + inboxID: "msg_image_late", + item: { + type: "synthetic", + delivery: "steer", + payload: { + description: "Image generation", + text: "Image request finished. Saved image: /tmp/image.png (test/image).", + metadata: { + source: "experimental_image_generate", + jobID: "image_gap", + state: "completed", + path: "/tmp/image", + }, + }, + }, + }), + out("image.read", { + path: "/api/fs/read/image.png", + "location[directory]": "/tmp", + }), + reply("image.read", { imageBase64: "iVBORw0KGgo=" }), + ] + : []), + ], + { ...(source !== "history" ? { version: "2.2.6" as const } : {}), external: true }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil( + (e) => + e.type === "turn_item.updated" && + e.turnItem.nativeItemRef?.nativeId === "call-image-gap" && + (source === "late-preview" + ? e.turnItem.type === "dynamic_tool" && + toolOutputImages(e.turnItem.output).length > 0 + : e.turnItem.status === "failed"), + ), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + assert.include( + JSON.stringify(collected.at(-1)), + source === "late-preview" + ? "Saved image: /tmp/image.png" + : source !== "history" + ? "Image generation error" + : "Recovered image failure", + ); + assert.isTrue( + collected.some( + (e) => + e.type === "provider_thread.updated" && + e.providerThread.pendingBackgroundTasks?.some((task) => task.taskId === "image_gap"), + ), + ); + }).pipe(Effect.scoped), + ); + + it.effect("settles a recovered image outcome when preview I/O is interrupted", () => + Effect.gen(function* () { + const reading = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const fs = yield* FileSystem.FileSystem; + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...reconnected({}, "succeeded", { + content: [ + { + type: "tool", + id: "call-image-slow", + name: "experimental_image_generate", + executed: false, + state: { + status: "completed", + input: { prompt: "A tree" }, + content: [{ type: "text", text: "Launched" }], + metadata: { jobID: "image_slow", path: "/tmp/image", model: "test/image" }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + }), + out("message.list", { sessionID: SESSION, type: "synthetic", limit: "100" }), + reply("message.list", { + data: [ + { + id: "msg_image_slow", + type: "synthetic", + time: { created: 3 }, + text: "Image request finished. Saved image: /tmp/image.png (test/image).", + description: "Image generation", + metadata: { + source: "experimental_image_generate", + jobID: "image_slow", + state: "completed", + path: "/tmp/image", + }, + }, + ], + cursor: {}, + }), + event("session.model.selected", { + sessionID: SESSION, + model: { providerID: "opencode", id: "big-pickle", variant: "recovery-observed" }, + }), + ], + { + fileSystem: { + ...fs, + stat: (path) => + path === "/tmp/image.png" + ? Deferred.succeed(reading, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ) + : fs.stat(path), + }, + }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil( + (e) => + e.type === "provider_thread.updated" && + e.providerThread.nativeMetadata?.modelSelection?.options?.some( + (option) => option.value === "recovery-observed", + ) === true, + ), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + yield* Deferred.await(reading); + yield* TestClock.adjust("5 seconds"); + yield* Deferred.await(interrupted); + const collected = yield* Fiber.join(events); + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.nativeItemRef?.nativeId === "call-image-slow" && + e.turnItem.status === "completed", + ), + ); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.each(["root", "child"] as const)( + "settles %s native image tasks when reconnect attempts fail", + (owner) => + Effect.gen(function* () { + const attempts = yield* Effect.forEach([0, 1, 2, 3, 4], () => Deferred.make()); + let count = 0; + let failures = 0; + const borrowers = { + get current() { + return count; + }, + set current(next: number) { + if (next < count && count === 2) { + const attempt = attempts[failures++]; + if (attempt !== undefined) Deferred.doneUnsafe(attempt, Effect.void); + } + count = next; + }, + }; + const tool = { + sessionID: owner === "root" ? SESSION : CHILD, + assistantMessageID: "msg_assistant", + id: "call-image-lost", + }; + const { runtime, thread } = yield* resumed( + [ + ...(owner === "root" + ? [out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted] + : [ + ...backgroundLaunch(CHILD), + event("session.execution.started", { sessionID: CHILD }), + ]), + event("session.tool.input.started", { ...tool, name: "experimental_image_generate" }), + event( + "session.tool.success", + { + ...tool, + content: [{ type: "text", text: "Launched" }], + metadata: { jobID: "image_lost", path: "/tmp/image", model: "test/image" }, + executed: false, + }, + undefined, + 2, + ), + ...(owner === "child" + ? [event("session.execution.succeeded", { sessionID: CHILD })] + : []), + event("session.execution.succeeded", { sessionID: SESSION }), + { type: "runtime_exit", status: "success" }, + ], + { borrowers }, + ); + const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(attempts[0]!); + for (const attempt of attempts.slice(1)) { + yield* TestClock.adjust("2 seconds"); + yield* Deferred.await(attempt); + } + const collected = yield* Fiber.join(events); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.nativeItemRef?.nativeId === + (owner === "root" ? tool.id : `${CHILD}:${tool.id}`) && + e.turnItem.status === "failed", + ), + ); + const rootSnapshots = collected.flatMap((e) => + e.type === "provider_thread.updated" && e.providerThread.id === thread.id ? [e] : [], + ); + assert.isFalse(rootSnapshots.at(-1)?.pendingBackgroundWork); + assert.deepEqual(rootSnapshots.at(-1)?.providerThread.pendingBackgroundTasks, []); + assert.isTrue( + rootSnapshots.some((e) => + e.providerThread.pendingBackgroundTasks?.some((task) => task.taskId === "image_lost"), + ), + ); + }).pipe(Effect.scoped), + ); + + it.effect("projects successful native todo updates into one checklist", () => + Effect.gen(function* () { + const updates = [ + [ + { content: " Inspect code ", status: "completed" }, + { content: "Implement", status: "in_progress" }, + { content: "Verify", status: "pending" }, + { content: "Abandoned", status: "cancelled" }, + ], + [{ content: "Inspect code", status: "completed" }], + [{ content: "Abandoned", status: "cancelled" }], + [], + [{ content: "Follow up", status: "pending" }], + ]; + const tool = (id: string) => ({ + sessionID: SESSION, + assistantMessageID: "msg_assistant", + id, + }); + const call = (id: string, todos: unknown): ReadonlyArray => [ + event("session.tool.input.started", { ...tool(id), name: "todowrite" }), + event("session.tool.called", { ...tool(id), input: { todos }, executed: false }), + ]; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + ...call("call-denied", [{ content: "Not saved", status: "pending" }]), + event( + "session.tool.failed", + { + ...tool("call-denied"), + error: { type: "unknown", message: "Unable to update todos" }, + executed: false, + }, + undefined, + 2, + ), + ...call("call-malformed", [{ content: "Not saved", status: "pending" }]), + event( + "session.tool.success", + { + ...tool("call-malformed"), + content: [{ type: "text", text: "Unrecognized todo result" }], + metadata: { todos: [{ content: "Unknown", status: "new-status" }] }, + executed: false, + }, + undefined, + 2, + ), + ...updates.flatMap((todos, index) => [ + ...call(`call-todo-${index}`, todos), + event( + "session.tool.success", + { + ...tool(`call-todo-${index}`), + content: [{ type: "text", text: "Todos updated" }], + metadata: { todos }, + executed: false, + }, + undefined, + 2, + ), + ]), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const plans = collected.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], + ); + assert.deepEqual( + plans.map((plan) => plan.steps.map((step) => [step.text, step.status])), + [ + [ + ["Inspect code", "completed"], + ["Implement", "running"], + ["Verify", "pending"], + ], + [["Inspect code", "completed"]], + [], + [], + [["Follow up", "pending"]], + ], + ); + assert.equal(new Set(plans.map((plan) => plan.id)).size, 1); + assert.equal(plans[0]?.steps[0]?.id, plans[1]?.steps[0]?.id); + assert.deepEqual( + plans.map((plan) => plan.status), + ["active", "completed", "completed", "completed", "active"], + ); + const items = collected.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + const checklists = items.filter((item) => item.type === "todo_list"); + assert.equal(new Set(checklists.map((item) => item.id)).size, 1); + assert.deepEqual( + checklists.map((item) => item.steps), + plans.map((plan) => plan.steps), + ); + assert.deepEqual( + checklists.map((item) => item.status), + ["completed", "completed", "completed", "completed", "completed"], + ); + assert.isFalse( + items.some( + (item) => + item.type === "dynamic_tool" && item.nativeItemRef?.nativeId?.startsWith("call-todo-"), + ), + ); + assert.deepInclude( + items.find((item) => item.nativeItemRef?.nativeId === "call-denied"), + { type: "dynamic_tool", status: "failed" }, + ); + assert.deepInclude( + items.find((item) => item.nativeItemRef?.nativeId === "call-malformed"), + { type: "dynamic_tool", status: "completed" }, + ); + }).pipe(Effect.scoped), + ); + + it.effect("recovers native todo updates missed during reconnect", () => + Effect.gen(function* () { + const todos = [{ content: "Saved during disconnect", status: "in_progress" }]; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...reconnected({}, "succeeded", { + content: [ + { + type: "tool", + id: "call-todo-gap", + name: "todowrite", + executed: false, + state: { + status: "completed", + input: { todos }, + content: [{ type: "text", text: "Todos updated" }], + metadata: { todos }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const plans = collected.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], + ); + assert.equal(plans.length, 1); + assert.deepEqual( + plans[0]?.steps.map((step) => [step.text, step.status]), + [["Saved during disconnect", "running"]], + ); + assert.include(turnItems(collected), "todo_list:completed"); + }).pipe(Effect.scoped), + ); + + it.effect("stores a shell command that exits non-zero as failed", () => + Effect.gen(function* () { + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-exit" }; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.tool.input.started", { ...tool, name: "shell" }), + event("session.tool.called", { ...tool, input: { command: "vp test" }, executed: false }), + { + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: "evt_toolsuccess_call-exit", + created: 1, + type: "session.tool.success", + data: { + ...tool, + content: [{ type: "text", text: "1 failed\n" }], + metadata: { status: "completed", truncated: false, exit: 1 }, + executed: false, + }, + durable: { aggregateID: SESSION, seq: 1, version: 2 }, + }, }, }, event("session.execution.succeeded", { sessionID: SESSION }), @@ -4886,21 +6145,126 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { event("session.execution.succeeded", { sessionID: SESSION }), ]).pipe( Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: (request) => - Effect.suspend(() => - offers.push(request) === 2 - ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid) - : Effect.void, - ), + offer: (request) => + Effect.suspend(() => + offers.push(request) === 2 + ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + take: Effect.never, + }), + ); + const collected: Array = []; + const ended = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + collected.push(event); + if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { + yield* Deferred.succeed(ended, undefined); + } + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(bothOffered); + yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b")); + yield* Deferred.await(ended); + const continuationId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake-b`, + }); + assert.deepEqual( + collected.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.providerTurnId === continuationId + ? [event.turnItem.text] + : [], + ), + ["REPLY_B"], + ); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), + ); + + it.effect("shows a question asked while OpenCode wakes for a background reply", () => + Effect.gen(function* () { + const OTHER = "ses_other00000000000000000000"; + const gate = makeProviderReplayGate(["continued"]); + const offered = + yield* Deferred.make(); + const { runtime, thread } = yield* resumed( + [ + out("session.get", { sessionID: OTHER }), + replyData("session.get", sessionInfo({ id: OTHER })), + out("permission.list", { sessionID: OTHER }), + replyData("permission.list", []), + out("session.form.list", { sessionID: OTHER }), + replyData("session.form.list", []), + ...backgroundLaunch(CHILD), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD }), + childReport("msg_report", "CHILD_OK"), + event("session.execution.started", { sessionID: SESSION }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report" }), + event("form.created", { form: colorForm }), + // Handled after the form, so its update proves the form reached the adapter. + event("session.model.selected", { + sessionID: OTHER, + model: { providerID: "opencode", id: "big-pickle", variant: "none" }, + }), + // Released once the continuation started, so its update follows the replay. + event( + "session.model.selected", + { + sessionID: OTHER, + model: { providerID: "opencode", id: "big-pickle", variant: "thinking" }, + }, + "continued", + ), + out("session.form.reply", { + sessionID: SESSION, + formID: colorForm.id, + answer: { q0: "Red" }, + }), + reply("session.form.reply", null), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { replayGate: gate }, + ).pipe( + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid), take: Effect.never, }), ); + yield* runtime.resumeThread({ + providerThread: { + ...thread, + id: ProviderThreadId.make("provider-thread:opencode2-adapter:other"), + nativeThreadRef: { driver: OPENCODE_PROVIDER, nativeId: OTHER, strength: "strong" }, + }, + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); const collected: Array = []; + const formSeen = yield* Deferred.make(); + const replayed = yield* Deferred.make(); const ended = yield* Deferred.make(); + const otherVariant = (event: ProviderAdapter.ProviderAdapterV2Event) => + event.type === "provider_thread.updated" && + event.providerThread.nativeThreadRef?.nativeId === OTHER + ? event.providerThread.nativeMetadata?.modelSelection?.options?.[0]?.value + : undefined; yield* runtime.events.pipe( Stream.tap((event) => Effect.gen(function* () { collected.push(event); + if (otherVariant(event) === "none") yield* Deferred.succeed(formSeen, undefined); + if (otherVariant(event) === "thinking") yield* Deferred.succeed(replayed, undefined); if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) { yield* Deferred.succeed(ended, undefined); } @@ -4910,25 +6274,28 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { Effect.forkScoped, ); yield* runtime.startTurn(withLineage(thread)); - yield* Deferred.await(bothOffered); - yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b")); - yield* Deferred.await(ended); - const continuationId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: OPENCODE_PROVIDER, - nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake-b`, - }); + const offer = yield* Deferred.await(offered); + yield* Deferred.await(formSeen); + yield* runtime.startTurn(continuationTurn(thread, offer)); + gate.release("continued"); + yield* Deferred.await(replayed); + const requests = collected.flatMap((event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending" + ? [event.runtimeRequest] + : [], + ); assert.deepEqual( - collected.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.providerTurnId === continuationId - ? [event.turnItem.text] - : [], - ), - ["REPLY_B"], + requests.map((request) => request.nativeRequestRef?.nativeId), + [colorForm.id], ); - assert.isFalse(yield* runtime.hasPendingBackgroundWork!); - }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), + yield* runtime.respondToRuntimeRequest({ + requestId: requests[0]!.id, + answers: { q0: "Red" }, + }); + yield* Deferred.await(ended); + const last = collected.findLast((event) => event.type === "turn.terminal"); + assert.equal(last?.type === "turn.terminal" ? last.status : undefined, "completed"); + }).pipe(Effect.scoped), ); it.effect("ends a follow-up turn a steer joined exactly once", () => @@ -5031,6 +6398,441 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { assert.lengthOf(offers, 1); }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), ); + it.effect.each(["", "pause", "clear", "resume", "Write proof.txt"])( + "preserves older OpenCode goal command execution for /goal %s", + (control) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("command.list", ""), + reply("command.list", { location: { directory: WORK }, data: [{ name: "goal" }] }), + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { data: [], cursor: {} }), + out("session.command", { sessionID: SESSION, name: "goal", text: control }), + reply("session.command", null), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_legacy_goal", + ordinal: 0, + text: "Legacy command finished", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { goal: { objective: "Saved before downgrade", status: "active" } }, + ); + assert.isFalse(runtime.providerSession.capabilities.turns.supportsNativeGoals); + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const input = turnInput(thread); + yield* runtime.startTurn({ + ...input, + message: { ...input.message, text: `/goal ${control}`.trim() }, + }); + const collected = yield* Fiber.join(events); + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.type === "assistant_message" && + e.turnItem.text === "Legacy command finished", + ), + ); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect("reads an existing OpenCode 2.2.5 goal when resuming a session", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntime( + [ + ...opening, + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + ...noOpenRequests, + out("rpc.call", { + rpcID: "opencode.goal", + method: "get", + "location[directory]": WORK, + input: { sessionID: SESSION }, + }), + reply("rpc.call", { output: nativeGoal("paused") }), + ...emptyShellInventory, + ], + { version: "2.2.5" }, + ); + assert.isTrue(runtime.providerSession.capabilities.turns.supportsNativeGoals); + const thread = yield* runtime.resumeThread({ + providerThread: providerThread(yield* DateTime.now), + threadId, + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + assert.deepEqual(thread.goal, { + objective: "Write proof.txt", + status: "paused", + checks: 2, + lastCheck: "Checked proof.txt", + }); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { status: "complete", reconnect: false }, + { status: "paused", reconnect: false }, + { status: "complete", reconnect: true }, + ] as const)( + "keeps an OpenCode goal turn open until $status, with reconnect $reconnect", + ({ status, reconnect }) => + Effect.gen(function* () { + const recovered = reconnected({ [SESSION]: { type: "running" } }); + const { runtime, thread } = yield* resumed( + [ + out("command.list", ""), + reply("command.list", { location: { directory: WORK }, data: [{ name: "goal" }] }), + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { data: [], cursor: {} }), + out("session.command", { sessionID: SESSION, name: "goal", text: "Write proof.txt" }), + reply("session.command", null), + event("rpc.opencode.goal.changed", { sessionID: SESSION, goal: nativeGoal("active") }), + event("session.execution.succeeded", { sessionID: SESSION }), + ...(status === "complete" + ? reconnect + ? [ + ...recovered.slice(0, 2), + out("rpc.call", ""), + reply("rpc.call", { output: nativeGoal("active") }), + ...emptyShellInventory, + ...recovered.slice(2), + ...noOpenRequests, + ] + : [event("session.execution.started", { sessionID: SESSION })] + : []), + event("rpc.opencode.goal.changed", { sessionID: SESSION, goal: nativeGoal(status) }), + ...(status === "complete" + ? [ + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_goal_final", + ordinal: 0, + text: "Final goal output", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ] + : []), + ], + { version: "2.2.5" }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const input = turnInput(thread); + yield* runtime.startTurn({ + ...input, + message: { ...input.message, text: "/goal Write proof.txt" }, + }); + const collected = yield* Fiber.join(events); + const goals = collected.flatMap((e) => + e.type === "provider_thread.updated" && e.providerThread.goal + ? [e.providerThread.goal] + : [], + ); + assert.includeDeepMembers(goals, [ + { + objective: "Write proof.txt", + status: "active", + checks: 2, + lastCheck: "Checked proof.txt", + }, + { + objective: "Write proof.txt", + status, + checks: 2, + lastCheck: "Checked proof.txt", + }, + ]); + if (status === "complete") + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.type === "assistant_message" && + e.turnItem.text === "Final goal output", + ), + ); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect("does not replace a newer goal event with an older goal RPC response", () => + Effect.gen(function* () { + const gate = makeProviderReplayGate(["release-goal-read"]); + const { runtime, thread } = yield* resumed( + [ + out("rpc.call", ""), + event("rpc.opencode.goal.changed", { sessionID: SESSION, goal: nativeGoal("paused") }), + event( + "rpc.opencode.goal.changed", + { sessionID: SESSION, goal: nativeGoal("paused") }, + "release-goal-read", + ), + reply("rpc.call", { output: nativeGoal("active") }), + ], + { version: "2.2.5", replayGate: gate }, + ); + const changed = yield* Deferred.make(); + const events = yield* runtime.events.pipe( + Stream.tap((e) => + e.type === "provider_thread.updated" && e.providerThread.goal?.status === "paused" + ? Deferred.succeed(changed, undefined) + : Effect.void, + ), + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const input = turnInput(thread); + const starting = yield* runtime + .startTurn({ ...input, message: { ...input.message, text: "/goal" } }) + .pipe(Effect.forkScoped); + yield* Deferred.await(changed); + gate.release("release-goal-read"); + yield* Fiber.join(starting); + const collected = yield* Fiber.join(events); + const updated = collected.findLast((e) => e.type === "provider_thread.updated"); + assert.equal( + updated?.type === "provider_thread.updated" + ? updated.providerThread.goal?.status + : undefined, + "paused", + ); + }).pipe(Effect.scoped), + ); + + it.effect.each(["unreadable", "stalled"] as const)( + "settles a completed execution after reconnect when goal refresh is %s", + (failure) => + Effect.gen(function* () { + const gate = makeProviderReplayGate(["goal-refresh-stalled"]); + const reconnect = reconnected({}, "succeeded"); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + event("rpc.opencode.goal.changed", { sessionID: SESSION, goal: nativeGoal("active") }), + ...reconnect.slice(0, 2), + out("rpc.call", ""), + reply("rpc.call", failure === "stalled" ? "" : { output: { unreadable: true } }), + ...(failure === "stalled" + ? [ + event( + "rpc.opencode.goal.changed", + { sessionID: SESSION, goal: null }, + "goal-refresh-stalled", + ), + ] + : []), + ...emptyShellInventory, + ...reconnect.slice(2), + ], + { version: "2.2.5", replayGate: gate }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + if (failure === "stalled") { + yield* Effect.promise(() => gate.waitForReached("goal-refresh-stalled")); + gate.release("goal-refresh-stalled"); + yield* TestClock.adjust("5 seconds"); + } + const collected = yield* Fiber.join(events); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { initial: "active", recovered: "paused" }, + { initial: "paused", recovered: "active" }, + { initial: "active", recovered: "unreadable" }, + ] as const)( + "recovers unreadable native goal updates when cached goal is $initial and RPC returns $recovered", + ({ initial, recovered }) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + event("rpc.opencode.goal.changed", { sessionID: SESSION, goal: nativeGoal(initial) }), + event("rpc.opencode.goal.changed", { + sessionID: SESSION, + goal: { ...nativeGoal(initial), status: "unknown" }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + out("rpc.call", ""), + reply("rpc.call", { output: recovered === "unreadable" ? {} : nativeGoal(recovered) }), + ...(recovered === "active" + ? [ + event("session.execution.started", { sessionID: SESSION }), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_recovered_goal", + ordinal: 0, + text: "Continued work", + }), + event("rpc.opencode.goal.changed", { + sessionID: SESSION, + goal: nativeGoal("complete"), + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ] + : []), + ], + { version: "2.2.5" }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const updated = collected.findLast((e) => e.type === "provider_thread.updated"); + assert.equal( + updated?.type === "provider_thread.updated" + ? updated.providerThread.goal?.status + : undefined, + recovered === "active" ? "complete" : recovered === "paused" ? "paused" : "active", + ); + if (recovered === "active") + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.type === "assistant_message" && + e.turnItem.text === "Continued work", + ), + ); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect.each(["show", "pause", "clear"] as const)( + "a delayed /goal %s response cannot finish its replacement turn", + (control) => + Effect.gen(function* () { + const operation = control === "show" ? "rpc.call" : "session.command"; + const { runtime, thread } = yield* resumed( + [ + ...(control === "show" + ? [] + : [ + out("command.list", ""), + reply("command.list", { + location: { directory: WORK }, + data: [{ name: "goal" }], + }), + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { data: [], cursor: {} }), + ]), + out(operation, ""), + event("session.execution.interrupted", { sessionID: SESSION, reason: "user" }), + out("session.prompt", ""), + promptAccepted, + reply(operation, control === "show" ? { output: nativeGoal("paused") } : null), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_replacement", + ordinal: 0, + text: "Replacement still tracked", + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { version: "2.2.5" }, + ); + const interrupted = yield* Deferred.make(); + const events = yield* runtime.events.pipe( + Stream.tap((e) => + e.type === "turn.terminal" && e.status === "interrupted" + ? Deferred.succeed(interrupted, undefined) + : Effect.void, + ), + Stream.takeUntil((e) => e.type === "turn.terminal" && e.status === "completed"), + Stream.runCollect, + Effect.forkScoped, + ); + const input = turnInput(thread); + const starting = yield* runtime + .startTurn({ + ...input, + message: { ...input.message, text: control === "show" ? "/goal" : `/goal ${control}` }, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(interrupted); + yield* runtime.startTurn({ + ...input, + attemptId: RunAttemptId.make("replacement"), + providerTurnOrdinal: 2, + }); + yield* Fiber.join(starting); + const collected = yield* Fiber.join(events); + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.type === "assistant_message" && + e.turnItem.text === "Replacement still tracked", + ), + ); + }).pipe(Effect.scoped), + ); + + it.effect.each(["show", "pause", "clear"] as const)( + "settles OpenCode /goal %s without waiting for an execution", + (control) => + Effect.gen(function* () { + const goal = control === "clear" ? null : nativeGoal("paused"); + const { runtime, thread } = yield* resumed( + control === "show" + ? [out("rpc.call", ""), reply("rpc.call", { output: goal })] + : [ + out("command.list", ""), + reply("command.list", { location: { directory: WORK }, data: [{ name: "goal" }] }), + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { data: [], cursor: {} }), + out("session.command", { sessionID: SESSION, name: "goal", text: control }), + event("rpc.opencode.goal.changed", { sessionID: SESSION, goal }), + reply("session.command", null), + ], + { version: "2.2.5" }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => e.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const input = turnInput(thread); + yield* runtime.startTurn({ + ...input, + message: { ...input.message, text: control === "show" ? "/goal" : `/goal ${control}` }, + }); + const collected = yield* Fiber.join(events); + const updated = collected.findLast((e) => e.type === "provider_thread.updated"); + assert.equal( + updated?.type === "provider_thread.updated" + ? (updated.providerThread.goal?.status ?? null) + : undefined, + goal?.status ?? null, + ); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); }); const providerTurnId = Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 46421aa5c5c86..0196f6e2631ab 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -54,6 +54,26 @@ const operationOf = ( query: Record, body: unknown, ) => { + if (method === "GET" && path === "/api/experimental/job" && query.kind === "image") + return { type: "imageJobs", input: query }; + if (method === "GET" && path.startsWith("/api/fs/read/")) + return { type: "image.read", input: { path, ...query } }; + const rpc = /^\/api\/rpc\/([^/]+)\/([^/]+)$/.exec(path); + if (method === "POST" && rpc !== null) + return { + type: "rpc.call", + input: { rpcID: rpc[1], method: rpc[2], ...query, ...(body as object) }, + }; + if (method === "GET" && path === "/api/experimental/job") + return { type: "job.list", input: query }; + const shell = /^\/api\/shell(?:\/([^/]+)(\/output)?)?$/.exec(path); + if (shell !== null) { + const input = { ...(shell[1] === undefined ? {} : { id: shell[1] }), ...query }; + return { + type: `shell.${method === "DELETE" ? "remove" : shell[2] ? "output" : shell[1] ? "get" : "list"}`, + input, + }; + } const session = /^\/api\/session\/([^/]+)(\/.*)?$/.exec(path); if (method === "GET" && path === "/api/event") return { type: "event.subscribe" }; if (method === "GET" && path === "/api/model") return { type: "model.list", input: query }; @@ -170,6 +190,11 @@ const replayHttpClient = ( } // Recorded responses are the raw HTTP bodies; `null` is an empty 204. const body = await controller.response(operation.type); + if (typeof body === "object" && body !== null && "imageBase64" in body) { + return new Response(Buffer.from(String(body.imageBase64), "base64"), { + headers: { "content-type": "image/png" }, + }); + } if (body === "") return new Promise(() => {}); if (typeof body === "object" && body !== null && "status" in body && "body" in body) { return new Response(encodeJson(body.body), { @@ -292,6 +317,7 @@ export const openCode2ReplayRuntime = ( entries: ReadonlyArray, options?: { readonly external?: boolean; + readonly version?: string; readonly onPrompt?: (input: unknown) => void; readonly borrowers?: { current: number }; readonly replayGate?: ProviderReplayGate; @@ -306,7 +332,7 @@ export const openCode2ReplayRuntime = ( { provider: OPENCODE_PROVIDER, protocol: OPENCODE2_HTTP_PROTOCOL, - version: "2.0.18", + version: options?.version ?? "2.0.18", scenario: "opencode2_adapter", entries, }, diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 47a2ae7cd2110..aeadd6329df88 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -1058,6 +1058,73 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { }), ); + it.effect("acknowledging a queued wake's results rewrites it, then cancels it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("queued-wake-ack-parent"); + const runId = RunId.make("queued-wake-ack-origin"); + const taskId = NodeId.make("queued-wake-ack-first"); + const siblingId = NodeId.make("queued-wake-ack-sibling"); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make("queued-wake-ack-project"), + runId, + rootNodeId: NodeId.make("queued-wake-ack-root"), + taskId, + deliveryState: "claimed", + deliveryTaskIds: [taskId, siblingId], + siblingTasks: [{ id: siblingId, deliveryState: "claimed" }], + completionWake: "always", + now, + }); + const delivery = (yield* orchestrator.getThreadProjection(threadId)).runs[0]! + .delegatedCompletion!.delivery!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make("queued-wake-ack-wake"), + threadId, + messageId: delivery.messageId, + text: "Delegated tasks completed", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + delegatedCompletion: { + parentRunId: runId, + generation: delivery.generation, + taskIds: delivery.taskIds, + }, + }); + const acknowledge = (id: NodeId) => + orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make(`queued-wake-ack:${id}`), + parentThreadId: threadId, + taskId: id, + observedByRunId: runId, + }); + + yield* acknowledge(taskId); + const rewritten = yield* orchestrator.getThreadProjection(threadId); + const wake = rewritten.runs.find((run) => run.userMessageId === delivery.messageId)!; + assert.equal(wake.status, "queued"); + const message = rewritten.messages.find((row) => row.id === delivery.messageId); + assert.deepEqual(message?.delegatedCompletion?.taskIds, [siblingId]); + assert.equal(message?.text, `Delegated task ${siblingId} [completed]:\nchild finished`); + + yield* acknowledge(siblingId); + const cancelled = yield* orchestrator.getThreadProjection(threadId); + assert.equal(cancelled.runs.find((run) => run.id === wake.id)?.status, "cancelled"); + assert.equal( + cancelled.nodes.find((node) => node.id === wake.rootNodeId)?.status, + "cancelled", + ); + assert.isNull(cancelled.runs.find((run) => run.id === runId)?.delegatedCompletion?.delivery); + }), + ); + it.effect.each([ { command: "acknowledge", before: "running", after: "failed", capture: false }, { command: "acknowledge", before: "waiting", after: "completed", capture: true }, @@ -1383,6 +1450,145 @@ const seedRestartCancelledChild = (input: { return { taskId, childThreadId, childRunId }; }); +// Parent spawned two always-wake tasks: the incoming one's completion wake is queued behind a busy +// run while the sibling's child run is still going. +const queueCompletionWakeBesideRunningSibling = (key: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`${key}-parent`); + const originRunId = RunId.make(`${key}-origin`); + const originRootId = NodeId.make(`${key}-origin-root`); + const incomingTaskId = NodeId.make(`${key}-incoming`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`${key}-project`), + runId: originRunId, + rootNodeId: originRootId, + taskId: incomingTaskId, + deliveryState: "claimed", + deliveryTaskIds: [incomingTaskId], + completionWake: "always", + now, + }); + yield* sink.write({ + events: [ + { + id: EventId.make(`event:${key}-origin-root`), + type: "node.updated", + threadId, + runId: originRunId, + nodeId: originRootId, + occurredAt: now, + payload: { + id: originRootId, + threadId, + runId: originRunId, + parentNodeId: null, + rootNodeId: originRootId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make(`${key}-sibling`), + parentThreadId: threadId, + parentRunId: originRunId, + parentNodeId: originRootId, + task: "Keep working", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + }); + const seeded = yield* orchestrator.getThreadProjection(threadId); + const origin = seeded.runs.find((run) => run.id === originRunId)!; + const sibling = seeded.subagents.find((task) => task.id !== incomingTaskId)!; + // The coordinator finished its spawning turn but is busy with another run, so the wake queues. + yield* sink.write({ + events: [ + { + ...runEvent({ threadId, runId: originRunId, ordinal: 1, status: "completed", now }), + payload: { ...origin, status: "completed", completedAt: now }, + }, + runEvent({ + threadId, + runId: RunId.make(`${key}-busy`), + ordinal: 2, + status: "running", + now, + }), + ], + }); + const delivery = origin.delegatedCompletion!.delivery!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`${key}-wake`), + threadId, + messageId: delivery.messageId, + text: "Delegated task completed", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + delegatedCompletion: { + parentRunId: originRunId, + generation: delivery.generation, + taskIds: delivery.taskIds, + }, + }); + const wake = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === delivery.messageId, + )!; + assert.equal(wake.status, "queued"); + const completeSibling = Effect.gen(function* () { + const childRun = (yield* orchestrator.getThreadProjection(sibling.childThreadId!)).runs[0]!; + const afterSequence = yield* sink.latestSequence(); + yield* sink.write({ + events: [ + { + ...runEvent({ + threadId: sibling.childThreadId!, + runId: childRun.id, + ordinal: childRun.ordinal, + status: "completed", + now, + }), + payload: { ...childRun, status: "completed", completedAt: now }, + }, + ], + }); + return afterSequence; + }); + return { + orchestrator, + sink, + now, + threadId, + originRunId, + incomingTaskId, + sibling, + delivery, + wake, + completeSibling, + }; + }); + it.layer(layerContinuationWorkerTest)("interrupted completion wakes", (it) => { it.effect.each([ { name: "an interrupt keeps", holdQueue: false, siblingFinishesFirst: false }, @@ -1626,6 +1832,137 @@ it.layer(layerContinuationWorkerTest)("interrupted completion wakes", (it) => { ); }), ); + + it.effect("cancelling a queued completion wake keeps its other siblings", () => + Effect.gen(function* () { + const { + orchestrator, + sink, + threadId, + originRunId, + incomingTaskId, + sibling, + delivery, + wake, + completeSibling, + } = yield* queueCompletionWakeBesideRunningSibling("sibling-queued-cancel"); + const cancelled = yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("sibling-queued-cancel-cancel"), + threadId, + runId: wake.id, + }); + const cancelDelivery = new Map( + cancelled.storedEvents.flatMap(({ event }) => + event.type === "subagent.updated" + ? [[event.payload.id, event.payload.completionDelivery?.state] as const] + : [], + ), + ); + assert.equal(cancelDelivery.get(incomingTaskId), "disposed"); + assert.notEqual( + cancelDelivery.get(sibling.id), + "disposed", + "cancelling the wake must not dispose a still-running sibling it did not carry", + ); + const afterCancel = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterCancel.runs.find((run) => run.id === originRunId)?.delegatedCompletion?.disposition, + "open", + ); + + const afterSequence = yield* completeSibling; + yield* sink.stream({ afterSequence, eventType: "subagent.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === sibling.id && + stored.event.payload.completionDelivery?.state === "claimed", + ), + Stream.take(1), + Stream.runDrain, + ); + const successor = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.id === originRunId, + )?.delegatedCompletion?.delivery; + assert.deepEqual(successor?.taskIds, [sibling.id]); + assert.notEqual(successor?.messageId, delivery.messageId); + }), + ); + + it.effect("settling with a queued completion wake disposes its running siblings", () => + Effect.gen(function* () { + const { orchestrator, sink, now, threadId, originRunId, sibling, wake, completeSibling } = + yield* queueCompletionWakeBesideRunningSibling("sibling-settle"); + const busy = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "running", + )!; + yield* sink.write({ + events: [ + { + ...runEvent({ + threadId, + runId: busy.id, + ordinal: busy.ordinal, + status: "cancelled", + now, + }), + payload: { ...busy, status: "cancelled", completedAt: now }, + }, + { + ...runEvent({ threadId, runId: wake.id, ordinal: wake.ordinal, status: "queued", now }), + payload: { ...wake, queueHeld: true }, + }, + ], + }); + + const settled = yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("sibling-settle-settle"), + threadId, + }); + assert.deepEqual( + settled.storedEvents.flatMap(({ event }) => + event.type === "run.updated" && event.payload.id === wake.id + ? [event.payload.status] + : [], + ), + ["cancelled"], + ); + const afterSettle = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterSettle.subagents.find((task) => task.id === sibling.id)?.completionDelivery?.state, + "disposed", + ); + + const afterSequence = yield* completeSibling; + yield* sink.stream({ afterSequence, eventType: "subagent.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === sibling.id && + stored.event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + ); + const afterLateResult = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterLateResult.subagents.find((task) => task.id === sibling.id)?.completionDelivery?.state, + "disposed", + ); + assert.deepEqual( + afterLateResult.runs.map((run) => run.id), + afterSettle.runs.map((run) => run.id), + ); + assert.equal( + afterLateResult.runs.find((run) => run.id === originRunId)?.delegatedCompletion + ?.disposition, + "disposed", + ); + assert.equal(afterLateResult.thread.settledOverride, "settled"); + }), + ); }); it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( @@ -2164,6 +2501,287 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( }), ); + // The child's follow-up run sends `t3_thread_send` to its own thread before it ends. + const startSelfQueuedFollowup = (name: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const now = yield* DateTime.now; + const parentThreadId = ThreadId.make(`thread:${name}-parent`); + const projectId = ProjectId.make(`project:${name}`); + const parentRunId = RunId.make(`run:${name}-parent`); + const rootNodeId = NodeId.make(`node:${name}-parent`); + yield* seedParentWithTerminalTask({ + threadId: parentThreadId, + projectId, + runId: parentRunId, + rootNodeId, + taskId: NodeId.make(`node:${name}-seed`), + deliveryState: "acknowledged", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId, + projectId, + parentRunId, + rootNodeId, + name: `${name}-child`, + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.recoverDelegatedTasks; + let firstWake = yield* requests.take; + while (firstWake.threadId !== parentThreadId) firstWake = yield* requests.take; + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make(`command:${name}-ack`), + parentThreadId, + taskId: child.taskId, + observedByRunId: parentRunId, + }); + const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.find( + (run) => run.id === parentRunId, + )!; + yield* eventSink.write({ + commandId: reconcileCommandId(`${name}-parent-finished`), + events: [ + { + id: EventId.make(`event:${name}-parent-finished`), + type: "run.updated", + threadId: parentThreadId, + runId: parentRunId, + occurredAt: now, + payload: { ...parentRun, status: "completed", completedAt: now }, + }, + ], + }); + const followupMessageId = MessageId.make(`message:${name}`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make(`command:${name}`), + threadId: child.childThreadId, + senderThreadId: parentThreadId, + messageId: followupMessageId, + text: "Check the same work again.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const queuedRun = (yield* orchestrator.getThreadProjection(child.childThreadId)).runs.find( + (row) => row.userMessageId === followupMessageId, + )!; + const followupTaskId = queuedRun.delegatedTaskId; + assert.isDefined(followupTaskId); + const followupRun = { ...queuedRun, status: "running" as const, startedAt: now }; + yield* eventSink.write({ + commandId: reconcileCommandId(`${name}-running`), + events: [ + { + id: EventId.make(`event:${name}-running`), + type: "run.updated", + threadId: child.childThreadId, + runId: followupRun.id, + occurredAt: now, + payload: followupRun, + }, + ], + }); + const selfQueuedMessageId = MessageId.make(`message:${name}-self-send`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make(`command:${name}-self-send`), + threadId: child.childThreadId, + senderThreadId: child.childThreadId, + messageId: selfQueuedMessageId, + text: "Finish the work after this turn.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const selfQueuedRun = (yield* orchestrator.getThreadProjection( + child.childThreadId, + )).runs.find((row) => row.userMessageId === selfQueuedMessageId)!; + assert.equal(selfQueuedRun.status, "queued"); + const finishRun = (step: string, run: OrchestrationV2Run, text: string) => + eventSink.write({ + commandId: reconcileCommandId(`${name}-${step}`), + events: [ + { + id: EventId.make(`event:${name}-${step}:result`), + type: "message.updated", + threadId: child.childThreadId, + runId: run.id, + occurredAt: now, + payload: { + id: MessageId.make(`message:${name}-${step}:result`), + threadId: child.childThreadId, + runId: run.id, + nodeId: null, + role: "assistant", + text, + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:${name}-${step}:finished`), + type: "run.updated", + threadId: child.childThreadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + const removeSelfQueuedRun = orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`command:${name}-remove-self-send`), + threadId: child.childThreadId, + runId: selfQueuedRun.id, + }); + const readFollowup = Effect.gen(function* () { + yield* orchestrator.recoverDelegatedTasks; + return (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === followupTaskId, + )!; + }); + return { + parentThreadId, + parentRunId, + childThreadId: child.childThreadId, + firstWake, + followupRun, + selfQueuedRun, + finishRun, + removeSelfQueuedRun, + readFollowup, + }; + }); + + it.effect("waits for a run the follow-up's child queued on its own thread", () => + Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const followup = yield* startSelfQueuedFollowup("self-queued-followup"); + yield* followup.finishRun("handoff", followup.followupRun, "HANDOFF-C"); + const waiting = yield* followup.readFollowup; + assert.isNull( + waiting.result, + "the follow-up task settled on its handoff turn while the run its child queued was pending", + ); + assert.notEqual(waiting.status, "completed"); + + yield* followup.finishRun("final", followup.selfQueuedRun, "FINAL-D"); + const finished = yield* followup.readFollowup; + assert.equal(finished.status, "completed"); + assert.equal(finished.result, "FINAL-D"); + assert.equal(finished.completionDelivery?.state, "claimed"); + let wake = yield* requests.take; + while ( + wake.threadId !== followup.parentThreadId || + wake.delegatedCompletion?.messageId === followup.firstWake.delegatedCompletion?.messageId + ) { + wake = yield* requests.take; + } + assert.equal(wake.delegatedCompletion?.parentRunId, followup.parentRunId); + }), + ); + + it.effect("keeps a follow-up running when the run its child queued is removed early", () => + Effect.gen(function* () { + const followup = yield* startSelfQueuedFollowup("self-queued-removed-early-followup"); + yield* followup.removeSelfQueuedRun; + const running = yield* followup.readFollowup; + assert.isNull(running.result); + assert.equal(running.status, "running"); + + yield* followup.finishRun("handoff", followup.followupRun, "HANDOFF-C"); + const finished = yield* followup.readFollowup; + assert.equal(finished.status, "completed"); + assert.equal(finished.result, "HANDOFF-C"); + }), + ); + + it.effect.each(["removed", "held"] as const)( + "settles a follow-up on its last turn when the run it queued is %s", + (ending) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const followup = yield* startSelfQueuedFollowup(`self-queued-${ending}-followup`); + if (ending === "held") { + yield* eventSink.write({ + commandId: reconcileCommandId("self-queued-held-followup-hold"), + events: [ + { + id: EventId.make("event:self-queued-held-followup-hold"), + type: "run.updated", + threadId: followup.childThreadId, + runId: followup.selfQueuedRun.id, + occurredAt: now, + payload: { ...followup.selfQueuedRun, queueHeld: true }, + }, + ], + }); + } + yield* followup.finishRun("handoff", followup.followupRun, "HANDOFF-C"); + if (ending === "removed") yield* followup.removeSelfQueuedRun; + const finished = yield* followup.readFollowup; + assert.equal(finished.status, "completed"); + assert.equal(finished.result, "HANDOFF-C"); + }), + ); + + it.effect("waits for every run its child queued when a later one is moved ahead", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const followup = yield* startSelfQueuedFollowup("self-queued-reordered-followup"); + const laterMessageId = MessageId.make("message:self-queued-reordered-followup-later"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:self-queued-reordered-followup-later"), + threadId: followup.childThreadId, + senderThreadId: followup.childThreadId, + messageId: laterMessageId, + text: "Do this part first.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const later = (yield* orchestrator.getThreadProjection(followup.childThreadId)).runs.find( + (run) => run.userMessageId === laterMessageId, + )!; + yield* orchestrator.dispatch({ + type: "queued-run.reorder", + commandId: CommandId.make("command:self-queued-reordered-followup-reorder"), + threadId: followup.childThreadId, + runId: later.id, + beforeRunId: followup.selfQueuedRun.id, + }); + assert.equal((yield* followup.readFollowup).status, "running"); + yield* followup.finishRun("handoff", followup.followupRun, "HANDOFF-C"); + yield* followup.finishRun("later", later, "PART-ONE"); + const waiting = yield* followup.readFollowup; + assert.isNull(waiting.result, "the follow-up settled while an earlier queued run waited"); + + // Promotion moves a queued run after the runs that started ahead of it. + const promoted = { ...followup.selfQueuedRun, ordinal: later.ordinal + 1 }; + yield* followup.finishRun("final", promoted, "FINAL-D"); + const finished = yield* followup.readFollowup; + assert.equal(finished.status, "completed"); + assert.equal(finished.result, "FINAL-D"); + }), + ); + const outcomes = [ "completed", "failed", @@ -2171,6 +2789,8 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( "restarted", "stopped", "overlapping", + "after-cancel", + "cancelled", ] as const; it.effect.each(outcomes)( "delivers a %s follow-up independently of the original task", @@ -2215,6 +2835,26 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( taskId: child.taskId, observedByRunId: parentRunId, }); + if (outcome === "after-cancel") { + // A follow-up is its own task, even after task_cancel reached the original. + const settled = (yield* orchestrator.getThreadProjection( + parentThreadId, + )).subagents.find((task) => task.id === child.taskId)!; + // The follow-up starts in the same millisecond the cancel was recorded. + yield* eventSink.write({ + commandId: reconcileCommandId("followup-original-cancelled"), + events: [ + { + id: EventId.make("event:followup-original-cancelled"), + type: "subagent.updated", + threadId: parentThreadId, + nodeId: settled.id, + occurredAt: now, + payload: { ...settled, cancelledRunId: child.childRunId }, + }, + ], + }); + } const original = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( (task) => task.id === child.taskId, )!; @@ -2241,7 +2881,8 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( creationSource: outcome === "failed" ? "web" : "mcp", commandId: CommandId.make(`command:followup-send-${outcome}`), threadId: child.childThreadId, - senderThreadId: parentThreadId, + // `t3 thread send` and unscoped MCP sessions carry no sender thread. + ...(outcome === "completed" ? {} : { senderThreadId: parentThreadId }), messageId, text: "Check the same work again.", attachments: [], @@ -2324,9 +2965,54 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( commandId: CommandId.make(`command:followup-stop-${outcome}`), }); } + if (outcome === "cancelled") { + // task_cancel reaches the follow-up after its turn ended, while background work runs. + yield* eventSink.write({ + commandId: reconcileCommandId("followup-waiting"), + events: [ + { + id: EventId.make("event:followup-waiting"), + type: "run.updated", + threadId: child.childThreadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "waiting", startedAt: now }, + }, + ], + }); + yield* orchestrator.cancelDelegatedTask({ + parentThreadId, + taskId: run.delegatedTaskId!, + command: { + type: "thread.stop", + commandId: CommandId.make(`cancel-followup-${outcome}`), + threadId: child.childThreadId, + stopDelegatedTasks: false, + }, + stopDescendants: () => Effect.void, + }); + } yield* eventSink.write({ commandId: reconcileCommandId(`followup-finish-${outcome}`), events: [ + { + id: EventId.make(`event:followup-question-${outcome}`), + type: "runtime-request.updated", + threadId: child.childThreadId, + runId: run.id, + occurredAt: now, + payload: { + id: RuntimeRequestId.make(`request:followup-${outcome}`), + nodeId: run.rootNodeId!, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + }, { id: EventId.make(`event:followup-finish-${outcome}`), type: "run.updated", @@ -2338,7 +3024,10 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( status: outcome === "stopped" ? "interrupted" - : outcome === "restarted" || outcome === "overlapping" + : outcome === "restarted" || + outcome === "overlapping" || + outcome === "after-cancel" || + outcome === "cancelled" ? "completed" : outcome, startedAt: now, @@ -2348,6 +3037,12 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( ], }); yield* orchestrator.recoverDelegatedTasks; + // Only an interrupted or cancelled follow-up ends its question, even after the original + // was cancelled. + const ended = + outcome === "stopped" || outcome === "interrupted" || outcome === "cancelled"; + const { runtimeRequests } = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.equal(runtimeRequests[0]?.status, ended ? "resolved" : "pending"); const finished = yield* orchestrator.getThreadProjection(parentThreadId); assert.deepEqual( finished.subagents.find((task) => task.id === child.taskId), @@ -2366,17 +3061,18 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( } assert.equal( followup.status, - outcome === "stopped" + outcome === "stopped" || outcome === "cancelled" ? "interrupted" - : outcome === "restarted" || outcome === "overlapping" + : outcome === "restarted" || outcome === "overlapping" || outcome === "after-cancel" ? "completed" : outcome, ); assert.equal( followup.completionDelivery?.state, - outcome === "stopped" ? "disposed" : "claimed", + outcome === "stopped" || outcome === "cancelled" ? "disposed" : "claimed", ); - if (outcome !== "stopped") { + assert.equal(followup.cancelledRunId, outcome === "cancelled" ? run.id : undefined); + if (outcome !== "stopped" && outcome !== "cancelled") { let wake = yield* requests.take; while ( wake.threadId !== parentThreadId || @@ -3145,6 +3841,54 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); + it.effect("cancelling a task whose child already finished keeps its completed result", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:cancel-finished-parent"); + const projectId = ProjectId.make("project:cancel-finished-parent"); + const runId = RunId.make("run:cancel-finished-parent"); + const rootNodeId = NodeId.make("node:cancel-finished-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:cancel-finished-parent-settled"), + deliveryState: "delivered", + now, + }); + // The child run finished, but its parent row has not been finalized yet. + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "cancel-finished-child", + completionWake: "always", + continuationPending: false, + runStatus: "completed", + now, + }); + yield* orchestrator.cancelDelegatedTask({ + parentThreadId: threadId, + taskId: child.taskId, + command: { + type: "thread.stop", + commandId: CommandId.make("cancel-finished-child"), + threadId: child.childThreadId, + stopDelegatedTasks: false, + }, + stopDescendants: () => Effect.void, + }); + const task = (yield* orchestrator.getThreadProjection(threadId)).subagents.find( + (row) => row.id === child.taskId, + ); + assert.equal(task?.status, "completed"); + assert.isUndefined(task?.cancelledRunId); + }), + ); + // Running does not prove prompt consumption in Claude; re-offer cut deliveries even when resuming the turn. it.effect("re-offers a cut delivery even when a continuation resumes its turn", () => Effect.gen(function* () { 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 39770ba9163c0..2eea27874b8bf 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -64,6 +64,194 @@ const layerTest = Layer.mergeAll( ), ); +it.effect.each( + (["provider.switch", "thread.model-selection.set"] as const).flatMap((type) => + [ + { active: true, selectionChange: "instance" }, + { active: false, selectionChange: "instance" }, + { active: true, selectionChange: "next-turn" }, + { active: true, selectionChange: "restart" }, + ].map((scenario) => ({ type, ...scenario })), + ), +)("$type $selectionChange ($active)", ({ type, active, selectionChange }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const threadId = ThreadId.make("thread:instance-switch"); + const now = yield* DateTime.now; + const sessionId = ProviderSessionId.make("session:instance-switch"); + const providerThreadId = ProviderThreadId.make("provider-thread:instance-switch"); + const runId = RunId.make("run:instance-switch"); + const nodeId = NodeId.make("node:instance-switch"); + const attemptId = RunAttemptId.make("attempt:instance-switch"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-instance-switch"), + threadId, + projectId: ProjectId.make("project:instance-switch"), + title: "Instance switch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* projections.apply({ + id: EventId.make("instance-switch:session"), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: sessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* projections.apply({ + id: EventId.make("instance-switch:provider-thread"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver: adapter.driver, + providerInstanceId: instanceId, + providerSessionId: sessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver: adapter.driver, + nativeId: "native:instance-switch", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }); + if (active) { + yield* projections.apply({ + id: EventId.make("instance-switch:run"), + type: "run.created", + threadId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make("message:instance-switch"), + rootNodeId: nodeId, + activeAttemptId: attemptId, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }); + yield* projections.apply({ + id: EventId.make("instance-switch:turn"), + type: "provider-turn.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:instance-switch"), + providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: { driver: adapter.driver, nativeId: "native:turn", strength: "strong" }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + } + const before = yield* projections.getThreadProjection(threadId); + const sequence = yield* orchestrator.getThreadEventSequence(threadId); + const commandId = CommandId.make("switch-instance"); + const target = { + instanceId: + selectionChange === "instance" ? ProviderInstanceId.make("codex-other") : instanceId, + model: selectionChange === "instance" ? modelSelection.model : "gpt-6", + }; + const outcome = yield* orchestrator + .dispatch({ + type, + commandId, + threadId, + modelSelection: target, + }) + .pipe(Effect.result); + const after = yield* projections.getThreadProjection(threadId); + if (active && selectionChange !== "next-turn") { + assert.deepEqual( + after, + before, + "a refused switch must preserve the turn, session and selection", + ); + assert.deepEqual( + yield* outbox.listByCommandId(commandId), + [], + "a refused switch must not release the provider session", + ); + assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence); + assert.ok(Result.isFailure(outcome)); + assert.instanceOf(outcome.failure, Orchestrator.OrchestratorProviderSelectionBusyError); + } else { + assert.ok(Result.isSuccess(outcome)); + assert.deepEqual(after.thread.modelSelection, target); + assert.equal(after.thread.providerInstanceId, target.instanceId); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.providerTurns, before.providerTurns); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "instance-switch" }, + ProviderAdapterRegistry.layerFromAdapters([ + { + ...adapter, + planSelectionTransition: () => + Effect.succeed( + selectionChange === "restart" + ? { type: "restart_session" as const } + : { type: "apply_on_next_turn" as const }, + ), + }, + { ...adapter, instanceId: ProviderInstanceId.make("codex-other") }, + ]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), + ), + ), + ), +); + it.effect( "records approval feedback and targets only its runtime request without dispatching a parent turn", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts b/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts index 911c1b8de7df5..7f2a3cb3a753a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts @@ -10,6 +10,14 @@ it("reissues imported context until a V2 run completes", () => { legacyImportItemCount: 2, }), ); + assert.isFalse( + shouldPrepareLegacyImportHandoff({ + historyOrigin: "v1_import", + hasCompletedRun: false, + legacyImportItemCount: 2, + hasNativeHistory: true, + }), + ); assert.isFalse( shouldPrepareLegacyImportHandoff({ historyOrigin: "v1_import", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index db079bbe51d2b..9a9e61f4e57c5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -129,6 +129,7 @@ import { cancelledBeforeStart, continuationFailedBeforePrompt, continuationStartRetryDelayMs, + delegatedTaskResultRun, isRestartContinuationSource, runOutcome, } from "./RestartBackgroundNote.ts"; @@ -297,6 +298,15 @@ export class OrchestratorThreadAboveModeLimitError extends Schema.TaggedError()( + "OrchestratorProviderSelectionBusyError", + { commandId: CommandId, threadId: ThreadId }, +) { + override get message(): string { + return `Thread ${this.threadId} has an active turn. Wait for it to finish, then retry the provider selection change.`; + } +} + const isOrchestratorDispatchError = Schema.is(OrchestratorDispatchError); /** The ceiling refusal under however many dispatch errors wrapped it on the way out. */ @@ -361,6 +371,7 @@ export const OrchestratorV2Error = Schema.Union([ OrchestratorCommandIdConflictError, OrchestratorSubagentThreadReadOnlyError, OrchestratorThreadAboveModeLimitError, + OrchestratorProviderSelectionBusyError, OrchestratorRuntimeModeAboveCeilingError, ]); export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; @@ -376,6 +387,15 @@ export interface OrchestratorV2Shape { readonly resumeQueuedRuns: Effect.Effect; readonly recoverDelegatedTasks: Effect.Effect; readonly recoverDelegatedTask: (threadId: ThreadId, sourceRunId: RunId) => Effect.Effect; + /** Commits cancellation with the child's Stop after descendants accept stopping. */ + readonly cancelDelegatedTask: (input: { + readonly parentThreadId: ThreadId; + readonly taskId: NodeId; + readonly command: Extract; + readonly stopDescendants: ( + dispatch: OrchestratorV2Shape["dispatch"], + ) => Effect.Effect; + }) => Effect.Effect; readonly delegatedTaskResultPending: ( childThreadId: ThreadId, taskId?: NodeId, @@ -514,23 +534,41 @@ function nextRunOrdinal(projection: Pick Math.max(max, run.ordinal), 0) + 1; } -/** Stamp at wake start; queued prompts lack start times and delegated results jump the queue. */ +/** + * Stamp at wake start; queued prompts lack start times and delegated results jump the queue. + * Auto-drained agent messages continue active work; explicit queue starts reset it. + */ function wakeWorkStartedAt( - runs: ReadonlyArray, - trigger: { + projection: Pick, + trigger: Pick< + OrchestrationV2ConversationMessage, + "createdBy" | "creationSource" | "senderThreadId" + > & { readonly notification?: unknown; readonly delegatedCompletion?: unknown; readonly restartContinuationOfRunId?: RunId | undefined; }, + delivery: "immediate" | "automatic-queue" | "explicit-queue" = "immediate", ): Pick { if ( trigger.notification === undefined && trigger.delegatedCompletion === undefined && - trigger.restartContinuationOfRunId === undefined + trigger.restartContinuationOfRunId === undefined && + !( + trigger.createdBy === "agent" && + trigger.creationSource === "mcp" && + trigger.senderThreadId !== undefined && + (delivery === "automatic-queue" || + (delivery === "immediate" && + projection.subagents.some( + (subagent) => + subagent.origin === "app_owned" && subagent.childThreadId === trigger.senderThreadId, + ))) + ) ) { return {}; } - const previous = runs + const previous = projection.runs .flatMap((run) => (run.startedAt === null ? [] : [{ run, startedAt: run.startedAt }])) .toSorted( (left, right) => @@ -902,9 +940,15 @@ function queueTailModelSelection( ); } +/** + * The provider's latest run stopped on its usage limit and nothing ran after it. + * With `now` (automatic starts), a run on another provider since does not lift + * a reported reset still in the future; an unknown reset is lifted, as transient 429s are. + */ function usageLimitHoldsQueue( projection: Pick, providerInstanceId: ModelSelection["instanceId"], + now?: DateTime.Utc, ): boolean { let latestSession: OrchestrationV2ThreadProjection["providerSessions"][number] | undefined; for (const session of projection.providerSessions) { @@ -916,10 +960,16 @@ function usageLimitHoldsQueue( latestSession = session; } } - return ( - usageLimitBlockedRun(projection.runs, projection.turnItems, latestSession?.lastError ?? null) - ?.providerInstanceId === providerInstanceId + const limited = usageLimitBlockedRun( + projection.runs.filter((run) => run.providerInstanceId === providerInstanceId), + projection.turnItems, + latestSession?.lastError ?? null, ); + if (limited === null) return false; + if (latestExecutedRun(projection.runs)?.id === limited.id) return true; + if (now === undefined) return false; + const resetAt = latestRootProviderFailure(limited, projection.turnItems)?.resetAt; + return resetAt != null && Date.parse(resetAt) > DateTime.toEpochMillis(now); } function latestStableRun( @@ -1039,12 +1089,16 @@ function latestContextTransfer( } export function shouldPrepareLegacyImportHandoff(input: { + readonly hasNativeHistory?: boolean; readonly hasCompletedRun: boolean; readonly historyOrigin: OrchestrationV2AppThread["historyOrigin"]; readonly legacyImportItemCount: number; }): boolean { return ( - input.historyOrigin === "v1_import" && !input.hasCompletedRun && input.legacyImportItemCount > 0 + input.historyOrigin === "v1_import" && + !input.hasNativeHistory && + !input.hasCompletedRun && + input.legacyImportItemCount > 0 ); } @@ -1133,7 +1187,13 @@ function rootProviderThreadsForProvider( } function isHandoffSourceRun(run: OrchestrationV2Run): boolean { - return run.status === "completed" || run.status === "failed" || run.status === "interrupted"; + return ( + run.status === "completed" || + run.status === "failed" || + run.status === "interrupted" || + // A queued request cancelled before it started never reached any provider. + (run.status === "cancelled" && run.startedAt !== null) + ); } function lastDeliveredRunForProviderThread( @@ -1200,9 +1260,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: OrchestrationV2ServerCommand, providerInstanceId: ProviderInstanceId, ) { - const { driver } = yield* providerAdapters - .get(providerInstanceId) - .pipe(mapDispatchError(command)); + const adapter = yield* providerAdapters.get(providerInstanceId).pipe(mapDispatchError(command)); + const { driver } = adapter; + if (driver === "opencode") { + const capabilities = yield* adapter.getCapabilities().pipe(mapDispatchError(command)); + return capabilities.turns.supportsNativeGoals === true ? driver : undefined; + } return driver === "codex" || driver === "claudeAgent" ? driver : undefined; }); @@ -1914,6 +1977,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly failedRunId?: RunId; readonly endedRunId?: RunId; readonly queuedRunId?: RunId; + readonly userStarted?: boolean; }, ) { if (!(yield* projectionStore.canStartQueuedRun(threadId))) return; @@ -1945,7 +2009,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerInstanceId: modelSelection.instanceId, }; // A limited provider would fail and consume every waiting message. - if (usageLimitHoldsQueue(projection, queuedRun.providerInstanceId)) { + if ( + usageLimitHoldsQueue( + projection, + queuedRun.providerInstanceId, + options?.userStarted === true ? undefined : yield* DateTime.now, + ) + ) { return; } // Waits without a hold: lowering the thread's mode starts the queue again. @@ -2395,6 +2465,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), updatedAt: now, }; + // Resume can clear the hold before the stopped run ends; its Stop marker still makes the drain explicit. + const endedRunId = options?.endedRunId; + const queueDelivery = + endedRunId === undefined || + queuedRun.queueHeld === true || + (yield* projectionStore + .getThreadRecords(threadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [endedRunId], + }) + .pipe( + Effect.map(({ turnItems }) => turnItems.length > 0), + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + )) + ? "explicit-queue" + : "automatic-queue"; const startingRun: OrchestrationV2Run = { ...queuedRun, providerThreadId: queuedProviderThread.id, @@ -2403,11 +2489,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio queuePosition: null, startedAt: null, contextHandoffId: activeHandoff?.id ?? null, - ...wakeWorkStartedAt(projection.runs, { - notification: queuedMessage.notification, - delegatedCompletion: queuedMessage.delegatedCompletion, - restartContinuationOfRunId: queuedRun.restartContinuationOfRunId, - }), + ...wakeWorkStartedAt( + projection, + { + createdBy: queuedMessage.createdBy, + creationSource: queuedMessage.creationSource, + senderThreadId: queuedMessage.senderThreadId, + notification: queuedMessage.notification, + delegatedCompletion: queuedMessage.delegatedCompletion, + restartContinuationOfRunId: queuedRun.restartContinuationOfRunId, + }, + queueDelivery, + ), }; const userTurnItem: OrchestrationV2TurnItem = { ...(legacyQueuedTurnItem ?? { @@ -2845,21 +2938,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio >, events: Ref.Ref>, ) { - const projection = yield* projectionStore - .getThreadRecords( - command.parentThreadId, - ["subagents", "runs", "nodes", "attempts", "turnItems"], - { turnItemTypes: [] }, - ) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorProjectionError({ - threadId: command.parentThreadId, - cause, - }), - ), - ); + const projection = yield* loadProjectionForCommand(command, ["subagents"], { + subagentIds: [command.taskId], + }); const task = projection.subagents.find( (candidate) => candidate.id === command.taskId && candidate.origin === "app_owned", ); @@ -2876,6 +2957,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : "disposed"; const now = yield* DateTime.now; const emitEvent = emit(events, command); + const { + runs: [parentRun], + } = yield* loadProjectionForCommand(command, ["runs"], { + runIds: task.runId === null ? [] : [task.runId], + }); // A late wait can arrive after finalization held the result for its still-live spawning run. if (isUnobservedAcknowledgement(command)) { if ( @@ -2883,23 +2969,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio task.completionDelivery?.state !== "pending" ) return; - const parentRun = projection.runs.find((run) => run.id === task.runId); if (parentRun !== undefined && isLiveRun(parentRun)) return; - const records = yield* projectionStore - .getThreadRecords(command.parentThreadId, ["messages", "providerTurns"], { - messageRoles: ["user"], - }) - .pipe( - Effect.mapError( - (cause) => - new OrchestratorProjectionError({ - threadId: command.parentThreadId, - cause, - }), - ), - ); + const records = yield* loadProjectionForCommand( + command, + ["runs", "subagents", "messages", "providerTurns"], + { messageRoles: ["user"] }, + ); const plan = yield* planDelegatedCompletionDelivery({ - parentProjection: { ...projection, ...records }, + parentProjection: records, parentRun, task, updatedTask: task, @@ -2969,10 +3046,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: updatedTask, }); - const parentRun = - task.runId === null - ? undefined - : projection.runs.find((candidate) => candidate.id === task.runId); const cohort = parentRun?.delegatedCompletion; const delivery = cohort?.delivery; if ( @@ -2987,17 +3060,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } - const { messages } = yield* projectionStore - .getThreadRecords(command.parentThreadId, ["messages"], { - messageRoles: ["user"], - messageIds: [delivery.messageId], - }) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.parentThreadId, cause }), - ), - ); - const deliveryProjection = { ...projection, messages }; + const { messages } = yield* loadProjectionForCommand(command, ["messages"], { + messageRoles: ["user"], + messageIds: [delivery.messageId], + }); + const deliveryRunId = completionDeliveryMessage({ messages }, delivery)?.runId ?? null; + const { runs } = yield* loadProjectionForCommand(command, ["runs"], { + runIds: deliveryRunId === null ? [] : [deliveryRunId], + }); + const deliveryProjection = { messages, runs }; const remainingTaskIds = delivery.taskIds.filter((taskId) => taskId !== task.id); const deliveryRun = completionDeliveryRun(deliveryProjection, delivery); const clearDelivery = @@ -3027,10 +3098,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (deliveryRun?.status === "queued") { if (remainingTaskIds.length === 0) { + const rootNodeFilter = { + nodeIds: deliveryRun.rootNodeId === null ? [] : [deliveryRun.rootNodeId], + }; yield* emitQueuedRunCancellation({ command, events, - projection, + // Attempts have no id filter; a wake that never started has no attempt to cancel. + projection: + deliveryRun.activeAttemptId === null + ? { + attempts: [], + ...(yield* loadProjectionForCommand(command, ["nodes"], rootNodeFilter)), + } + : yield* loadProjectionForCommand(command, ["nodes", "attempts"], rootNodeFilter), run: deliveryRun, now, }); @@ -3038,6 +3119,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const message = completionDeliveryMessage(deliveryProjection, delivery); if (message !== undefined) { + const { subagents: remainingTasks } = yield* loadProjectionForCommand( + command, + ["subagents"], + { subagentIds: remainingTaskIds }, + ); yield* emitEvent({ type: "message.updated", threadId: command.parentThreadId, @@ -3047,7 +3133,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: { ...message, - text: delegatedCompletionWakeDetail(remainingTaskIds, projection.subagents), + text: delegatedCompletionWakeDetail(remainingTaskIds, remainingTasks), delegatedCompletion: { parentRunId: parentRun.id, generation: delivery.generation, @@ -3074,8 +3160,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly disposition: "stopped" | "disposed"; readonly now: DateTime.Utc; readonly cancelQueuedDelivery?: boolean; - // An interrupted wake disposes only the results it carried. The cohort stays open, and its - // delivery stays the wake's, so the wake's terminal reserves any sibling's result. + // An interrupted or cancelled wake disposes only the results it carried. The cohort stays + // open, and its delivery stays the wake's, so the wake's terminal reserves any sibling's result. readonly interruptedWakeTaskIds?: ReadonlyArray; }) { const parentRun = input.projection.runs.find((candidate) => candidate.id === input.parentRunId); @@ -3868,6 +3954,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio runId: run.id, }, events, + "cohort", ); } } @@ -3981,12 +4068,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - return yield* providerSwitchService + const plan = yield* providerSwitchService .plan({ projection: providerContext!, targetModelSelection: command.modelSelection, }) .pipe(mapDispatchError(command)); + if (plan.instanceChanged || plan.releaseProviderSessionIds.length > 0) { + const projection = yield* loadProjectionForCommand(command, ["runs"], { + turnItemTypes: [], + }); + if (projection.runs.some(isActiveRun)) { + return yield* new OrchestratorProviderSelectionBusyError({ + commandId: command.commandId, + threadId: command.threadId, + }); + } + } + return plan; }) : null; @@ -6417,8 +6516,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection = applyToProjection(projection, event); } - const continuedTaskId = projection.runs.find( - (run) => run.id === command.restartContinuationOfRunId, + // A run the thread queues for itself continues the task of the run that sent it. + const continuedTaskId = projection.runs.find((run) => + command.restartContinuationOfRunId === undefined + ? command.senderThreadId === command.threadId && isActiveRun(run) + : run.id === command.restartContinuationOfRunId, )?.delegatedTaskId; if (projection.thread.settledOverride !== null) { @@ -6512,7 +6614,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.dispatchMode, command.deliveryIntent, ); - if (dispatchMode.type === "steer_active") { + if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { const targetRunId = dispatchMode.targetRunId; const target = projection.runs.find((run) => run.id === targetRunId); const turn = latestProviderTurnForAttempt( @@ -6524,9 +6626,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( target !== undefined && (target.status === "completed" || + (dispatchMode.type === "restart_active" && !isActiveRun(target)) || ((target.status === "running" || target.status === "waiting") && turn !== undefined && - turn.status === "completed")) + (turn.status === "completed" || + (dispatchMode.type === "restart_active" && + turn.status !== "pending" && + turn.status !== "running")))) ) { dispatchMode = { type: "start_immediately" }; } @@ -6534,7 +6640,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // Stop leaves the run reading as running until its provider stops. A plain // send in that window queues behind the stopped queue instead of steering // text the agent never receives, and like any user send it releases the - // hold; an explicit steer or restart still fails below. + // hold; an explicit steer still fails below. if ( command.deliveryIntent === "auto" && (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") && @@ -6542,6 +6648,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { dispatchMode = { type: "queue_after_active" }; } + // An explicit restart that Stop reached has only its start left, so it + // queues behind the stopping run like any other start. + if ( + dispatchMode.type === "restart_active" && + (yield* stopReachedRun(command, command.threadId, dispatchMode.targetRunId)) + ) { + dispatchMode = { type: "start_immediately" }; + } const freshContext = command.freshContext === true; if ( freshContext && @@ -6549,14 +6663,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { dispatchMode = { type: "queue_after_active" }; } - // Codex and Claude own /goal; elsewhere it is ordinary text that cannot steer. + // Native goal commands must not steer an existing turn. const parsedGoalCommand = parseGoalCommand(command); const goalCommand = parsedGoalCommand === null || (yield* goalDriverFor(command, modelSelection.instanceId)) === undefined ? null : parsedGoalCommand; - let goalStopRunId: RunId | undefined; + let queuedRunStop: + | { readonly runId: RunId; readonly reason: string; readonly skipsHeldQueue?: true } + | undefined; if (goalCommand !== null) { // A goal command never joins a running turn. One that starts or ends goal // work stops the run and goes first in the queue; the rest wait. A send @@ -6582,7 +6698,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (targetMessage === undefined || !isNativeMaintenanceCommand(targetMessage)) && !(yield* stopReachedRun(command, command.threadId, target.id)) ) { - goalStopRunId = target.id; + queuedRunStop = { runId: target.id, reason: "Stopped to apply /goal" }; } dispatchMode = { type: "queue_after_active" }; } @@ -6642,6 +6758,37 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio taskIds: delivery.taskIds, }; } + // A provider that can stop a turn but not restart it with new input runs a + // restart as the next turn: first in the queue, with the running turn stopped. + if (dispatchMode.type === "restart_active" && !isNativeMaintenanceCommand(command)) { + const targetRunId = dispatchMode.targetRunId; + const target = projection.runs.find((run) => run.id === targetRunId); + const providerSessionId = projection.providerThreads.find( + (candidate) => candidate.id === target?.providerThreadId, + )?.providerSessionId; + const turns = projection.providerSessions.find((session) => session.id === providerSessionId) + ?.capabilities.turns; + const targetMessage = projection.messages.find( + (message) => message.id === target?.userMessageId, + ); + if ( + target?.status === "running" && + turns !== undefined && + turns.supportsInterrupt && + turns.supportsQueuedMessages && + !turns.supportsSteeringByInterruptRestart && + (targetMessage === undefined || !isNativeMaintenanceCommand(targetMessage)) && + !(yield* stopReachedRun(command, command.threadId, target.id)) + ) { + // The restart replaces the stopped turn, so an older held queue does not hold it. + queuedRunStop = { + runId: target.id, + reason: "Stopped to send a new message", + skipsHeldQueue: true, + }; + dispatchMode = { type: "queue_after_active" }; + } + } // Steer mailbox deliveries, delegated task questions, and command notices // into an active goal under the thread lock: queued behind a long turn or a // goal run, they are never seen. Never interrupt/restart a turn for one. @@ -6794,14 +6941,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "start_immediately" || dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; - // An explicit pick queued ahead reaches the thread only when its run starts. A goal - // command that stops the active run goes first, so nothing queued applies before it. + // An explicit pick queued ahead reaches the thread only when its run starts. A goal or + // restart command that stops the active run goes first, so nothing queued applies before it. // Buffered provider wakes drain output their own provider already produced. const incomingMessage = { ...command, delegatedCompletion }; if ( shouldQueue && command.modelSelection === undefined && - goalStopRunId === undefined && + queuedRunStop === undefined && !followsCurrentConversation(incomingMessage) && !isBufferedProviderNotification(incomingMessage) ) { @@ -6955,11 +7102,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeAttemptId: attemptId, status: "queued", ...(worktreeMissingRun !== undefined || - (heldRunsToRelease.length === 0 && queueIsHeld && !wakeSkipsHold) + (heldRunsToRelease.length === 0 && + queueIsHeld && + !wakeSkipsHold && + queuedRunStop?.skipsHeldQueue !== true) ? { queueHeld: true } : {}), queuePosition: - goalStopRunId !== undefined + queuedRunStop !== undefined ? 1 : Math.max( 0, @@ -7117,15 +7267,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } - if (goalStopRunId !== undefined) { + if (queuedRunStop !== undefined) { return yield* stopRunForQueuedCommand({ command, events, effects, projection: yield* getProjectionWithPendingEvents(command.threadId, events), queuedRunId: runId, - targetRunId: goalStopRunId, - reason: "Stopped to apply /goal", + targetRunId: queuedRunStop.runId, + reason: queuedRunStop.reason, now, }); } @@ -7269,6 +7419,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio nativeThreadId: `pending:${runId}`, }); const legacyImportHandoff = shouldPrepareLegacyImportHandoff({ + hasNativeHistory: + activeProviderThread?.driver === "acpRegistry" && + activeProviderThread.nativeThreadRef !== null, historyOrigin: projection.thread.historyOrigin, hasCompletedRun: latestCompletedRun !== undefined, legacyImportItemCount: legacyImportItems.length, @@ -7366,7 +7519,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? { workspacePreparation: dispatchMode.workspaceStrategy } : {}), ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), - ...wakeWorkStartedAt(projection.runs, command), + ...wakeWorkStartedAt(projection, command), ...(freshContext ? { freshContext: true } : {}), }; const attempt: OrchestrationV2RunAttempt = { @@ -8175,7 +8328,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { usageLimitContinuationOfRunId: command.usageLimitContinuationOfRunId }), ...(continuedTaskId === undefined ? {} : { delegatedTaskId: continuedTaskId }), - ...wakeWorkStartedAt(projection.runs, command), + ...wakeWorkStartedAt(projection, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -8978,12 +9131,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (!startsAttempt) return; const child = yield* projectionStore.getThread(command.threadId); const parentThreadId = child.lineage.parentThreadId; + // A send with no sender thread comes from a client acting for the owner, like a user send. if ( child.lineage.relationshipToParent !== "subagent" || parentThreadId === null || (command.notification === undefined && command.createdBy !== "user" && command.usageLimitContinuationOfRunId === undefined && + command.senderThreadId !== undefined && command.senderThreadId !== parentThreadId) ) return; @@ -9036,6 +9191,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completionDelivery: { state: "pending", observedByRunId: null }, status: run.status === "queued" ? (run.queueHeld === true ? "idle" : "pending") : "running", progress: undefined, + cancelledRunId: undefined, result: null, startedAt: now, completedAt: null, @@ -10796,6 +10952,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchQueuedRunCancel = Effect.fn("Orchestrator.dispatchQueuedRunCancel")(function* ( command: Extract, events: Ref.Ref>, + // Settling disposes a cancelled wake's whole cohort, so a sibling's late result cannot unsettle it. + disposes: "wake" | "cohort" = "wake", ) { const projection = yield* loadProjectionForCommand( command, @@ -10813,8 +10971,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const queuedMessage = projection.messages.find( (candidate) => candidate.id === queuedRun.userMessageId, ); + const now = yield* DateTime.now; if (queuedMessage?.delegatedCompletion !== undefined) { - const now = yield* DateTime.now; yield* disposeDelegatedCompletionCohort({ command, events, @@ -10822,64 +10980,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio parentRunId: queuedMessage.delegatedCompletion.parentRunId, disposition: "disposed", now, + cancelQueuedDelivery: false, + ...(disposes === "wake" + ? { interruptedWakeTaskIds: queuedMessage.delegatedCompletion.taskIds } + : {}), }); - return; } - const queuedRootNode = - queuedRun.rootNodeId === null - ? undefined - : projection.nodes.find((candidate) => candidate.id === queuedRun.rootNodeId); - const queuedAttempt = - queuedRun.activeAttemptId === null - ? undefined - : projection.attempts.find((candidate) => candidate.id === queuedRun.activeAttemptId); - - const now = yield* DateTime.now; + yield* emitQueuedRunCancellation({ command, events, projection, run: queuedRun, now }); const emitEvent = emit(events, command); - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: queuedRun.id, - ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedRun, - status: "cancelled", - queuePosition: null, - completedAt: now, - }, - }); - if (queuedAttempt !== undefined && queuedRootNode !== undefined) { - yield* emitEvent({ - type: "run-attempt.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedAttempt, - status: "cancelled", - completedAt: now, - }, - }); - } - if (queuedRootNode !== undefined) { - yield* emitEvent({ - type: "node.updated", - threadId: command.threadId, - runId: queuedRun.id, - nodeId: queuedRootNode.id, - providerInstanceId: queuedRun.providerInstanceId, - occurredAt: now, - payload: { - ...queuedRootNode, - status: "cancelled", - completedAt: now, - }, - }); - } const guest = queuedMessage?.context?.guest; if (guest !== undefined) { const thread = guestShareAfterCancelledSend(projection.thread, guest, now); @@ -13595,10 +13703,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? options.task.id === forkedFrom.nodeId ? options.resultRun : undefined - : taskRuns.toSorted((a, b) => b.ordinal - a.ordinal)[0]; + : delegatedTaskResultRun(taskRuns, options.task.id); if (childRun === undefined) return; - const outcome = runOutcome(childRun); - const terminalStatus = delegatedTaskTerminalStatus(outcome); + let outcome = runOutcome(childRun); + let terminalStatus = delegatedTaskTerminalStatus(outcome); if (terminalStatus === null) { if (childRun.delegatedTaskId === undefined || childRun.id !== options.syncRunId) return; const task = options.task; @@ -13704,6 +13812,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (task === undefined) { return; } + // A cancelled task's completed run ends it interrupted. + const cancelled = task.cancelledRunId === childRun.id; + if (cancelled && terminalStatus === "completed") outcome = terminalStatus = "interrupted"; const existingResultTransfer = parentProjection.contextTransfers.find( (transfer) => transfer.type === "subagent_result" && @@ -13741,6 +13852,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(usage === undefined ? {} : { usage }), completedAt: now, updatedAt: now, + // The cancelling parent gets no wake, and the task records the run it settled from. + ...(cancelled + ? { + cancelledRunId: childRun.id, + completionDelivery: { state: "disposed" as const, observedByRunId: null }, + } + : {}), }; const completionPlan = yield* planDelegatedCompletionDelivery({ parentProjection, @@ -13847,6 +13965,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + // A waiting run has ended its turn, so its task waits only on the work it left. + const progressAfterTurn = (child: Parameters[0]) => + delegatedTaskProgress({ + ...child, + runs: child.runs.map((run) => + run.status === "waiting" ? { ...run, status: "completed" as const } : run, + ), + }); + const finalizeAppOwnedSubagent = Effect.fn("Orchestrator.finalizeAppOwnedSubagent")(function* ( childThreadId: ThreadId, options?: { readonly settledContinuationOf?: RunId; readonly syncRunId?: RunId }, @@ -13869,11 +13996,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "contextTransfers", ]); for (const task of parent.subagents) { + // A cancelled task does not wait for the work its cancel is stopping. + const cancelled = task.cancelledRunId !== undefined; if ( task.origin !== "app_owned" || task.childThreadId !== childThreadId || (task.id === childControls.thread.forkedFrom.nodeId && - progress.state !== "result_available") || + (cancelled ? progress.state === "working" : progress.state !== "result_available")) || (task.result !== null && parent.contextTransfers.some( (transfer) => @@ -13888,10 +14017,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } + // Only the run a cancelled task settled from ends with it. The row read above has no + // such run yet when this call settled the task, from the same result run. const { resultRun } = progress; - if (progress.state !== "result_available" || resultRun === undefined) return; + const resultTaskId = resultRun?.delegatedTaskId ?? childControls.thread.forkedFrom.nodeId; + const resultTask = parent.subagents.find((task) => task.id === resultTaskId); + const taskCancelled = + resultTask?.cancelledRunId !== undefined && resultTask.cancelledRunId === resultRun?.id; + if ( + resultRun === undefined || + (taskCancelled ? progress.state === "working" : progress.state !== "result_available") + ) + return; const status = delegatedTaskTerminalStatus(runOutcome(resultRun)); - if (status !== "cancelled" && status !== "interrupted") return; + if (status !== "interrupted" && status !== "cancelled" && !taskCancelled) return; if ( yield* childAwaitsRestartContinuation( childControls.runs, @@ -14630,10 +14769,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (command.type === "queue.resume" || command.type === "queued-run.start") { yield* mapDispatchError(command)( - startNextQueuedRun( - command.threadId, - command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, - ), + startNextQueuedRun(command.threadId, { + userStarted: true, + ...(command.type === "queued-run.start" ? { queuedRunId: command.runId } : {}), + }), ); } return { @@ -14647,6 +14786,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, sideChatIds: ReadonlyArray = [], + cancelledTask?: OrchestrationV2Subagent, ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -14758,7 +14898,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - if (plan.events.length === 0 && plan.effects.length === 0) { + if (plan.events.length === 0 && plan.effects.length === 0 && cancelledTask === undefined) { // A settle that ended nothing still records its receipt: a replayed Stop // effect then finds it instead of settling work that appeared since. const { resultSequence, inserted } = yield* Effect.gen(function* () { @@ -14808,7 +14948,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: commandThreadId(command), commandType: command.type, acceptedAt, - events: plan.events, + events: + cancelledTask === undefined + ? plan.events + : [ + ...plan.events, + yield* makeSystemEvent({ + type: "subagent.updated", + threadId: cancelledTask.threadId, + nodeId: cancelledTask.id, + occurredAt: cancelledTask.updatedAt, + payload: cancelledTask, + }).pipe(mapDispatchError(command)), + ], effects: plan.effects, ...(plan.cancelUnsettledEffects === undefined ? {} @@ -14851,10 +15003,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (command.type === "thread.runtime-mode.set" && runtimeModeCeiling !== undefined) ) { yield* mapDispatchError(command)( - startNextQueuedRun( - command.threadId, - command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, - ), + startNextQueuedRun(command.threadId, { + userStarted: command.type !== "thread.runtime-mode.set", + ...(command.type === "queued-run.start" ? { queuedRunId: command.runId } : {}), + }), ); } if ( @@ -15169,7 +15321,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (taskId !== undefined) { const runs = child.runs.filter((run) => run.delegatedTaskId === taskId); - const run = runs.toSorted((a, b) => b.ordinal - a.ordinal)[0]; + const run = delegatedTaskResultRun(runs, taskId); if (run === undefined || delegatedTaskTerminalStatus(run.status) === null) return true; return yield* childAwaitsRestartContinuation(runs, run); } @@ -15206,6 +15358,81 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + const cancelDelegatedTask: OrchestratorV2Shape["cancelDelegatedTask"] = Effect.fn( + "Orchestrator.cancelDelegatedTask", + )(function* (input) { + const { command } = input; + return yield* threadDispatch.withLock( + input.parentThreadId, + Effect.gen(function* () { + const { subagents } = yield* projectionStore + .getThreadRecords(input.parentThreadId, ["subagents"]) + .pipe(mapDispatchError(command)); + const task = subagents.find( + (candidate) => + candidate.id === input.taskId && + candidate.origin === "app_owned" && + candidate.childThreadId === command.threadId && + isOrchestrationV2WorkActive(candidate.status), + ); + const child = yield* projectionStore + .getThreadRecords( + command.threadId, + ["runs", "messages", "subagents", "providerThreads"], + { messageRoles: ["user"] }, + ) + .pipe(mapDispatchError(command)); + const followUpRun = child.runs + .filter((run) => run.delegatedTaskId === input.taskId) + .toSorted((a, b) => b.ordinal - a.ordinal)[0]; + const unsettled = + followUpRun === undefined + ? child.thread.forkedFrom?.type === "node" && + child.thread.forkedFrom.nodeId === input.taskId && + progressAfterTurn(child).state !== "result_available" + : delegatedTaskTerminalStatus(runOutcome(followUpRun)) === null; + yield* threadDispatch.withLock( + command.threadId, + Effect.gen(function* () { + // Hold intake closed until descendants stop and this thread's Stop commits. + yield* input.stopDescendants((command) => + commandThreadId(command) === input.command.threadId + ? dispatchWithReceiptEffect(command) + : dispatchWithReceipt(command), + ); + const child = yield* projectionStore + .getThreadRecords( + command.threadId, + ["runs", "messages", "subagents", "providerThreads"], + { messageRoles: ["user"] }, + ) + .pipe(mapDispatchError(command)); + const followUpRun = child.runs + .filter((run) => run.delegatedTaskId === input.taskId) + .toSorted((a, b) => b.ordinal - a.ordinal)[0]; + const monitorRunIds = new Set( + child.messages + .filter((message) => message.notification?.source.kind === "monitor") + .map((message) => message.runId), + ); + const activeRun = child.runs + .filter((run) => isLiveRun(run) && !monitorRunIds.has(run.id)) + .toSorted((a, b) => b.ordinal - a.ordinal)[0]; + const cancelledRun = followUpRun ?? activeRun ?? progressAfterTurn(child).resultRun; + const now = yield* DateTime.now; + const cancelledTask = + task !== undefined && unsettled && cancelledRun !== undefined + ? { ...task, cancelledRunId: cancelledRun.id, updatedAt: now } + : undefined; + // The marker, Stop receipt and interrupt outbox commit together under both locks. + yield* dispatchWithReceiptEffect(command, [], cancelledTask); + }), + ); + yield* finalizeAppOwnedSubagent(command.threadId).pipe(mapDispatchError(command)); + }), + ); + }); + const shellProjectionError = (cause: unknown) => new OrchestratorProjectionError({ threadId: ThreadId.make("thread:shell"), cause }); @@ -15213,6 +15440,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio resumeQueuedRuns, recoverDelegatedTasks, recoverDelegatedTask, + cancelDelegatedTask, delegatedTaskResultPending, restartContinuationPending: Effect.fn("OrchestratorV2.restartContinuationPending")( function (run) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 4a8bbbe4e6e5d..43f48ec7faf94 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1,3 +1,5 @@ +import * as NodeSqlite from "node:sqlite"; + import { assert, it, vi } from "@effect/vitest"; import { EnvironmentId, @@ -57,6 +59,13 @@ import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import { layerReaderDatabase, traceSqlRouting } from "../persistence/testkit/SqliteRouting.ts"; +declare module "node:sqlite" { + interface StatementSync { + // Node 26 has this; @types/node 24 does not declare it yet. + stat(counter: "vmStep"): number; + } +} + const layerTest = Layer.mergeAll( ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), SqlitePersistence.layerMemory, @@ -697,6 +706,53 @@ it.effect("idle compaction filters keep the latest run, live work and the newest records.runs.map((run) => run.id), ["run:idle-compaction:rolled-back", "run:idle-compaction:4"], ); + const { statements, tracer } = traceSqlStatements(); + const statusOnly = yield* store + .getThreadRecords(threadId, ["runs"], { + runStatuses: ["preparing", "starting", "running", "rolled_back"], + }) + .pipe(Effect.withTracer(tracer)); + assert.deepEqual( + statusOnly.runs.map((run) => run.id), + ["run:idle-compaction:rolled-back"], + ); + const runQuery = statements.find((statement) => + statement.includes("FROM orchestration_v2_projection_runs"), + ); + assert.isDefined(runQuery); + const sql = yield* SqlClient.SqlClient; + // Read only the matching runs, not every run in ordinal order. + const runPlan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${runQuery}`, + )) + .map((row) => row.detail) + .join("\n"); + assert.notInclude(runPlan, "orchestration_v2_projection_runs_thread_ordinal_idx"); + assert.match(runPlan, /SEARCH orchestration_v2_projection_runs USING INDEX \w*status\w*/); + // An id filter reads by primary key, not every row of the thread. + const byId = traceSqlStatements(); + const found = yield* store + .getThreadRecords(threadId, ["runs", "nodes", "subagents", "messages"], { + runIds: [RunId.make("run:idle-compaction:4")], + nodeIds: [nodeId], + subagentIds: [nodeId], + messageIds: [MessageId.make("message:idle-compaction:4")], + }) + .pipe(Effect.withTracer(byId.tracer)); + assert.deepEqual( + found.runs.map((run) => run.id), + ["run:idle-compaction:4"], + ); + for (const table of ["runs", "nodes", "subagents", "messages"]) { + const query = byId.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"); + assert.include(plan, `USING INDEX sqlite_autoindex_orchestration_v2_projection_${table}_1`); + } assert.deepEqual( records.providerTurns.map((turn) => turn.id), ["turn:idle-newest"], @@ -767,6 +823,23 @@ it.effect("record reads filter notification sources without dropping matching me matching.messages.map((message) => [message.id, message.text]), [["monitor", "Complete body for monitor"]], ); + const { statements, tracer } = traceSqlStatements(); + const userMessages = yield* store + .getThreadRecords(threadId, ["messages"], { messageRoles: ["user"] }) + .pipe(Effect.withTracer(tracer)); + assert.lengthOf(userMessages.messages, 3); + const ordinalLookup = statements.find((statement) => statement.includes("MIN(ordinal)")); + assert.isDefined(ordinalLookup); + const sql = yield* SqlClient.SqlClient; + const plan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${ordinalLookup}`, + [threadId, "[]"], + )).map((row) => row.detail); + // User-only reads must stay on the partial index instead of scanning every turn item. + assert.isTrue( + plan.some((detail) => detail.includes("turn_items_user_message_idx")), + plan.join("\n"), + ); assert.lengthOf((yield* store.getThreadRecords(threadId, ["messages"])).messages, 4); assert.lengthOf( (yield* store.getThreadRecords(threadId, ["messages"], { @@ -783,6 +856,105 @@ it.effect("record reads filter notification sources without dropping matching me }).pipe(Effect.provide(layerTest)), ); +it.effect("shells flag a latest run triggered by a provider background wake", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("background-wake"); + const initial = yield* store.getThreadProjection(threadId); + const at = initial.updatedAt; + let ordinal = 1; + for (const [id, source, outcome, expected] of [ + ["background-task", { kind: "background_task" }, "updated", true], + ["monitor", { kind: "monitor" }, "updated", true], + ["command", { kind: "command" }, "updated", true], + ["command-finished", { kind: "command" }, "completed", false], + ["monitor-stopped", { kind: "monitor" }, "cancelled", false], + ["subagent", { kind: "subagent" }, "updated", false], + ["delegated", { kind: "delegated_task", taskIds: [] }, "updated", false], + ["steered", { kind: "background_task" }, "updated", false], + ] as const) { + const messageId = MessageId.make(`message:background-wake:${id}`); + yield* store.apply({ + id: EventId.make(`event:background-wake:${id}:message`), + type: "message.updated", + threadId, + occurredAt: at, + payload: { + id: messageId, + threadId, + runId: null, + nodeId: null, + createdBy: "agent", + creationSource: "server", + role: "user", + text: id, + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + notification: { source, outcome, summary: "Background activity updated" }, + ...(id === "delegated" + ? { + delegatedCompletion: { + parentRunId: initial.runs[0]!.id, + generation: 1, + taskIds: [], + }, + } + : {}), + }, + }); + ordinal += 1; + yield* store.apply({ + id: EventId.make(`event:background-wake:${id}:run`), + type: "run.created", + threadId, + occurredAt: at, + payload: { + ...initial.runs[0]!, + id: RunId.make(`run:background-wake:${id}`), + ordinal, + userMessageId: messageId, + status: "completed", + completedAt: at, + }, + }); + if (id === "steered") { + yield* store.apply({ + id: EventId.make("event:background-wake:steered:follow-up"), + type: "message.updated", + threadId, + occurredAt: at, + payload: { + id: MessageId.make("message:background-wake:steered:follow-up"), + threadId, + runId: RunId.make("run:background-wake:steered"), + nodeId: null, + createdBy: "user", + creationSource: "web", + role: "user", + text: "also fix X", + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + }, + }); + } + const shells = [ + yield* store.getThreadShell(threadId), + ProjectionStore.threadShellFromProjection(yield* store.getThreadProjection(threadId)), + ]; + assert.deepEqual( + shells.map((shell) => shell?.latestRunBackgroundWake), + [expected, expected], + id, + ); + yield* assertShellIndexCoversShells(); + } + }).pipe(Effect.provide(layerTest)), +); + it.effect("SQL queue readiness skips held runs without bypassing active work", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -2976,7 +3148,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { parent_item_id, ordinal, type, status, updated_at, payload_json ) VALUES ( ${`turn-item:bounded-sql-history:ordinal-${suffix}`}, ${itemThreadId}, NULL, NULL, NULL, - NULL, NULL, ${ordinal}, 'assistant_message', 'completed', ${nowIso}, + NULL, NULL, ${ordinal}, 'user_message', 'completed', ${nowIso}, ${encodeUnknownJsonString({ messageId })} ) `; @@ -3368,6 +3540,137 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 999, payload_json = json_set(payload_json, '$.ordinal', 999) WHERE turn_item_id = 'turn-item:bounded-sql-history:999'`; + // Work folded below the newest 502 visible rows still counts its whole group, so the + // 100 commands after the messages stay hidden in the 398-item group. + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'assistant_message', + payload_json = json_set(payload_json, '$.type', 'assistant_message', '$.text', 'Working', + '$.messageId', turn_item_id, '$.streaming', json('false')) + WHERE thread_id = ${threadId} AND ordinal BETWEEN 300 AND 899`; + const straddledWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + const straddledOrdinals = [ + ...Array.from({ length: sqlPageLimit }, (_, index) => 900 - sqlPageLimit + index), + 1000, + ]; + assert.deepEqual( + straddledWindow.projection.turnItems.map((item) => item.ordinal), + straddledOrdinals, + ); + // A run that starts with work (a wake) has no segment start, so the group begins at its first item. + const moveFirstItemToRun = (runId: string) => sql` + UPDATE orchestration_v2_projection_turn_items SET run_id = ${runId}, + payload_json = json_set(payload_json, '$.runId', ${runId}) + WHERE thread_id = ${threadId} AND ordinal = 1`; + yield* moveFirstItemToRun("run:bounded-sql-history:2"); + const wakeWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.deepEqual( + wakeWindow.projection.turnItems.map((item) => item.ordinal), + straddledOrdinals, + ); + yield* moveFirstItemToRun(sharedRunId); + // A failed error below the tail still blocks folding for its run. Only the SQL columns + // change; the row sits below the window, so its payload is never decoded. + // A steer at 100 starts a later segment, so the error sits below the scan start too. + const setItem = (ordinal: number, type: string, status: string) => sql` + UPDATE orchestration_v2_projection_turn_items SET type = ${type}, status = ${status} + WHERE thread_id = ${threadId} AND ordinal = ${ordinal}`; + yield* setItem(100, "user_message", "completed"); + yield* setItem(2, "error", "failed"); + const blockedWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isFalse( + blockedWindow.projection.visibleTurnItems.some((row) => row.item.deferredWork), + ); + assert.deepEqual( + blockedWindow.projection.turnItems.map((item) => item.ordinal), + Array.from({ length: sqlPageLimit }, (_, index) => 1001 - sqlPageLimit + index), + ); + yield* setItem(2, "command_execution", "completed"); + yield* setItem(100, "command_execution", "completed"); + 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 BETWEEN 300 AND 899`; + + // Runless rows below a run that starts after the newest 502 visible rows stay in the window. + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET run_id = CASE WHEN ordinal > 990 THEN 'run:bounded-sql-history:991' END, + type = CASE WHEN ordinal = 991 THEN 'user_message' ELSE 'assistant_message' END, + payload_json = CASE WHEN ordinal = 991 + THEN json_set(payload_json, '$.type', 'user_message', '$.runId', 'run:bounded-sql-history:991', + '$.createdBy', 'user', '$.creationSource', 'web', '$.inputIntent', 'turn_start', + '$.messageId', turn_item_id, '$.text', 'Turn 991', '$.attachments', json('[]')) + ELSE json_set(payload_json, '$.type', 'assistant_message', + '$.runId', CASE WHEN ordinal > 990 THEN 'run:bounded-sql-history:991' END, + '$.text', 'Working', '$.messageId', turn_item_id, '$.streaming', json('false')) + END + WHERE thread_id = ${threadId} AND ordinal > 1`; + const windowStatements: Array]> = []; + const runlessWindow = yield* projectionStore + .getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }) + .pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + windowStatements.push(statement.compile()); + return Effect.succeed(statement); + }), + ); + // The window read must not grow with history below the newest visible rows. VM steps are + // deterministic; reading the whole history here cost 65% more steps. + const copyPath = "file:/thread-window-steps?vfs=memdb"; + { + using copy = new NodeSqlite.DatabaseSync(copyPath); + yield* sql`VACUUM INTO ${copyPath}`; + assert.isNotEmpty(windowStatements); + const windowSteps = () => + windowStatements.reduce((steps, [query, params]) => { + const statement = copy.prepare(query); + statement.all(...(params as ReadonlyArray)); + return steps + statement.stat("vmStep"); + }, 0); + const fullSteps = windowSteps(); + assert.isAbove(fullSteps, 0); + copy.exec( + "DELETE FROM orchestration_v2_projection_turn_items WHERE ordinal BETWEEN 2 AND 498", + ); + assert.isAtMost(fullSteps, windowSteps() * 1.1); + } + assert.deepEqual( + runlessWindow.projection.turnItems.map((item) => item.ordinal), + Array.from({ length: THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT }, (_, index) => 499 + index), + ); + const runlessPage = buildBoundedThreadProjection({ + projection: runlessWindow.projection, + snapshotSequence: runlessWindow.snapshotSequence, + }); + assert.isTrue(runlessPage.hasMoreHistory); + assert.isNotNull(runlessPage.historyCursor); + // Limits SQLite reads differently from `tailRows` skip the tail bound and match main. + const windowOrdinalCount = (rowLimit: number) => + projectionStore + .getThreadSnapshotWindow(threadId, { rowLimit, userTurnLimit: 10, deferWork: true }) + .pipe(Effect.map((window) => window.projection.turnItems.length)); + assert.strictEqual(yield* windowOrdinalCount(502.5), THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT); + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET payload_json = json_set(payload_json, '$.inputIntent', 'steer') + WHERE thread_id = ${threadId} AND ordinal = 991`; + assert.strictEqual(yield* windowOrdinalCount(-1), 1000); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = ${sharedRunId}, + type = 'command_execution', + payload_json = json_set(payload_json, '$.type', 'command_execution', '$.runId', ${sharedRunId}) + WHERE thread_id = ${threadId} AND ordinal > 1`; yield* sql` UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') @@ -3529,7 +3832,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { activeAttemptId: null, status: "cancelled", requestedAt: nowIso, - startedAt: nowIso, + startedAt: null, completedAt: nowIso, checkpointId: null, contextHandoffId: null, @@ -3658,6 +3961,48 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); + // A steer cancels the queued run before it starts while the steered run keeps writing, so + // the queued message leaves the window but its run is still the shell's latest run. + const steeredItemId = "turn-item:bounded-sql-history:steered-output"; + const steeredRunId = RunId.make("run:bounded-sql-history:1000"); + 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 + ) VALUES ( + ${steeredItemId}, ${threadId}, ${steeredRunId}, NULL, NULL, NULL, NULL, 1203, + 'command_execution', 'completed', ${nowIso}, + ${encodeUnknownJsonString({ + id: steeredItemId, + threadId, + runId: steeredRunId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1203, + status: "completed", + title: "steered output", + input: "echo steered", + output: "steered", + exitCode: 0, + startedAt: nowIso, + completedAt: nowIso, + updatedAt: nowIso, + type: "command_execution", + })} + ) + `; + const steeredSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + }); + assert.isFalse( + steeredSnapshot.projection.turnItems.some((item) => item.runId === cancelledRunId), + ); + assert.isTrue(steeredSnapshot.projection.runs.some((run) => run.id === cancelledRunId)); + yield* sql`DELETE FROM orchestration_v2_projection_turn_items WHERE turn_item_id = ${steeredItemId}`; + const interruptRunId = RunId.make("run:bounded-sql-history:interrupt"); const interruptNodeId = NodeId.make("node:bounded-sql-history:interrupt"); const interruptRequestId = TurnItemId.make("turn-item:bounded-sql-history:interrupt-request"); @@ -4343,7 +4688,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ); const settledPayload = (settledAt: string | null, archivedAt: string | null) => sql` UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, '$.settledAt', ${settledAt}, '$.archivedAt', ${archivedAt}) + SET payload_json = json_set(payload_json, '$.settledAt', ${settledAt}, '$.archivedAt', ${archivedAt}), + archived_at = ${archivedAt} WHERE thread_id = ${readThreadId} `; yield* settledPayload(DateTime.formatIso(now), null); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 04f2d480e7519..c43b89e59bf76 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -97,11 +97,13 @@ import type * as Statement from "effect/sql/Statement"; import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; +import { readableSearchText } from "./messageSearchIndex.ts"; import { isThreadHistoryUserTurn, isConversationHistoryItem, THREAD_HISTORY_MAX_RAW_TURNS, missingInterruptRequests, + DEFERRED_WORK_ITEM_TYPES, deferWorkHistoryRows, selectDeferredWorkPage, encodeThreadHistoryCursor, @@ -347,6 +349,7 @@ export interface ProjectionRecordFilter { readonly turnItemRunIds?: ReadonlyArray; readonly turnItemNodeIds?: ReadonlyArray; readonly runIds?: ReadonlyArray; + /** Without `includeLatestRun`, reads through a status index and sorts the matches, so keep it to statuses few runs hold. */ readonly runStatuses?: ReadonlyArray; /** Keeps the thread's highest-ordinal run even when `runStatuses` excludes it. */ readonly includeLatestRun?: true; @@ -696,6 +699,10 @@ export interface ProjectionStoreV2Shape { threadId: ThreadId, runId: RunId, ) => Effect.Effect; + readonly getRun: ( + threadId: ThreadId, + runId: RunId, + ) => Effect.Effect; readonly canStartQueuedRun: ( threadId: ThreadId, ) => Effect.Effect; @@ -1052,6 +1059,7 @@ type ShellThreadRow = { readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; readonly latest_run_stop_requested: number; + readonly latest_run_background_wake: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1063,6 +1071,7 @@ type ShellThreadRow = { readonly blocking_run_started_at: string | null; readonly blocking_run_completed_at: string | null; readonly blocking_run_stop_requested: number; + readonly blocking_run_background_wake: number; readonly blocking_failure_payload_json: string | null; readonly pending_request_payload_json: string | null; readonly pending_secret_request_payload_json: string | null; @@ -1693,6 +1702,8 @@ export function threadShellFromProjection( projection.turnItems.some( (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", ), + latestRunBackgroundWake: + latestRun != null && isBackgroundWakeRunForShell(latestRun, projection), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1793,6 +1804,25 @@ function isSilentRunForShell( ); } +const BACKGROUND_WAKE_SOURCE_KINDS = new Set(["background_task", "command", "monitor"]); + +function isBackgroundWakeRunForShell( + run: OrchestrationV2ThreadProjection["runs"][number], + projection: Pick, +): boolean { + const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + return ( + message?.delegatedCompletion === undefined && + // Only progress wakes; a terminal one ("Command … finished") is the work reporting its end. + message?.notification?.outcome === "updated" && + BACKGROUND_WAKE_SOURCE_KINDS.has(message.notification.source.kind) && + // A follow-up the user steered into the wake makes the run theirs. + !projection.messages.some( + (candidate) => candidate.runId === run.id && candidate.createdBy === "user", + ) + ); +} + function isInterruptibleRunForShell(run: OrchestrationV2ThreadProjection["runs"][number]): boolean { return run.status === "preparing" || run.status === "starting" || run.status === "running"; } @@ -1815,6 +1845,7 @@ type ShellThreadState = { readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; readonly latestRunStopRequested: boolean; + readonly latestRunBackgroundWake: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1978,6 +2009,7 @@ function shellFromState(input: { latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, latestRunStopRequested: input.state.latestRunStopRequested, + latestRunBackgroundWake: input.state.latestRunBackgroundWake, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -2619,7 +2651,8 @@ export const layer: Layer.Layer = streaming, created_at, updated_at, - payload_json + payload_json, + search_text ) VALUES ( ${event.payload.id}, @@ -2630,7 +2663,8 @@ export const layer: Layer.Layer = ${booleanInt(event.payload.streaming)}, ${createdAt}, ${updatedAt}, - ${payloadJson} + ${payloadJson}, + ${readableSearchText(event.payload.text)} ) ON CONFLICT(message_id) DO UPDATE SET @@ -2641,7 +2675,8 @@ export const layer: Layer.Layer = streaming = excluded.streaming, created_at = excluded.created_at, updated_at = excluded.updated_at, - payload_json = excluded.payload_json + payload_json = excluded.payload_json, + search_text = excluded.search_text `; break; } @@ -3086,6 +3121,48 @@ export const layer: Layer.Layer = SELECT ordinal, (SELECT MAX(ordinal) FROM user_anchors) AS latest FROM boundary `; const boundary = anchors[0]!; + // Folding needs whole segments, so the window would walk every item since the boundary. + // Non-deferrable types always keep their own row, so the window stays above the newest + // `tailRows` of them. Start at the latest segment start of each run that reaches that + // point, clamped to it so runless rows below stay in the scan. + const tailRows = Math.max(window.rowLimit, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT); + const tailWorkStart = sql<{ readonly ordinal: number | null }>` + ${eligible}, visible_tail AS ( + SELECT ordinal FROM eligible + WHERE ordinal >= ${boundary.ordinal} + AND type NOT IN ${sql.in(DEFERRED_WORK_ITEM_TYPES)} + ORDER BY ordinal DESC + LIMIT ${tailRows} + ), tail AS ( + SELECT MIN(ordinal) AS ordinal FROM visible_tail HAVING COUNT(*) >= ${tailRows} + ), tail_runs AS ( + SELECT DISTINCT item.run_id + FROM tail CROSS JOIN eligible AS item + WHERE item.ordinal >= tail.ordinal AND item.run_id IS NOT NULL + ) + SELECT MIN(MIN(COALESCE( + ( + SELECT start.ordinal FROM eligible AS start + WHERE start.run_id = tail_runs.run_id AND start.type = 'user_message' + AND start.ordinal <= tail.ordinal + ORDER BY start.ordinal DESC LIMIT 1 + ), + ( + SELECT MIN(first.ordinal) FROM orchestration_v2_projection_turn_items AS first + WHERE first.thread_id = ${threadId} AND first.run_id = tail_runs.run_id + ), + tail.ordinal + ), tail.ordinal)) AS ordinal + FROM tail LEFT JOIN tail_runs + `; + // A negative or fractional limit makes SQLite's LIMIT disagree with `tailRows`; keep the old start. + const workStart = + window.deferWork === true && + boundary.latest !== null && + Number.isSafeInteger(window.rowLimit) && + window.rowLimit >= 0 + ? Math.max(boundary.ordinal, (yield* tailWorkStart)[0]?.ordinal ?? 0) + : boundary.ordinal; return sql` ${eligible}, blocked_runs AS MATERIALIZED ( SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items @@ -3093,7 +3170,7 @@ export const layer: Layer.Layer = AND ${window.deferWork ? 1 : 0} = 1 AND run_id IN ( SELECT run_id FROM eligible - WHERE ordinal >= ${boundary.ordinal} AND run_id IS NOT NULL + WHERE ordinal >= ${workStart} AND run_id IS NOT NULL ) AND (type IN ('run_interrupt_request', 'run_interrupt_result') OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) @@ -3102,14 +3179,14 @@ export const layer: Layer.Layer = MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, CASE WHEN status = 'completed' AND (run_status = 'completed' OR attempt_status = 'superseded') - AND type IN ('command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool') + AND type IN ${sql.in(DEFERRED_WORK_ITEM_TYPES)} AND NOT (${inlineWorkOutput}) AND run_id NOT IN (SELECT run_id FROM blocked_runs) THEN 1 ELSE 0 END AS can_defer FROM eligible AS item WHERE ${window.deferWork ? 1 : 0} = 1 AND ${boundary.latest === null ? 0 : 1} = 1 - AND ordinal >= ${boundary.ordinal} + AND ordinal >= ${workStart} ), deferred_work AS ( SELECT run_id, segment, attempt_id, MIN(ordinal) AS first_ordinal, MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count @@ -3144,6 +3221,7 @@ export const layer: Layer.Layer = FROM window_items WHERE ordinal >= ${boundary.ordinal} ORDER BY ordinal DESC, turn_item_id DESC + -- Every LIMIT here and in latest_user_turn must stay <= tailRows, or rows below workStart go missing. LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 WHEN (SELECT row_count FROM latest_user_turn) @@ -3177,6 +3255,11 @@ export const layer: Layer.Layer = `; }); + // An id filter reads by primary key. `+` keeps SQLite off the thread index, which walks + // every row of the thread to find the few ids. + const threadColumn = (ids: ReadonlyArray | undefined, column = "thread_id") => + sql.literal(ids === undefined ? column : `+${column}`); + const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")( function* ( threadId: ThreadId, @@ -3410,21 +3493,26 @@ export const layer: Layer.Layer = ? sql` SELECT payload_json FROM orchestration_v2_projection_runs - WHERE thread_id = ${threadId} + WHERE ${threadColumn(filter?.runIds)} = ${threadId} ${filter?.runIds === undefined ? sql`` : sql`AND run_id IN (SELECT value FROM json_each(${encodeIdList(filter.runIds)}))`} ${ filter?.runStatuses === undefined - ? sql`` - : sql`AND (status IN (SELECT value FROM json_each(${encodeIdList(filter.runStatuses)})) - OR (${filter.includeLatestRun === true ? 1 : 0} = 1 AND run_id = ( + ? sql`ORDER BY ordinal ASC` + : filter.includeLatestRun !== true + ? // `+ordinal` stops SQLite from walking every run in the (thread_id, ordinal) + // index to skip a sort; a status index reads only matching runs. + sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.runStatuses)})) + ORDER BY +ordinal ASC` + : sql`AND (status IN (SELECT value FROM json_each(${encodeIdList(filter.runStatuses)})) + OR run_id = ( SELECT run_id FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND (status <> 'cancelled' OR json_extract(payload_json, '$.startedAt') IS NOT NULL) ORDER BY ordinal DESC LIMIT 1 - )))` + )) + ORDER BY ordinal ASC` } - ORDER BY ordinal ASC ` : sql` SELECT payload_json FROM orchestration_v2_projection_runs @@ -3432,6 +3520,12 @@ export const layer: Layer.Layer = AND (status IN ('queued','preparing','starting','running','waiting') OR run_id IN (SELECT value FROM json_each(${cohortRunIds})) OR run_id = ${window.requiredRunId ?? null} + ${ + // The shell's latest run, even a steer-cancelled one outside the newest window. + window.anchorItemId === undefined && window.requiredRunId === undefined + ? sql`OR run_id = ${presentedShellRunId(sql`${threadId}`)}` + : sql`` + } -- Queued or cancelled turns must not hide the latest actual session. OR run_id = ${latestTodoList?.run_id ?? null} OR run_id = ( @@ -3468,7 +3562,7 @@ export const layer: Layer.Layer = ? sql` SELECT payload_json FROM orchestration_v2_projection_nodes AS node - WHERE node.thread_id = ${threadId} + 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 @@ -3507,7 +3601,7 @@ export const layer: Layer.Layer = ? sql` SELECT payload_json FROM orchestration_v2_projection_subagents AS subagent - WHERE thread_id = ${threadId} + WHERE ${threadColumn(filter?.subagentIds)} = ${threadId} ${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)}))`} ${ @@ -3642,7 +3736,7 @@ export const layer: Layer.Layer = : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_messages - WHERE thread_id = ${threadId} + WHERE ${threadColumn(filter?.messageIds)} = ${threadId} ${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``} @@ -3802,10 +3896,13 @@ 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 - WHERE thread_id = ${threadId} AND type IN ('user_message', 'assistant_message') + WHERE thread_id = ${threadId} + AND ${userOnly ? sql`type = 'user_message'` : sql`type IN ('user_message', 'assistant_message')`} ${loadedMessageIds === undefined ? sql`` : sql`AND json_extract(payload_json, '$.messageId') IN (SELECT value FROM json_each(${encodeIdList(loadedMessageIds)}))`} GROUP BY json_extract(payload_json, '$.messageId') `; @@ -5553,6 +5650,21 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError(controlReadError(threadId))); + const getRun: ProjectionStoreV2Shape["getRun"] = (threadId, runId) => + sql + .withTransaction( + Effect.gen(function* () { + yield* requireThread(threadId); + const rows = yield* sql` + SELECT payload_json FROM orchestration_v2_projection_runs + WHERE run_id = ${runId} AND thread_id = ${threadId}`; + return rows[0] === undefined + ? undefined + : yield* decodeRunPayload(rows[0].payload_json); + }), + ) + .pipe(Effect.mapError(controlReadError(threadId))); + const canStartQueuedRun: ProjectionStoreV2Shape["canStartQueuedRun"] = (threadId) => sql .withTransaction( @@ -6258,6 +6370,38 @@ export const layer: Layer.Layer = ) )`; }; + // The newest run not waiting in a held queue, matching latestUnheldRun. + const presentedShellRunId = (threadId: Statement.Fragment) => sql`( + SELECT candidate.run_id + FROM orchestration_v2_projection_runs candidate + WHERE candidate.thread_id = ${threadId} + AND NOT ( + candidate.status = 'queued' + AND json_extract(candidate.payload_json, '$.queueHeld') IS 1 + ) + AND NOT ${isSilentShellRun("candidate")} + ORDER BY candidate.ordinal DESC, candidate.run_id DESC + LIMIT 1 + )`; + + // Mirrors isBackgroundWakeRunForShell. Stored sources keep the legacy encoding, where a + // command is background_command and a subagent is background_task with work "subagent". + const isBackgroundWakeShellRun = (alias: string) => { + const run = sql.literal(alias); + return sql`EXISTS ( + SELECT 1 FROM orchestration_v2_projection_messages wake + WHERE wake.message_id = json_extract(${run}.payload_json, '$.userMessageId') + AND json_extract(wake.payload_json, '$.notification.source.kind') + IN ('background_task', 'background_command', 'command', 'monitor') + AND json_extract(wake.payload_json, '$.notification.source.work') IS NULL + AND json_extract(wake.payload_json, '$.notification.outcome') = 'updated' + AND json_extract(wake.payload_json, '$.delegatedCompletion') IS NULL + ) AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_messages steer + WHERE steer.run_id = ${run}.run_id + AND json_extract(steer.payload_json, '$.createdBy') = 'user' + )`; + }; const selectShellThreadRows = ( threadId?: ThreadId, @@ -6291,6 +6435,7 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id AND stop_request.type = 'run_interrupt_request' ) AS latest_run_stop_requested, + ${isBackgroundWakeShellRun("presented")} AS latest_run_background_wake, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -6348,6 +6493,7 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = blocked.run_id AND stop_request.type = 'run_interrupt_request' ) AS blocking_run_stop_requested, + ${isBackgroundWakeShellRun("blocked")} AS blocking_run_background_wake, ( SELECT item.payload_json FROM orchestration_v2_projection_turn_items item @@ -6425,19 +6571,8 @@ export const layer: Layer.Layer = ORDER BY r.ordinal DESC, r.run_id DESC LIMIT 1 ) - -- The newest run not waiting in a held queue, matching latestUnheldRun. - LEFT JOIN orchestration_v2_projection_runs presented ON presented.run_id = ( - SELECT candidate.run_id - FROM orchestration_v2_projection_runs candidate - WHERE candidate.thread_id = t.thread_id - AND NOT ( - candidate.status = 'queued' - AND json_extract(candidate.payload_json, '$.queueHeld') IS 1 - ) - AND NOT ${isSilentShellRun("candidate")} - ORDER BY candidate.ordinal DESC, candidate.run_id DESC - LIMIT 1 - ) + LEFT JOIN orchestration_v2_projection_runs presented + ON presented.run_id = ${presentedShellRunId(sql.literal("t.thread_id"))} LEFT JOIN orchestration_v2_projection_runs blocked ON blocked.run_id = ( SELECT candidate.run_id FROM orchestration_v2_projection_runs candidate @@ -6933,6 +7068,7 @@ export const layer: Layer.Layer = ? null : DateTime.makeUnsafe(row.latest_run_completed_at); let latestRunStopRequested = row.latest_run_stop_requested === 1; + let latestRunBackgroundWake = row.latest_run_background_wake === 1; if (row.blocking_run_id !== null && row.blocking_failure_payload_json !== null) { const blockingFailure = yield* decodeTurnItemPayload( row.blocking_failure_payload_json, @@ -6946,6 +7082,7 @@ export const layer: Layer.Layer = terminalFailureItem = blockingFailure; latestRunId = RunId.make(row.blocking_run_id); latestRunStopRequested = row.blocking_run_stop_requested === 1; + latestRunBackgroundWake = row.blocking_run_background_wake === 1; latestRunStatus = "failed"; latestRunRequestedAt = row.blocking_run_requested_at === null @@ -6986,6 +7123,7 @@ export const layer: Layer.Layer = latestRunStartedAt, latestRunCompletedAt, latestRunStopRequested, + latestRunBackgroundWake, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: @@ -7171,7 +7309,7 @@ export const layer: Layer.Layer = SELECT t.payload_json FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL${ threadId === undefined - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : sql` AND t.thread_id = ${threadId}` }${ unsettledOnly @@ -7256,6 +7394,7 @@ export const layer: Layer.Layer = activityRunStatus: state.activityRunStatus, latestRunCompletedAt: state.latestRunCompletedAt, latestRunStopRequested: state.latestRunStopRequested, + latestRunBackgroundWake: state.latestRunBackgroundWake, pendingRuntimeRequest: state.pendingRuntimeRequest, pendingBackgroundTasks: state.pendingBackgroundTasks, })), @@ -7616,6 +7755,7 @@ export const layer: Layer.Layer = getCheckpointCaptureContext: readOnReader(getCheckpointCaptureContext), getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext), getRunMessage: readOnReader(getRunMessage), + getRun: readOnReader(getRun), canStartQueuedRun: readOnReader(canStartQueuedRun), getHeldQueuedRuns: readOnReader(getHeldQueuedRuns), getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 73562588127b2..be761db231879 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1643,6 +1643,73 @@ layer("ProviderEventIngestorV2", (it) => { }), ); + it.effect("keeps a steered message pending until the provider reports it read", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + }); + const runId = RunId.make("run:steer-read"); + const messageId = MessageId.make("message:steer-read"); + const steer: OrchestrationV2TurnItem = { + id: idAllocator.derive.userTurnItem({ messageId }), + threadId: threadEvent.threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + messageId, + inputIntent: "steer", + text: "Steer behind a command", + attachments: [], + }; + yield* eventSink.write({ events: [threadEvent] }); + const ingest = (event: ProviderAdapter.ProviderAdapterV2Event) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + event, + }); + const projected = projectionStore.getTurnItem({ + threadId: threadEvent.threadId, + itemId: steer.id, + }); + const steerUpdated = (status: "pending" | "completed") => + ingest({ + type: "steer.updated", + driver: CODEX_DRIVER, + threadId: threadEvent.threadId, + runId, + messageId, + status, + }); + + yield* ingest({ type: "turn_item.updated", driver: CODEX_DRIVER, turnItem: steer }); + yield* steerUpdated("pending"); + assert.deepInclude(yield* projected, { status: "pending", completedAt: null }); + yield* steerUpdated("completed"); + assert.equal((yield* projected)?.status, "completed"); + }), + ); + it.effect("stores a bounded copy of a large file-change diff in both SQLite tables", () => Effect.gen(function* () { const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index ad2cc36e14354..ee8bf164f1a70 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -685,6 +685,35 @@ export const layer: Layer.Layer< }), ]; } + case "steer.updated": { + const item = yield* projections.getTurnItem({ + threadId: input.event.threadId, + itemId: idAllocator.derive.userTurnItem({ messageId: input.event.messageId }), + }); + if ( + item?.type !== "user_message" || + item.status === input.event.status || + (item.status !== "pending" && item.status !== "completed") + ) { + return []; + } + const occurredAt = yield* DateTime.now; + return [ + yield* makeDomainEvent(input, { + type: "turn-item.updated", + threadId: item.threadId, + payload: { + ...item, + status: input.event.status, + completedAt: input.event.status === "pending" ? null : occurredAt, + updatedAt: occurredAt, + }, + runId: item.runId, + nodeId: item.nodeId, + occurredAt, + }), + ]; + } case "background_work.updated": return []; case "command_output.delta": diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index d374d980b2342..4d5bb557c4231 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -769,6 +769,7 @@ it.effect( ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], + ["turn-item.updated", "completed"], ["provider-thread.updated", "idle"], ["provider-session.updated", "stopped"], ], diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index f219ef0292e15..2c97b73ff7ba6 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -639,6 +639,36 @@ export const make = Effect.gen(function* () { payload: cancelledItem(item, now), }); } + // Queued runs and unsent resumes leave no visible turn behind, so there is nothing to explain. + if (run.status === "queued" || unsentResumeRunIds.includes(run.id)) continue; + events.push({ + id: yield* allocateEventId(), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: ids.derive.runSignalTurnItem({ runId: run.id, signal: "server-recovery" }), + threadId: projection.thread.id, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + // EventSink allocates the real position, after the run's existing items. + ordinal: 0, + status: "completed", + title: trigger === "startup" ? "Server restarted" : "Server shut down", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "system_notice", + message: detail, + }, + }); } // Blocking waits die with their turn; let child results wake the parent continuation as async tasks. const cutRunIds = new Set(runs.map((run) => run.id)); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index b4fc69727c1de..25ae18f7b6690 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3660,89 +3660,205 @@ it.effect( }), ); -it.effect("ProviderSessionManagerV2 persists session-scoped runtime requests without a run", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const now = yield* DateTime.now; - const projectId = yield* idAllocator.allocate.project({ - fixtureName: "provider-session-manager-session-request", - }); - const threadId = yield* idAllocator.allocate.thread({ - fixtureName: "provider-session-manager-session-request", - projectId, - }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThread = makeProviderThread({ - idAllocator, - threadId, - providerSessionId, - now, - }); +it.effect( + "ProviderSessionManagerV2 persists session-scoped requests and recovered commands without a run", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-session-request", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-session-request", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + }); - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - const pendingRequest = yield* makePendingRuntimeRequestEvents({ - idAllocator, - threadId, - providerSessionId, - providerThread, - now, - }); - const afterSequence = yield* eventSink.latestSequence({ threadId }); - const persistedFiber = yield* eventSink.stream({ threadId, afterSequence }).pipe( - Stream.filter( - (stored) => - stored.event.type === "runtime-request.updated" || - stored.event.type === "node.updated" || - stored.event.type === "turn-item.updated", - ), - Stream.take(3), - Stream.runCollect, - Effect.forkScoped, - ); - const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); - assert.isDefined(adapterEvents); - yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); - const persisted = Array.from(yield* Fiber.join(persistedFiber)); + const otherThreadId = yield* idAllocator.allocate.thread({ + fixtureName: "unattached-shell-owner", + projectId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: otherThreadId, now }), + ], + }); + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + const shellId = "sh_recovered"; + const command = { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_DRIVER, + nativeItemId: shellId, + }), + threadId, + runId: null, + nodeId: null, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: { driver: CODEX_DRIVER, nativeId: shellId, strength: "strong" as const }, + parentItemId: null, + ordinal: 0, + status: "running" as const, + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution" as const, + input: "echo retained", + output: "retained", + }; + const anchored = { + ...command, + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CODEX_DRIVER, + nativeItemId: "sh_anchored", + }), + runId: idAllocator.derive.run({ threadId, ordinal: 1 }), + }; + const commandEvents = [ + { + type: "turn_item.updated" as const, + driver: CODEX_DRIVER, + turnItem: anchored, + sessionScoped: true, + }, + { + type: "turn_item.updated" as const, + driver: CODEX_DRIVER, + turnItem: command, + sessionScoped: true, + }, + { + type: "provider_thread.updated" as const, + driver: CODEX_DRIVER, + providerThread: { + ...providerThread, + pendingBackgroundTasks: [{ taskId: shellId, kind: "command" as const }], + }, + sessionScoped: true, + }, + { + type: "turn_item.updated" as const, + driver: CODEX_DRIVER, + sessionScoped: true, + turnItem: { + ...command, + status: "completed" as const, + output: "retained\nfinished", + completedAt: now, + }, + }, + { + type: "provider_thread.updated" as const, + driver: CODEX_DRIVER, + providerThread: { ...providerThread, pendingBackgroundTasks: [] }, + sessionScoped: true, + }, + ]; + const afterSequence = yield* eventSink.latestSequence({ threadId }); + const persistedFiber = yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.filter( + (stored) => + stored.event.type === "runtime-request.updated" || + stored.event.type === "node.updated" || + (stored.event.type === "turn-item.updated" && + stored.event.payload.type === "approval_request"), + ), + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offerAll(adapterEvents!, [ + ...commandEvents, + { + type: "turn_item.updated", + driver: CODEX_DRIVER, + sessionScoped: true, + turnItem: { ...command, threadId: otherThreadId }, + }, + { + type: "provider_thread.updated", + driver: CODEX_DRIVER, + providerThread: { ...providerThread, appThreadId: otherThreadId }, + sessionScoped: true, + }, + ...pendingRequest.providerEvents, + ]); + const persisted = Array.from(yield* Fiber.join(persistedFiber)); - assert.sameMembers( - persisted.map((stored) => stored.event.type), - ["runtime-request.updated", "node.updated", "turn-item.updated"], - ); - const projection = yield* projectionStore.getThreadProjection(threadId); - const request = projection.runtimeRequests.find( - (candidate) => candidate.id === pendingRequest.requestId, - ); - const node = projection.nodes.find((candidate) => candidate.id === pendingRequest.nodeId); - const turnItem = projection.turnItems.find( - (candidate) => - candidate.type === "approval_request" && candidate.requestId === pendingRequest.requestId, - ); - assert.equal(request?.status, "pending"); - assert.equal(request?.providerTurnId, null); - assert.equal(node?.runId, null); - assert.equal(node?.status, "waiting"); - assert.equal(turnItem?.runId, null); - assert.equal(turnItem?.status, "waiting"); - }); + assert.sameMembers( + persisted.map((stored) => stored.event.type), + ["runtime-request.updated", "node.updated", "turn-item.updated"], + ); + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + const node = projection.nodes.find((candidate) => candidate.id === pendingRequest.nodeId); + const turnItem = projection.turnItems.find( + (candidate) => + candidate.type === "approval_request" && + candidate.requestId === pendingRequest.requestId, + ); + const recovered = projection.turnItems.find((item) => item.id === command.id); + assert.deepInclude(recovered, { + runId: null, + status: "completed", + output: "retained\nfinished", + }); + assert.deepEqual( + projection.providerThreads.find((item) => item.id === providerThread.id) + ?.pendingBackgroundTasks, + [], + ); + assert.deepInclude( + projection.turnItems.find((item) => item.id === anchored.id), + { runId: anchored.runId, output: "retained" }, + ); + assert.isEmpty(projection.runs); + const unrelated = yield* projectionStore.getThreadProjection(otherThreadId); + assert.isEmpty(unrelated.turnItems); + assert.isEmpty(unrelated.providerThreads); + assert.equal(request?.status, "pending"); + assert.equal(request?.providerTurnId, null); + assert.equal(node?.runId, null); + assert.equal(node?.status, "waiting"); + assert.equal(turnItem?.runId, null); + assert.equal(turnItem?.status, "waiting"); + }); - yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); + }), ); it.effect( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 0fb31e81a0732..84a64b10c81f2 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -281,10 +281,14 @@ function sessionKey(providerSessionId: ProviderSessionId): string { return String(providerSessionId); } -function sessionScopedRuntimeRequestThreadId( +function sessionScopedArtifactThreadId( event: ProviderAdapter.ProviderAdapterV2Event, ): ThreadId | undefined { switch (event.type) { + case "provider_thread.updated": + return event.sessionScoped === true + ? (event.providerThread.appThreadId ?? undefined) + : undefined; case "runtime_request.updated": return event.runtimeRequest.providerTurnId === null ? event.threadId : undefined; case "node.updated": @@ -292,6 +296,8 @@ function sessionScopedRuntimeRequestThreadId( ? event.node.threadId : undefined; case "turn_item.updated": + if (event.turnItem.type === "command_execution") + return event.sessionScoped === true ? event.turnItem.threadId : undefined; return event.turnItem.runId === null && (event.turnItem.type === "approval_request" || event.turnItem.type === "user_input_request") ? event.turnItem.threadId @@ -2274,17 +2280,21 @@ export const layerWithOptions = ( }); }); } - // Some providers can block before a run subscriber exists - // (project trust, login, or session-switch hooks). Persist - // their runless request artifacts directly so the normal T3 - // request UI can answer them and unblock session setup. - const threadId = sessionScopedRuntimeRequestThreadId(event); + // Requests and recovered background commands can arrive before a run subscribes. + const threadId = sessionScopedArtifactThreadId(event); if (threadId !== undefined) { yield* Effect.gen(function* () { const current = (yield* Ref.get(sessions)).get( sessionKey(entry.runtime.providerSessionId), ); - if (current?.runtime !== entry.runtime) return; + if ( + current?.runtime !== entry.runtime || + ((event.type === "provider_thread.updated" || + (event.type === "turn_item.updated" && + event.turnItem.type === "command_execution")) && + !current.attachedThreadIds.has(threadId)) + ) + return; yield* providerEventIngestor .ingestNormalized({ providerSessionId: entry.runtime.providerSessionId, @@ -2303,6 +2313,7 @@ export const layerWithOptions = ( ), ); }).pipe(entry.requestEventPermit.withPermits(1)); + yield* releaseLiveOutput; return; } yield* publishToSubscribers(entry.eventSubscribers, { type: "event", event }); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 5b562ee8b5b2d..6f451268fe9a2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -270,6 +270,7 @@ it.effect( getCheckpointRollbackContext: () => Effect.die("unused rollback context"), getCheckpointCaptureContext: () => Effect.die("not used"), getRunMessage: () => Effect.die("not used"), + getRun: () => Effect.die("not used"), isNodeStored: () => Effect.die("not used"), canStartQueuedRun: () => Effect.die("not used"), getHeldQueuedRuns: () => Effect.die("not used"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 3453842c80b03..39d502224f907 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -269,6 +269,7 @@ it.each([ function makeLocalCommandHarness(input: { readonly text: string; + readonly nativeGoals?: boolean; readonly worktreePath?: string; readonly worktreesDir?: string; readonly workspaceRoot?: string; @@ -342,7 +343,7 @@ function makeLocalCommandHarness(input: { }; const providerThread: OrchestrationV2ThreadProjection["providerThreads"][number] = { id: providerThreadId, - driver: ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make(input.nativeGoals === undefined ? "codex" : "opencode"), providerInstanceId: newInstanceId, providerSessionId, appThreadId: threadId, @@ -802,7 +803,13 @@ function makeLocalCommandHarness(input: { status: "ready", cwd: "/tmp/native-account-command", model: null, - capabilities: CodexProviderCapabilitiesV2, + capabilities: { + ...CodexProviderCapabilitiesV2, + turns: { + ...CodexProviderCapabilitiesV2.turns, + ...(input.nativeGoals === undefined ? {} : { supportsNativeGoals: input.nativeGoals }), + }, + }, createdAt: now, updatedAt: now, lastError: null, @@ -949,6 +956,18 @@ 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), + ), + }), + ); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( @@ -998,15 +1017,12 @@ function makeLocalCommandHarness(input: { Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadRecords, getTurnStartContext, - getRuntimeRecoveryProjection: () => - Effect.as(failReadIfRunning, { - ...projection, - hasConversation: projection.messages.some( - (m) => - m.role === "user" && - (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), - ), - }), + getRuntimeRecoveryProjection, + getRun: (_threadId, id) => + Effect.as( + failReadIfRunning, + projection.runs.find((candidate) => candidate.id === id), + ), getTurnStartHistory: () => input.sideChat !== undefined ? Effect.succeed([]) @@ -1047,6 +1063,7 @@ function makeLocalCommandHarness(input: { settledRuns, getThreadRecords, getTurnStartContext, + getRuntimeRecoveryProjection, setProjection: (next: OrchestrationV2ThreadProjection) => { projection = next; }, @@ -1375,6 +1392,23 @@ effectIt.effect.each([ }), ); +effectIt.effect.each([false, true])( + "OpenCode goal prompts defer switched-provider history only with native support: %s", + (nativeGoals) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "/goal all tests pass", + nativeGoals, + sideChat: { contextRun: "failed", switchedProvider: true }, + }); + harness.tryHandlePromptCommand.mockReturnValue(Effect.succeed(false)); + yield* harness.start; + expect(harness.startTurn.mock.calls[0]?.[0].message.text.includes("SIDE_CHAT_CONTEXT")).toBe( + !nativeGoals, + ); + }), +); + effectIt.effect.each([ { runStatus: "failed", turnStatus: "completed" }, { runStatus: "completed", turnStatus: "completed" }, @@ -1777,6 +1811,29 @@ effectIt.effect("does not overwrite a run interrupted while its provider session }), ); +effectIt.effect("checks the run's status without loading the thread's history", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "completed", turnStatus: "completed" }, + }); + + yield* harness.start; + + const controls = harness.startRootRun.mock.calls[0]?.[0]; + expect(controls).toBeDefined(); + if (controls === undefined) return; + expect(yield* controls.shouldStartProviderTurn!()).toBe(true); + expect(yield* controls.shouldFinalizeRun!()).toBe(true); + // Each check needs one run row; the recovery projection reads every run and open turn item. + // The one read left is the start's inherited background items snapshot, not a status check. + expect( + harness.getRuntimeRecoveryProjection.mock.calls.length, + "recovery projection reads beyond the inherited background items snapshot", + ).toBe(1); + }), +); + effectIt.effect("fails the run instead of replacing a native thread another client holds", () => Effect.gen(function* () { const detail = "This conversation is open in another Codex client."; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 22c3c04629838..11738a2bb4e23 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -240,13 +240,16 @@ export const layer: Layer.Layer< typeof RunExecutionService.selectInheritedBackgroundTurnItems >; }) => { + const isCurrentAttemptIn = (statuses: ReadonlyArray) => + projectionStore + .getRun(input.threadId, input.runId) + .pipe( + Effect.map( + (run) => run?.activeAttemptId === input.attemptId && statuses.includes(run.status), + ), + ); const isCurrentAttemptInStatus = (expectedStatus: OrchestrationV2Run["status"]) => - projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( - Effect.map((current) => { - const run = current.runs.find((candidate) => candidate.id === input.runId); - return run?.activeAttemptId === input.attemptId && run.status === expectedStatus; - }), - ); + isCurrentAttemptIn([expectedStatus]); return { isCurrentAttemptInStatus, loadInheritedBackgroundTurnItems: () => @@ -263,16 +266,7 @@ export const layer: Layer.Layer< Effect.catchCause(() => Effect.succeed(input.inheritedBackgroundTurnItems)), ), shouldStartProviderTurn: () => isCurrentAttemptInStatus("running"), - shouldFinalizeRun: () => - projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( - Effect.map((current) => { - const run = current.runs.find((candidate) => candidate.id === input.runId); - return ( - run?.activeAttemptId === input.attemptId && - (run.status === "starting" || run.status === "running") - ); - }), - ), + shouldFinalizeRun: () => isCurrentAttemptIn(["starting", "running"]), hasUnpairedRunInterruptRequest: () => projectionStore .hasUnpairedRunInterruptRequest( @@ -868,8 +862,12 @@ export const layer: Layer.Layer< linked: linkedCheckouts, records: contextRecords, }); - // Codex and Claude parse /goal from the prompt, so it goes verbatim and history waits a turn. - const goalDriver = session.driver === "codex" || session.driver === "claudeAgent"; + // Native goal commands go verbatim; handoff history waits a turn. + const goalDriver = + session.driver === "codex" || + session.driver === "claudeAgent" || + (session.driver === "opencode" && + session.providerSession.capabilities.turns.supportsNativeGoals === true); const goalCommand = goalDriver && parseGoalCommand(message) !== null; // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index a84c59ec3ec48..68b9147195e9e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -20,12 +20,14 @@ import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FiberMap from "effect/FiberMap"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { type ProviderChangeRequestWatchFingerprint, @@ -256,6 +258,10 @@ export const make = Effect.gen(function* () { Effect.suspend(() => reportEnd(lifeKey(target), lifeOf(target), reason)); const readFailures = new Map(); + /** The last sweep's groups, whose pull requests get host change feeds. */ + let currentGroups: ReadonlyArray = []; + // A pass reads watches and records new ones, so passes must not overlap. + const passes = Semaphore.makeUnsafe(1); const lastReads = new Map(); const threadTails = new Map< string, @@ -599,19 +605,26 @@ export const make = Effect.gen(function* () { }); }); - const sweep = 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) { const threads = yield* projections.getThreadsWithPullRequests(); const targets = threads.flatMap((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => - link.watch === undefined ? [] : [{ thread, link, watch: link.watch }], + link.watch === undefined || + (changed !== undefined && threadPullRequestKeyOf(link) !== changed) + ? [] + : [{ thread, link, watch: link.watch }], ), ); - const present = new Set(targets.map(lifeKey)); - yield* Effect.forEach( - [...lives].filter(([key]) => !present.has(key)), - ([key, life]) => reportEnd(key, life, "stopped"), - { discard: true }, - ); + if (changed === undefined) { + const present = new Set(targets.map(lifeKey)); + yield* Effect.forEach( + [...lives].filter(([key]) => !present.has(key)), + ([key, life]) => reportEnd(key, life, "stopped"), + { discard: true }, + ); + } for (const target of targets) lifeOf(target); const byPullRequest = new Map>(); const ending: Array = []; @@ -633,8 +646,11 @@ export const make = Effect.gen(function* () { targets: members, fingerprint: [...new Set(members.map(snapshotFingerprint))].toSorted().join("\n"), })); - for (const cache of [readFailures, lastReads, threadTails]) { - for (const key of cache.keys()) if (!byPullRequest.has(key)) cache.delete(key); + if (changed === undefined) { + currentGroups = groups; + for (const cache of [readFailures, lastReads, threadTails]) { + for (const key of cache.keys()) if (!byPullRequest.has(key)) cache.delete(key); + } } yield* Effect.forEach( ending, @@ -656,7 +672,24 @@ export const make = Effect.gen(function* () { yield* Effect.forEach( groups, (group) => - fingerprintOf(group).pipe( + // The host just reported a change, so a cached fingerprint may predate it. + (changed === undefined + ? Effect.void + : pullRequests.invalidate({ reference: referenceOf(group) }) + ).pipe( + Effect.andThen(fingerprintOf(group)), + Effect.tap((fingerprint) => { + const last = lastReads.get(group.key); + // Open pull request views refresh only when the host state moved. + return changed !== undefined && + fingerprint !== null && + fingerprint !== "paused" && + (last === undefined || + last.status !== fingerprint.status || + last.remarks !== fingerprint.remarks) + ? pullRequests.invalidate({ reference: referenceOf(group) }, { notifyReaders: true }) + : Effect.void; + }), Effect.flatMap((fingerprint) => fingerprint === "paused" ? Effect.void @@ -671,16 +704,54 @@ export const make = Effect.gen(function* () { ), { concurrency: "unbounded", discard: true }, ); - }).pipe( + }, passes.withPermit); + + const sweep = pass(undefined).pipe( Effect.catchCause(logFailure("pull request watch sweep failed", {})), Effect.withSpan("PullRequestWatchReactor.sweep"), ); - const start: PullRequestWatchReactor["Service"]["start"] = () => - forkParked( - sweep.pipe(Effect.repeat(Schedule.spaced(`${SWEEP_MINUTES} minutes`)), Effect.asVoid), + const pullRequestOf = (group: WatchGroup) => threadPullRequestKeyOf(group.targets[0]!.link); + + const readOnChange = (pullRequest: string) => + pass(pullRequest).pipe( + Effect.catchCause(logFailure("pull request watch change read failed", { pullRequest })), + Effect.withSpan("PullRequestWatchReactor.readOnChange"), ); + const start: PullRequestWatchReactor["Service"]["start"] = Effect.fn( + "PullRequestWatchReactor.start", + )(function* () { + // One feed per pull request: the host keeps one subscription per pull request and account. + const feeds = yield* FiberMap.make(); + const follow = Effect.gen(function* () { + const wanted = new Map(currentGroups.map((group) => [pullRequestOf(group), group])); + const stale = Array.from(feeds, ([pullRequest]) => pullRequest).filter( + (pullRequest) => !wanted.has(pullRequest), + ); + yield* Effect.forEach(stale, (pullRequest) => FiberMap.remove(feeds, pullRequest), { + discard: true, + }); + for (const [pullRequest, group] of wanted) { + // An ended feed restarts here, so a host that refused it is asked again each sweep. + yield* FiberMap.run(feeds, pullRequest, { onlyIfMissing: true })( + pullRequests.changes(referenceOf(group)).pipe( + // A review sends an event per comment; those landing during a read make one more. + Stream.buffer({ capacity: 1, strategy: "sliding" }), + Stream.runForEach(() => readOnChange(pullRequest)), + ), + ); + } + }); + yield* forkParked( + sweep.pipe( + Effect.andThen(follow), + Effect.repeat(Schedule.spaced(`${SWEEP_MINUTES} minutes`)), + Effect.asVoid, + ), + ); + }); + return { start, sweep } satisfies PullRequestWatchReactor["Service"]; }); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index c495290ad0813..f866efc3d0c06 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -1,4 +1,5 @@ import type { + NodeId, OrchestrationV2PendingBackgroundTask, OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, @@ -185,6 +186,24 @@ export function cancelledBeforeStart( return run.status === "cancelled" && neverStarted(run); } +/** A follow-up task's latest run, passing over queued work it no longer waits on, as the original task does. */ +export function delegatedTaskResultRun( + runs: ReadonlyArray, + taskId: NodeId, +): OrchestrationV2Run | undefined { + const taskRuns = runs + .filter((run) => run.delegatedTaskId === taskId) + .toSorted((a, b) => b.ordinal - a.ordinal); + const awaited = taskRuns.filter( + (run) => !cancelledBeforeStart(run) && !(run.status === "queued" && run.queueHeld === true), + ); + // Executing work reports the task's state; a reordered queue can finish a newer run before an older one. + const executing = awaited.find((run) => + ["preparing", "starting", "running", "waiting"].includes(run.status), + ); + return executing ?? awaited.find((run) => run.status === "queued") ?? awaited[0] ?? taskRuns[0]; +} + /** The provider never accepted this continuation's prompt, so starting it again repeats nothing. */ export function continuationFailedBeforePrompt( run: Pick, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index d9e70685bed4d..efda8723d377b 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -489,6 +489,8 @@ export function routeProviderEvent( } case "message.updated": return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state]; + case "steer.updated": + return [ownsRun(event.runId), state]; case "turn_item.updated": { if (ownsRun(event.turnItem.runId) || ownsChildThread(event.turnItem.threadId)) { return [true, state]; diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 733ace700ba9e..e06118769a5f5 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -29,6 +29,10 @@ import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -488,7 +492,9 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* canSteer = true, interruptTurn: Effect.Effect = Effect.void, steeringAcceptsAttachments = true, + nativeGoals?: boolean, ) { + const driver = ProviderDriverKind.make(nativeGoals === undefined ? "codex" : "opencode"); const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; @@ -501,6 +507,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* supportsActiveSteering: canSteer, supportsSteeringByInterruptRestart: canRestart, activeSteeringAcceptsAttachments: steeringAcceptsAttachments, + ...(nativeGoals === undefined ? {} : { supportsNativeGoals: nativeGoals }), }, }; const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { @@ -1773,6 +1780,225 @@ it.effect("keeps a queued message queued when promoted into a run that Stop reac ), ); +// regression: #32891 a restart that Stop already reached is lost +it.effect("refuses a steer but starts a restart sent into a run that Stop reached", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("restart-after-stop"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; + // The worker has not delivered the interrupt yet, so the run still reads as running. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop"), + threadId, + runId: first.runId, + }); + const steerError = yield* orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId: MessageId.make("message:steer"), + text: "steer", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.equal(steerError._tag, "OrchestratorDispatchError"); + assert.include(String(steerError.cause), "is stopping and cannot be steered"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("restart"), + threadId, + messageId: MessageId.make("message:restart"), + text: "restart", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "restart_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual( + started.map((turn) => turn.message.text), + ["first", "restart"], + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +// regression: #32891 a restart that arrives after the turn ended is lost +it.effect.each([ + { status: "completed", settled: true }, + { status: "completed", settled: false }, + { status: "interrupted", settled: true }, + { status: "interrupted", settled: false }, + { status: "cancelled", settled: false }, +] as const)( + "starts a restart that missed the turn ($status, run settled: $settled) as a new turn", + ({ status, settled: runSettled }) => + Effect.scoped( + Effect.gen(function* () { + const { events, started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + `restart-after-${status}-${runSettled ? "settled" : "unsettled"}`, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; + const turn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; + const turnEnded = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && + event.payload.id === turn.id && + event.payload.status === status, + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status, completedAt: yield* DateTime.now }, + }); + yield* Fiber.join(turnEnded); + const settleRun = Effect.gen(function* () { + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status !== "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status, + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + }); + if (runSettled) yield* settleRun; + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("restart"), + threadId, + messageId: MessageId.make("message:restart"), + text: "restart", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "restart_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + if (!runSettled) { + const restartLeftQueue = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.userMessageId === "message:restart" && + event.payload.status !== "queued", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* settleRun; + yield* Fiber.join(restartLeftQueue); + yield* worker.drain(); + } + + assert.deepEqual(steered, []); + assert.deepEqual( + started.map((turn) => turn.message.text), + ["first", "restart"], + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +// regression: #32891 a restart into a failed provider turn is lost +it.effect("queues a restart sent into a provider turn that failed before its run settled", () => + Effect.scoped( + Effect.gen(function* () { + const { events, started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "restart-after-failed-unsettled", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; + const turn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; + const turnFailed = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && + event.payload.id === turn.id && + event.payload.status === "failed", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "failed", completedAt: yield* DateTime.now }, + }); + yield* Fiber.join(turnFailed); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("restart"), + threadId, + messageId: MessageId.make("message:restart"), + text: "restart", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "restart_active", targetRunId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + + assert.deepEqual(steered, []); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + projection.runs.find((run) => run.userMessageId === "message:restart")?.status, + "queued", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("queues a plain send behind the stopped queue and releases its hold", () => Effect.scoped( Effect.gen(function* () { @@ -1841,8 +2067,15 @@ it.effect("queues a plain send behind the stopped queue and releases its hold", ), ); -const goalHarness = Effect.fn("goalHarness")(function* (name: string) { - const harness = yield* nextTurnSelectionHarness(name); +const goalHarness = Effect.fn("goalHarness")(function* (name: string, nativeGoals?: boolean) { + const harness = yield* nextTurnSelectionHarness( + name, + false, + true, + Effect.void, + true, + nativeGoals, + ); const send = (id: string, text: string, delivery: "queue" | "auto", freshContext = false) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -1882,26 +2115,34 @@ const goalHarness = Effect.fn("goalHarness")(function* (name: string) { return { ...harness, send, read }; }); -it.effect("stops the running turn and starts a goal sent into it next", () => - Effect.scoped( - Effect.gen(function* () { - const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-steer"); - yield* Effect.gen(function* () { - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = yield* startFirstTurn; - yield* send("queued", "queued", "queue"); - yield* send("goal", "/goal all tests pass", "auto"); - yield* worker.drain(); +it.effect.each([undefined, false, true])( + "goal dispatch stops the active turn only with native support: %s", + (nativeGoals) => + Effect.scoped( + Effect.gen(function* () { + const { steered, layer, startFirstTurn, send, read } = yield* goalHarness( + "goal-steer", + nativeGoals, + ); + yield* Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + const sending = send("goal", "/goal all tests pass", "auto"); + if (nativeGoals === false) { + yield* Effect.flip(sending); + } else yield* sending; + yield* worker.drain(); - assert.deepEqual(steered, []); - assert.deepEqual(yield* read(threadId), { - queue: ["queued"], - started: ["first", "/goal all tests pass"], - stopRequested: true, - }); - }).pipe(Effect.provide(layer)); - }), - ), + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: nativeGoals === false ? ["first"] : ["first", "/goal all tests pass"], + stopRequested: nativeGoals !== false, + }); + }).pipe(Effect.provide(layer)); + }), + ), ); it.effect("starts a goal that names no model on the thread's model ahead of a queued pick", () => @@ -1944,6 +2185,147 @@ it.effect("starts a goal that names no model on the thread's model ahead of a qu ), ); +it.effect("restarts by stopping the running turn when the provider cannot interrupt-restart", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn, send, read } = yield* goalHarness( + "restart-without-interrupt-restart", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("restart"), + threadId, + messageId: MessageId.make("message:restart"), + text: "do this instead", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "restart_active", targetRunId: started[0]!.runId }, + createdBy: "agent", + creationSource: "server", + }); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: ["first", "do this instead"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("starts a restart that stops the running turn ahead of an older held queue", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn, send, read } = + yield* goalHarness("restart-past-held-queue"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop"), + threadId, + }); + yield* worker.drain(); + const secondRunning = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("second"), + threadId, + messageId: MessageId.make("message:second"), + text: "second", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* worker.drain(); + yield* Fiber.join(secondRunning); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("restart"), + threadId, + messageId: MessageId.make("message:restart"), + text: "do this instead", + attachments: [], + dispatchMode: { type: "restart_active", targetRunId: started[1]!.runId }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* worker.drain(); + + const { queue, started: startedTexts } = yield* read(threadId); + assert.deepEqual( + { queue, started: startedTexts }, + { queue: ["queued"], started: ["first", "second", "do this instead"] }, + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("reports a restart that stops the running turn as restarted", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn } = yield* goalHarness("restart-send-delivery"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const threadId = yield* startFirstTurn; + const input = { + projectId: ProjectId.make("project:restart-send-delivery"), + commandId: CommandId.make("restart"), + threadId, + messageId: MessageId.make("message:restart"), + text: "do this instead", + attachments: [], + mode: "restart", + createdBy: "agent", + creationSource: "mcp", + } as const; + const sent = yield* threadManagement.sendToThread(input); + // Stop holds the replacement before it starts; a replay must still report the restart. + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-after-restart"), + threadId, + }); + const replayed = yield* threadManagement.sendToThread(input); + + assert.equal(started.length, 1); + assert.deepEqual([sent.delivery, replayed.delivery], ["restarted", "restarted"]); + }).pipe( + Effect.provide( + ThreadManagementService.layer.pipe( + Layer.provideMerge(layer), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), + ), + ); + }), + ), +); + it.effect("queues a goal clear without stopping work when no goal drives it", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 6793421a64333..9366fdf193dbf 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -1,4 +1,5 @@ import { assert, it, describe } from "@effect/vitest"; +import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; import { EventId, MessageId, @@ -19,6 +20,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -31,6 +34,8 @@ import { } from "./threadHistoryPaging.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; +vi.mock("@t3tools/shared/threadFindText", { spy: true }); + const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); @@ -460,6 +465,40 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("keeps its caches while Find is in use and drops them after one idle minute", () => + Effect.gen(function* () { + const projection = yield* setup; + yield* putItems([item("one", 1, "idle needle one"), item("two", 2, "idle needle two")]); + const parses = () => + vi + .mocked(searchableMessageSegments) + .mock.calls.filter(([message]) => message.text.startsWith("idle needle")).length; + const search = (query: string) => projection.searchThread({ threadId, query }); + // One idle minute passes inside this search. The later counts fail if it let the caches go. + yield* projection.searchThreadStream({ threadId, query: "needle" }).pipe( + Stream.tap((frame) => + frame.complete === false ? TestClock.adjust("1 minute") : Effect.void, + ), + Stream.runDrain, + ); + assert.equal(parses(), 2); + yield* TestClock.adjust("40 seconds"); + yield* search("idle"); + yield* TestClock.adjust("40 seconds"); + // A repeated query is answered from the completed scan and still counts as use. + const repeated = yield* projection + .searchThreadStream({ threadId, query: "needle" }) + .pipe(Stream.runCollect); + assert.equal(repeated.length, 1); + yield* TestClock.adjust("40 seconds"); + yield* search("one"); + assert.equal(parses(), 2); + yield* TestClock.adjust("1 minute"); + yield* search("needle"); + assert.equal(parses(), 4); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("invalidates cached counts when streamed text changes or a run rolls back", () => 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 ead68680a21c9..c6a2c012f758c 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -12,6 +12,7 @@ import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as RcRef from "effect/RcRef"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -38,6 +39,14 @@ interface FindDocument extends FindRow { readonly runId: OrchestrationV2TurnItem["runId"]; readonly count: number; } +type CompletedScans = Map< + string, + { + sequence: number; + documents: readonly FindDocument[]; + sourceThreadIds: readonly ThreadId[]; + } +>; class TextKey extends Data.Class<{ text: string; role: "user" | "assistant" | "plan"; @@ -232,26 +241,21 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( }); }); }); - // Scans visit every message; a cache smaller than the thread churns without hits. - const textCache = yield* Cache.make({ - capacity: 8192, - timeToLive: "1 minute", - lookup: (key: TextKey) => Effect.sync(() => parseText(key)), - }); - // Bounded separately because each entry can hold a large body; typing reuses the parse. - const largeTextCache = yield* Cache.make({ - capacity: 32, - timeToLive: "1 minute", - lookup: (key: TextKey) => Effect.sync(() => parseText(key)), + const makeTextCache = (capacity: number) => + Cache.make({ capacity, lookup: (key: TextKey) => Effect.sync(() => parseText(key)) }); + // Text keys are message content, so entries never go stale. Cache TTL only expires on access + // and would keep the last searched threads forever. Each search borrows this for its whole + // stream, and all of it is dropped once Find is idle. + const findCaches = yield* RcRef.make({ + acquire: Effect.all({ + // Scans visit every message; a cache smaller than the thread churns without hits. + text: makeTextCache(8192), + // Bounded separately because each entry can hold a large body; typing reuses the parse. + largeText: makeTextCache(32), + scans: Effect.sync((): CompletedScans => new Map()), + }), + idleTimeToLive: "1 minute", }); - const scans = new Map< - string, - { - sequence: number; - documents: readonly FindDocument[]; - sourceThreadIds: readonly ThreadId[]; - } - >(); const readRevision = Effect.fn("ThreadFind.readRevision")(function* ( threadIds: readonly ThreadId[], ) { @@ -275,7 +279,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( return revision[0]?.sequence ?? 0; }); const snapshot = Effect.fn("ThreadFind.snapshot")( - function* (input: OrchestrationV2SearchThreadInput) { + function* (input: OrchestrationV2SearchThreadInput, scans: CompletedScans) { const { threadId } = input; const active = yield* sql<{ cwd: string | null }>` SELECT COALESCE(json_extract(t.payload_json, '$.worktreePath'), p.workspace_root) AS cwd @@ -326,8 +330,9 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( const searchThreadStream = (input: OrchestrationV2SearchThreadInput) => Stream.unwrap( Effect.gen(function* () { + const { scans, ...textCache } = yield* RcRef.get(findCaches); // Release SQLite before decoding and parsing. Both frames describe one snapshot. - const data = yield* snapshot(input); + const data = yield* snapshot(input, scans); if (data.result) return Stream.succeed(data.result); const { payloads, cwd, sequence, cacheKey, sourceThreadIds } = data; const decode = (row: FindPayload) => @@ -357,7 +362,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( const decoded = yield* decode(row); const key = textKey(decoded.item, cwd, input.skills ?? []); const segments = yield* Cache.get( - key.text.length <= 32_768 ? textCache : largeTextCache, + key.text.length <= 32_768 ? textCache.text : textCache.largeText, key, ); const document = documentFor(decoded, countSegments(segments, input.query)); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index bd5174dfcf5d8..3c83ded0e9211 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -209,6 +209,7 @@ function makeHarness(options: HarnessOptions = {}) { : Option.none(), ), getByWorkspaceRoot: () => Effect.succeedSome(project), + workspaceScanDirectory: () => Effect.die("unused"), snapshot: Effect.die("unused"), getShell: () => Effect.die("unused"), listShells: () => Effect.succeed([project, otherProject] as never), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index b91f61cca6738..fa7e86c901aee 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -391,6 +391,7 @@ const make = Effect.gen(function* () { attachments: message.attachments, ...(message.context ? { context: message.context } : {}), modelSelection, + projectId, }) .pipe( Effect.map((result) => ({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 52f5c4c554266..f05d3da25b8d7 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -504,6 +504,7 @@ export interface ThreadManagementServiceShape { ThreadManagementBackgroundWaitError >; readonly stopDelegatedTasks: (input: { + readonly remoteDispatch?: Orchestrator.OrchestratorV2["Service"]["dispatch"]; readonly threadId: ThreadId; readonly commandId: CommandId; readonly reason?: string | undefined; @@ -513,6 +514,7 @@ export interface ThreadManagementServiceShape { >; readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; + readonly cancelDelegatedTask: Orchestrator.OrchestratorV2["Service"]["cancelDelegatedTask"]; readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; readonly streamStoredEvents: Orchestrator.OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: Orchestrator.OrchestratorV2["Service"]["streamStoredEventsFrom"]; @@ -524,13 +526,15 @@ export class ThreadManagementService extends Context.Service< ThreadManagementServiceShape >()("t3/orchestration-v2/ThreadManagementService") {} +export const ACTIVE_RUN_STATUSES = [ + "preparing", + "starting", + "running", + "waiting", +] as const satisfies ReadonlyArray; + export function isActiveRun(run: OrchestrationV2Run): boolean { - return ( - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting" - ); + return (ACTIVE_RUN_STATUSES as ReadonlyArray).includes(run.status); } export function isTerminalRunStatus( @@ -904,6 +908,7 @@ const make = Effect.gen(function* () { ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }), ...(input.freshContext ? { freshContext: true } : {}), dispatchMode, + ...(mode === "auto" ? { deliveryIntent: "auto" } : {}), createdBy: input.createdBy, creationSource: input.creationSource, }); @@ -933,8 +938,19 @@ const make = Effect.gen(function* () { messageId: input.messageId, }); } - const delivery: ThreadManagementSendResult["delivery"] = - turnItem === null || turnItem.inputIntent === "queued_turn" + // A provider without interrupt-and-restart stops the turn and runs the + // restart as the next turn. Read from this command's own events, so a replay agrees. + const restartedAsNextTurn = + mode === "restart" && + dispatched.storedEvents.some( + ({ event }) => + event.type === "turn-item.updated" && + event.payload.type === "run_interrupt_request" && + event.payload.runId !== run.id, + ); + const delivery: ThreadManagementSendResult["delivery"] = restartedAsNextTurn + ? "restarted" + : turnItem === null || turnItem.inputIntent === "queued_turn" ? "queued" : turnItem.inputIntent === "turn_start" ? "started" @@ -1521,7 +1537,7 @@ const make = Effect.gen(function* () { // One with its result keeps it, but its thread there may run follow-up work. if (task.remoteChild === undefined) continue; const commandId = CommandId.make(`${input.commandId}:stop:${task.id}`); - yield* dispatch( + yield* (input.remoteDispatch ?? dispatch)( task.result === null ? { type: "delegated_task.remote.complete", @@ -1562,7 +1578,9 @@ const make = Effect.gen(function* () { stopDelegatedTasks: false, ...(input.reason === undefined ? {} : { reason: input.reason }), }).pipe( - Effect.andThen(stopDelegatedTasks({ ...input, threadId })), + Effect.andThen( + stopDelegatedTasks({ threadId, commandId: input.commandId, reason: input.reason }), + ), Effect.catch((error) => Effect.logWarning("Unable to stop a delegated task", { parentThreadId: input.threadId, @@ -1667,6 +1685,7 @@ const make = Effect.gen(function* () { awaitBackgroundTasks, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, + cancelDelegatedTask: orchestrator.cancelDelegatedTask, delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index a77ce46f23ee6..edf80bcc8c2ee 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -24,6 +24,7 @@ function dispatchWasNotAccepted( case "OrchestratorCommandIdConflictError": case "OrchestratorSubagentThreadReadOnlyError": case "OrchestratorThreadAboveModeLimitError": + case "OrchestratorProviderSelectionBusyError": case "OrchestratorRuntimeModeAboveCeilingError": case "ThreadManagementProviderInstanceDisabledError": return true; diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 423d1cddd943c..21c54ff4ce1e8 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -181,6 +181,139 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect("looks up a finished turn's branch once for all its turn-end events", () => + Effect.scoped( + Effect.gen(function* () { + const thread = { ...threadShell("turn-end-thread"), branch: "t3/turn-end" }; + const otherThread = { + ...threadShell("other-turn-end-thread"), + branch: "t3/other-turn-end", + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + // A turn end commits its checkpoints and the run's end together. The queued + // non-refresh request ahead of them must still become a refresh when they join it. + const turnEnd = [ + { type: "thread.metadata-updated", threadId: thread.id }, + { type: "checkpoint.captured", threadId: thread.id }, + { type: "checkpoint.captured", threadId: thread.id }, + { type: "run.updated", threadId: thread.id, payload: { status: "completed" } }, + { type: "checkpoint.captured", threadId: otherThread.id }, + { type: "run.updated", threadId: otherThread.id, payload: { status: "completed" } }, + ] as Array; + // Arrives during the thread's own lookup, which may already have read the old host state. + const duringLookup = [ + { type: "checkpoint.captured", threadId: thread.id }, + { type: "thread.metadata-updated", threadId: thread.id }, + ] as Array; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const turnEndQueued = yield* Deferred.make(); + const lookupStarted = yield* Deferred.make(); + const followUpQueued = yield* Deferred.make(); + const lookups: Array<{ readonly branch: string; readonly refresh: boolean }> = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + // The batch arrives while the worker is busy with the startup sweep. + streamDomainEvents: Stream.fromEffect(Deferred.await(sweepStarted)).pipe( + Stream.drain, + Stream.concat(Stream.fromIterable(turnEnd)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(turnEndQueued, undefined)).pipe(Stream.drain), + ), + Stream.concat(Stream.fromEffect(Deferred.await(lookupStarted)).pipe(Stream.drain)), + Stream.concat(Stream.fromIterable(duringLookup)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(followUpQueued, undefined)).pipe(Stream.drain), + ), + Stream.concat(Stream.never), + ), + getPullRequestSnapshot: ({ threadId }) => + Effect.succeed({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread, otherThread].filter( + (candidate) => threadId === undefined || candidate.id === threadId, + ), + archivedThreads: [], + }), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + lookups.push({ branch: input.branch, refresh: options?.refresh ?? false }); + if (!(yield* Deferred.isDone(turnEndQueued))) { + yield* Deferred.succeed(sweepStarted, undefined); + yield* Deferred.await(turnEndQueued); + } else if ( + input.branch === thread.branch && + !(yield* Deferred.isDone(followUpQueued)) + ) { + yield* Deferred.succeed(lookupStarted, undefined); + yield* Deferred.await(followUpQueued); + } + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + shouldRunOpportunisticWork: Effect.succeed(false), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), + ), + FileSystem.layerNoop({}), + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(followUpQueued); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + // The startup sweep, one refresh lookup per thread for its turn-end batch, then one + // follow-up refresh for the events that arrived during the thread's lookup. + expect(lookups).toEqual([ + { branch: thread.branch, refresh: false }, + { branch: otherThread.branch, refresh: false }, + { branch: thread.branch, refresh: true }, + { branch: otherThread.branch, refresh: true }, + { branch: thread.branch, refresh: true }, + ]); + }), + ), + ); + it.effect.each([ { worktree: "a linked worktree", diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index d84c532d06ecd..4648660ce62b7 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -346,8 +346,15 @@ export const make = Effect.gen(function* () { ); }); + // At most one queued request per thread, so a turn end's events share one lookup. + // A request after that lookup starts queues another, so later host changes are still read. + const queuedThreadRequests = new Map(); + const worker = yield* makeDrainableWorker((request: RefreshRequest) => - synchronize(request).pipe( + Effect.suspend(() => { + if (request.threadId !== null) queuedThreadRequests.delete(request.threadId); + return synchronize(request); + }).pipe( Effect.provideService(GitVcsDriver.BackgroundGitWork, true), Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), @@ -359,15 +366,27 @@ export const make = Effect.gen(function* () { ), ); + const enqueueThread = (threadId: ThreadId, refresh: boolean) => + Effect.suspend(() => { + const queued = queuedThreadRequests.get(threadId); + if (queued !== undefined) { + queued.refresh ||= refresh; + return Effect.void; + } + const request = { threadId, refresh }; + queuedThreadRequests.set(threadId, request); + return worker.enqueue(request); + }); + const processEvent = (event: OrchestrationV2DomainEvent) => { switch (event.type) { case "thread.created": case "thread.unarchived": case "thread.metadata-updated": - return worker.enqueue({ threadId: event.threadId, refresh: false }); + return enqueueThread(event.threadId, false); case "thread.unsettled": case "checkpoint.captured": - return worker.enqueue({ threadId: event.threadId, refresh: true }); + return enqueueThread(event.threadId, true); case "run.updated": if ( event.payload.status === "completed" || @@ -375,7 +394,7 @@ export const make = Effect.gen(function* () { event.payload.status === "cancelled" || event.payload.status === "interrupted" ) { - return worker.enqueue({ threadId: event.threadId, refresh: true }); + return enqueueThread(event.threadId, true); } break; } diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index eea038ee0d3b1..30110acdc98fa 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + EnvironmentId, EventId, MessageId, ProjectId, @@ -18,9 +19,11 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { rebuildMessageSearch } from "./messageSearchIndex.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ThreadSearch from "./ThreadSearch.ts"; @@ -150,6 +153,20 @@ const message = ( }, }); +// The prefix and suffix are hidden selection context, not text the reader sees. +const quote = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment:local"), + threadId: ThreadId.make("thread:quoted"), + messageId: MessageId.make("quoted-answer"), + text: "A lighthouse guides ships safely through the darkness.", + comment: "what are you talking about", + start: 0, + end: 54, + prefix: "beacon01", + suffix: "harbor", +}); + it.effect.each([true, false])( "ThreadSearch excludes bootstrap text before the hit and candidate limits (indexed: %s)", (indexed) => @@ -379,6 +396,96 @@ it.layer(layerTest)("ThreadSearch", (it) => { }), ); + it.effect("matches the readable text of quotes and their comments", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:search-citation"); + const threadId = ThreadId.make("thread:citation"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "citation", "user", `${quote} quiet silver lantern`), + ], + projections.apply, + { discard: true }, + ); + const snippets = (query: string) => + Effect.map(search.search({ query }), (result) => + result.matches + .filter((match) => match.threadId === threadId) + .map((match) => match.snippet), + ); + + const readable = + "A lighthouse guides ships safely through the darkness. Comment: what are you talking about quiet silver lantern"; + assert.deepEqual(yield* snippets("what are you talking about"), [ + `…${readable.slice(readable.indexOf("the darkness"))}`, + ]); + assert.deepEqual(yield* snippets("ships safely through"), [readable]); + assert.deepEqual(yield* snippets("beacon01"), []); + assert.deepEqual(yield* snippets("harbor"), []); + + yield* projections.apply(message(threadId, "citation", "user", "quiet silver lantern")); + assert.deepEqual(yield* snippets("what are you talking about"), []); + assert.deepEqual(yield* snippets("lantern"), ["quiet silver lantern"]); + }), + ); + + it.effect("finds accented, CJK, and apostrophe words of a quote through the index", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:search-citation-index"); + const threadId = ThreadId.make("thread:citation-index"); + // The stored link percent-encodes these words; the index must hold them as they read. + const encodedQuote = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment:local"), + threadId: ThreadId.make("thread:quoted"), + messageId: MessageId.make("quoted-answer"), + text: "The kittiwake's résumé mentions 灯台の光 twice.", + comment: "naïve question в логах", + start: 0, + end: 44, + prefix: "", + suffix: "", + }); + yield* createProject(project); + yield* Effect.forEach( + [thread(threadId, project), message(threadId, "citation-index", "user", encodedQuote)], + projections.apply, + { discard: true }, + ); + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + assert.lengthOf(index, 1); + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + + for (const query of [ + "kittiwake's", + "kittiwake’s", + "résumé", + "灯台の光", + "naïve", + "логах в", + ]) { + assert.deepEqual(yield* threadIds(query), [threadId], query); + } + // `t3 doctor` rebuilds the index after VACUUM; quotes must still read. + yield* rebuildMessageSearch(sql); + assert.deepEqual(yield* threadIds("résumé"), [threadId]); + + yield* projections.apply(message(threadId, "citation-index", "user", "plain words")); + assert.deepEqual(yield* threadIds("résumé"), []); + assert.deepEqual(yield* threadIds("plain"), [threadId]); + }), + ); + it.effect("indexes a message when it finishes streaming and follows later edits", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index bfb10c7087974..7508bae4365f7 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -34,7 +34,11 @@ import { type ThreadSearchSubject, } from "@t3tools/shared/paletteSearch"; import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; -import { indexedMessageCondition, nonPlaceholderMessageCondition } from "./messageSearchIndex.ts"; +import { + indexedMessageCondition, + messageSearchText, + nonPlaceholderMessageCondition, +} from "./messageSearchIndex.ts"; import { loadOlderSettledThreadsPage } from "./ShellStream.ts"; // When the user last wrote in the thread aliased `threads`, as the clients @@ -204,7 +208,7 @@ export const messageHitsQuery = ( messages.thread_id AS "threadId", eligible.project_id AS "projectId", messages.role AS source, - json_extract(messages.payload_json, '$.text') AS text, + ${messageSearchText(sql)} AS text, messages.created_at AS "messageCreatedAt" FROM orchestration_v2_projection_messages AS messages INNER JOIN eligible ON eligible.thread_id = messages.thread_id @@ -221,7 +225,7 @@ export const messageHitsQuery = ( messages.thread_id AS "threadId", eligible.project_id AS "projectId", messages.role AS source, - json_extract(messages.payload_json, '$.text') AS text, + ${messageSearchText(sql)} AS text, messages.created_at AS "messageCreatedAt" FROM orchestration_v2_message_search INNER JOIN orchestration_v2_projection_messages AS messages @@ -242,13 +246,13 @@ export const messageHitsQuery = ( AND messages.message_id NOT LIKE 'async-answer:%' AND NOT EXISTS ( SELECT 1 FROM json_each(${substrings}) AS token - WHERE instr(lower(json_extract(messages.payload_json, '$.text')), token.value) = 0 + WHERE instr(lower(${messageSearchText(sql)}), token.value) = 0 ) AND NOT EXISTS ( SELECT 1 FROM json_each(${shortSpellings}) AS token WHERE NOT EXISTS ( SELECT 1 FROM json_each(token.value) AS spelling - WHERE instr(json_extract(messages.payload_json, '$.text'), spelling.value) > 0 + WHERE instr(${messageSearchText(sql)}, spelling.value) > 0 ) ) -- The index's own rowid, so FTS5 returns hits newest first and stops at diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 7e658f1100b75..a1f7bcc178876 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -796,6 +796,98 @@ it.effect("a message the user sends after Stop releases the held queue; an agent }).pipe(Effect.provide(layerTest)), ); +it.effect( + "an agent's auto send queues behind a run that is still stopping instead of steering it", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threads = yield* ThreadManagementService.ThreadManagementService; + const threadId = ThreadId.make("thread:stop-mcp-send"); + yield* createWatchingThread(threadId, 6); + yield* send(threadId, "active", "start_immediately"); + const initial = yield* orchestrator.getThreadProjection(threadId); + const run = initial.runs[0]!; + const providerSessionId = ProviderSessionId.make("session:stop-mcp-send"); + const now = yield* DateTime.now; + yield* projections.apply({ + id: EventId.make("event:stop-mcp-send:session"), + type: "provider-session.updated", + threadId, + occurredAt: now, + payload: { + id: providerSessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* projections.apply({ + id: EventId.make("event:stop-mcp-send:provider-thread"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { ...initial.providerThreads[0]!, providerSessionId }, + }); + yield* projections.apply({ + id: EventId.make("event:stop-mcp-send:running"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }); + yield* projections.apply({ + id: EventId.make("event:stop-mcp-send:turn"), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:stop-mcp-send"), + providerThreadId: run.providerThreadId!, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* send(threadId, "queued before the stop", "queue_after_active"); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-mcp-send"), + threadId, + }); + assert.deepEqual((yield* threadState(threadId)).runs, ["running", "queued:held"]); + + const result = yield* threads.sendToThread({ + projectId: ProjectId.make("project:thread-stop"), + commandId: CommandId.make("command:stop-mcp-send"), + threadId, + messageId: MessageId.make("message:stop-mcp-send"), + text: "Follow up after Stop", + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }); + assert.equal(result.delivery, "queued"); + assert.deepEqual((yield* threadState(threadId)).runs, [ + "running", + "queued:held", + "queued:held", + ]); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "thread.stop ends an idle thread's watches and accepts a thread with nothing to stop", () => diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 6c80def39059f..395e8768996e8 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -175,6 +175,7 @@ const make = Effect.gen(function* () { ...(inherited === undefined ? {} : { forkSource: inherited }), instructions: settings.threadTitleInstructions, modelSelection: settings.textGenerationModelSelection, + projectId: projection.thread.projectId, }); const generatedTitle = result.title.trim(); return generatedTitle === "New thread" || diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts index dbd3545e5906b..87d7256b6809d 100644 --- a/apps/server/src/orchestration-v2/messageSearchIndex.ts +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -1,3 +1,4 @@ +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as Effect from "effect/Effect"; import type * as SqlClient from "effect/sql/SqlClient"; @@ -40,46 +41,83 @@ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "message AND ${nonPlaceholderMessageCondition(sql, row)} `; +// Curly apostrophes straight, as search folds a query, since iOS keyboards +// type "’" and others type "'". +const foldApostrophes = (text: string) => `replace(replace(${text}, '’', ''''), '‘', '''')`; + +const readableText = (row: string) => + `COALESCE(${row}.search_text, json_extract(${row}.payload_json, '$.text'))`; + +/** + * What search_text holds for a message: its text as it reads, a quote's + * percent-encoded link replaced by its words and comment. NULL when that is + * the stored text, so most messages store nothing extra. + */ +export const readableSearchText = (text: string) => { + const readable = assistantCitationsToPlainText(text); + return readable === text ? null : readable; +}; + +/** A message's text as it reads, for thread search. */ +export const messageSearchText = (sql: SqlClient.SqlClient) => + sql.literal(readableText("messages")); + /** - * What the index stores for a message: its text with curly apostrophes - * straight, as search folds a query, since iOS keyboards type "’" and others - * type "'". Migration 69's triggers use it too. `payload` is the message's + * A message's stored text with apostrophes folded, what the index held before + * migration 81. Migration 69's backfill uses it. `payload` is the message's * JSON column. */ export const indexedMessageText = (sql: SqlClient.SqlClient, payload: string) => - sql.literal(`replace(replace(json_extract(${payload}, '$.text'), '’', ''''), '‘', '''')`); + sql.literal(foldApostrophes(`json_extract(${payload}, '$.text')`)); + +// What the index stores: a message's readable text with apostrophes folded. +// Migrations 69 and 74 create the triggers before migration 81 adds +// search_text, and `t3 doctor` can rebuild a database that has not migrated, +// so those fall back to the stored text. +const indexedText = Effect.fn("MessageSearchIndex.indexedText")(function* ( + sql: SqlClient.SqlClient, +) { + const column = yield* sql` + SELECT 1 FROM pragma_table_info('orchestration_v2_projection_messages') + WHERE name = 'search_text' + `; + return column.length > 0 + ? (row: string) => sql.literal(foldApostrophes(readableText(row))) + : (row: string) => indexedMessageText(sql, `${row}.payload_json`); +}); export const createMessageSearchTriggers = Effect.fn("MessageSearchIndex.createTriggers")( function* (sql: SqlClient.SqlClient) { + const text = yield* indexedText(sql); yield* sql` CREATE TRIGGER orchestration_v2_message_search_insert AFTER INSERT ON orchestration_v2_projection_messages WHEN ${indexedMessageCondition(sql, "new")} BEGIN INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + SELECT new.rowid, ${text("new")} WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; END `; // Streaming deltas rewrite the row many times a second; skip them until the - // message settles. Upserts rewrite every column, so a finished message - // re-indexes only when what the index reads changed. Deleting a rowid the - // index never held is a no-op. + // message settles. Upserts rewrite every column, so re-index only when what + // the index stores changed; migration 81's backfill writes only search_text. + // Deleting a rowid the index never held is a no-op. yield* sql` CREATE TRIGGER orchestration_v2_message_search_update - AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages + AFTER UPDATE ON orchestration_v2_projection_messages WHEN (old.streaming = 0 OR new.streaming = 0) AND ( old.streaming IS NOT new.streaming OR old.role IS NOT new.role - OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') + OR ${text("old")} IS NOT ${text("new")} OR json_extract(old.payload_json, '$.createdBy') IS NOT json_extract(new.payload_json, '$.createdBy') ) BEGIN DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + SELECT new.rowid, ${text("new")} WHERE ${indexedMessageCondition(sql, "new")} AND json_extract(new.payload_json, '$.text') IS NOT NULL; END @@ -95,6 +133,20 @@ export const createMessageSearchTriggers = Effect.fn("MessageSearchIndex.createT }, ); +/** Replaces the search triggers with their current definition, where the index exists. */ +export const recreateMessageSearchTriggers = Effect.fn("MessageSearchIndex.recreateTriggers")( + function* (sql: SqlClient.SqlClient) { + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + if (index.length === 0) return; + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_delete`; + yield* createMessageSearchTriggers(sql); + }, +); + /** * Refills the search index from the messages table. The index is keyed by * message rowid, so anything that may renumber or copy rows (VACUUM, a dev @@ -108,6 +160,7 @@ export const rebuildMessageSearch = Effect.fn("MessageSearchIndex.rebuild")(func SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' `; if (index.length === 0) return; + const text = yield* indexedText(sql); // One transaction: an interrupt between clear and refill would leave an // empty index that nothing repairs, since search only checks the table exists. yield* sql.withTransaction( @@ -118,7 +171,7 @@ export const rebuildMessageSearch = Effect.fn("MessageSearchIndex.rebuild")(func `; yield* sql` INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} + SELECT messages.rowid, ${text("messages")} FROM orchestration_v2_projection_messages AS messages WHERE ${indexedMessageCondition(sql)} AND json_extract(messages.payload_json, '$.text') IS NOT NULL diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bb8b20fac3311..cdcfa606f4612 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1594,10 +1594,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { assert.equal(native.runtimeRequests[0]?.responseCapability.type, "not_resumable"); yield* recovery.reconcile("shutdown"); yield* recovery.reconcile("startup"); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).runtimeRequests, - recovered.runtimeRequests, - ); + const rerecovered = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(rerecovered.runtimeRequests, recovered.runtimeRequests); + const runItems = rerecovered.turnItems.filter((item) => item.runId === runId); + const notices = runItems.filter((item) => item.type === "system_notice"); + assert.lengthOf(notices, scope === "run" ? 1 : 0); + if (scope === "run") { + assert.equal(notices[0]?.title, "Server restarted"); + assert.equal(notices[0]?.nodeId, recovered.runs[0]?.rootNodeId); + assert.equal(notices[0]?.ordinal, Math.max(...runItems.map((item) => item.ordinal))); + } yield* orchestrator.dispatch({ type: "runtime-request.respond", commandId: CommandId.make(`recovery-answer-${trigger}-${scope}`), @@ -3969,6 +3975,127 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect( + "reads a watched pull request when the host reports a change, and wakes once per change", + () => + Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-host-event"); + const projectId = ProjectId.make("pr-watch-host-event-project"); + yield* seedProject({ + projectId, + title: "Watch host events", + workspaceRoot: "/workspace/watch-host-event", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-host-event-create"), + threadId, + projectId, + title: "Watch host events", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 15 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-host-event-start"), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + }); + + let checks: PullRequestDetail["checks"] = [ + { name: "lint", status: "pending", description: null, url: null, required: true }, + ]; + let fingerprints = 0; + const events = yield* Queue.unbounded(); + const following = yield* Deferred.make(); + const detailReads = yield* Queue.unbounded(); + const host = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Queue.offer(detailReads, undefined).pipe( + Effect.as({ + ...watchedPullRequestDetail({ + projectId, + number: 15, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "aaaaaaaaaa", + checks, + }), + ), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + // A fresh fingerprint every time, so every pass reads the detail. + watchFingerprint: () => + Effect.sync(() => ({ status: String(++fingerprints), remarks: "" })), + invalidate: () => Effect.void, + changes: () => + Stream.fromEffectDrain(Deferred.succeed(following, undefined)).pipe( + Stream.concat(Stream.fromQueue(events)), + ), + }), + ); + const wakes = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => + messages.filter( + (message) => message.notification !== undefined && message.notification !== null, + ), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* reactor.start(); + // The feed starts after the first sweep has read the pull request. + yield* Deferred.await(following); + yield* Queue.take(detailReads); + + checks = [ + { name: "lint", status: "failure", description: null, url: null, required: true }, + ]; + yield* Queue.offer(events, undefined); + // The clock never moves, so no sweep can be the one that reads the failure. + yield* Queue.take(detailReads); + // Nothing new: this read must compare against the watch the last one recorded. + yield* Queue.offer(events, undefined); + yield* Queue.take(detailReads); + // A sweep waits for the change read in flight. + yield* reactor.sweep; + yield* worker.drain(); + const woken = yield* wakes; + assert.lengthOf(woken, 1); + assert.include(woken[0]!.notification?.summary ?? "", "failed"); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-host-event-end"), + threadId, + ...key, + watching: false, + }); + }), + ), + ); + it.effect("wakes for checks that complete after the no-checks notice on the same head", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -5630,6 +5757,218 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + { origin: "agent" as const, delivery: "queued" as const }, + { origin: "agent" as const, delivery: "after-completion" as const }, + { origin: "agent" as const, delivery: "held-resume" as const }, + { origin: "agent" as const, delivery: "resume-before-stop-ends" as const }, + { origin: "child" as const, delivery: "after-completion" as const }, + { origin: "user" as const, delivery: "queued" as const }, + ])( + "preserves queued agent and child updates but resets fresh requests (%j)", + ({ origin, delivery }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`work-clock-${origin}-${delivery}`); + const originalMessageId = MessageId.make(`${threadId}-original`); + const followUpMessageId = MessageId.make(`${threadId}-follow-up`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-create`), + threadId, + projectId: ProjectId.make(`${threadId}-project`), + title: "Agent message work clock", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-original`), + threadId, + messageId: originalMessageId, + text: "Original user work", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const dispatchFollowUp = orchestrator.dispatch({ + type: "message.dispatch", + createdBy: origin === "user" ? "user" : "agent", + creationSource: origin === "user" ? "web" : "mcp", + ...(origin === "user" ? {} : { senderThreadId: ThreadId.make("sending-agent") }), + commandId: CommandId.make(`${threadId}-follow-up`), + threadId, + messageId: followUpMessageId, + text: "Follow-up work", + attachments: [], + modelSelection, + dispatchMode: { + type: delivery === "after-completion" ? "start_immediately" : "queue_after_active", + }, + }); + const held = delivery === "held-resume" || delivery === "resume-before-stop-ends"; + const drainsOnEnd = delivery === "queued" || delivery === "resume-before-stop-ends"; + if (delivery === "queued" || held) yield* dispatchFollowUp; + const startedRunIds = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting" + ? Queue.offer(startedRunIds, stored.event.payload.id) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const original = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === originalMessageId, + ); + assert.isDefined(original); + if (origin === "child") { + assert.isNotNull(original.rootNodeId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}-child`), + type: "subagent.updated", + threadId, + runId: original.id, + occurredAt: original.requestedAt, + payload: { + id: NodeId.make(`${threadId}-child`), + threadId, + runId: original.id, + parentNodeId: original.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: original.providerInstanceId, + providerThreadId: null, + childThreadId: ThreadId.make("sending-agent"), + nativeTaskRef: null, + prompt: "Delegated work", + title: null, + model: null, + status: "completed", + result: "Done", + startedAt: original.requestedAt, + completedAt: original.requestedAt, + updatedAt: original.requestedAt, + }, + }, + ], + }); + } + if (held) { + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === followUpMessageId, + ); + assert.isDefined(queued); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}-hold`), + type: "run.updated", + threadId, + occurredAt: original.requestedAt, + payload: { ...queued, queueHeld: true }, + }, + { + id: EventId.make(`${threadId}-interrupt-request`), + type: "turn-item.updated", + threadId, + runId: original.id, + occurredAt: original.requestedAt, + payload: { + id: TurnItemId.make(`${threadId}-interrupt-request`), + type: "run_interrupt_request", + threadId, + runId: original.id, + nodeId: original.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 90, + status: "completed", + title: "Interrupt requested", + startedAt: original.requestedAt, + completedAt: original.requestedAt, + updatedAt: original.requestedAt, + message: "Stopped by user", + }, + }, + ], + }); + } + if (delivery === "resume-before-stop-ends") { + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}-resume`), + threadId, + }); + } + const originalStart = DateTime.makeUnsafe("2026-10-21T14:20:17.457Z"); + const completedAt = DateTime.makeUnsafe("2026-10-21T14:23:53.509Z"); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}-completed`), + type: "run.updated", + threadId, + runId: original.id, + ...(original.rootNodeId === null ? {} : { nodeId: original.rootNodeId }), + providerInstanceId: original.providerInstanceId, + occurredAt: completedAt, + payload: { + ...original, + status: held ? "interrupted" : "completed", + startedAt: originalStart, + completedAt, + }, + }, + ], + }); + if (delivery === "held-resume") { + yield* TestClock.adjust("3 hours"); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === followUpMessageId, + ); + assert.isDefined(queued); + assert.equal(queued.status, "queued"); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}-resume`), + threadId, + }); + } else if (delivery === "after-completion") yield* dispatchFollowUp; + const startedRunId = drainsOnEnd ? yield* Queue.take(startedRunIds) : null; + const followUp = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === followUpMessageId, + ); + assert.isDefined(followUp); + if (drainsOnEnd) assert.equal(startedRunId, followUp.id); + assert.equal(followUp.status, "starting"); + if (origin === "child" || (origin === "agent" && delivery === "queued")) { + assert.isDefined(followUp.workStartedAt); + assert.equal( + DateTime.toEpochMillis(followUp.workStartedAt), + DateTime.toEpochMillis(originalStart), + ); + } else { + assert.isUndefined(followUp.workStartedAt); + } + }), + ); + it.effect.each(["usage_limit", "provider_error"] as const)( "handles a queued message after a %s failure", (failureClass) => @@ -6190,10 +6529,143 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["the reset passes", "the user resumes"] as const)( + "holds a limited provider's queue through another provider's turn until %s", + (release) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`runtime-layer-queue-limit-switch-${release}`); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + createdBy: "user", + creationSource: "web", + projectId: ProjectId.make(`${threadId}:project`), + title: "Limit switch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const send = ( + name: string, + selection: typeof modelSelection | typeof switchedSelection, + type: "start_immediately" | "queue_after_active", + ) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${name}`), + threadId, + messageId: MessageId.make(`${threadId}:${name}`), + text: name, + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection: selection, + dispatchMode: { type }, + }); + yield* send("limited", modelSelection, "start_immediately"); + yield* send("queued", modelSelection, "queue_after_active"); + const [limited, queued] = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.isDefined(limited); + assert.isDefined(queued); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:error`), + type: "turn-item.updated", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:error`), + type: "error", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerThreadId: limited.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Usage limit reached", + code: "usage_limit", + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { hours: 1 })), + }, + }, + }, + { + id: EventId.make(`${threadId}:failed`), + type: "run.updated", + threadId, + runId: limited.id, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { ...limited, status: "failed", completedAt: now }, + }, + ], + }); + + // A message on another provider starts ahead of the held queue and + // completes there. + yield* send("switch", switchedSelection, "start_immediately"); + const switched = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:switch`), + ); + assert.equal(switched?.status, "starting"); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:switch:completed`), + type: "run.updated", + threadId, + runId: switched!.id, + providerInstanceId: switched!.providerInstanceId, + occurredAt: now, + payload: { ...switched!, status: "completed", completedAt: now }, + }, + ], + }); + + yield* orchestrator.resumeQueuedRuns; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.runs.find((run) => run.id === queued.id)?.status, "queued"); + + if (release === "the reset passes") { + yield* TestClock.adjust("61 minutes"); + yield* orchestrator.resumeQueuedRuns; + } else { + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + } + const reset = yield* orchestrator.getThreadProjection(threadId); + const resumed = reset.runs.find((run) => run.id === queued.id); + assert.equal(resumed?.status, "starting"); + assert.deepEqual(resumed?.modelSelection, modelSelection); + }), + ); + it.effect.each([ ["server", modelSelection], ["provider", modelSelection], - ["provider", { ...modelSelection, instanceId: alternateInstanceId }], ] as const)( "delivers a queued monitor update from %s after conversation selection %j", ([creationSource, currentSelection]) => @@ -6306,7 +6778,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect.each([ { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, - { instanceId: alternateInstanceId, model: "gpt-5.5" }, ])( "starts a queued delegated completion on a selection set while it waited: %j", (newerSelection) => @@ -9419,6 +9890,12 @@ it.layer(layerTest)("usage-limit recovery", (it) => { ), [run.id, resumed.runs.at(-1)!.id], ); + assert.deepEqual( + resumed.turnItems.flatMap((item) => + item.type === "system_notice" && item.title === "Server restarted" ? [item.runId] : [], + ), + scenario === "resume" ? [] : [continuation.id], + ); assert.deepEqual( resumed.runs.map((row) => row.status), scenario === "resume" ? ["failed", "cancelled", "starting"] : ["failed", "cancelled"], diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index ba1dadad1864c..6ea169ee98bd0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -244,6 +244,8 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( layerEventSink, IdAllocator.layer, ProviderSessionRuntime.layer, + ProviderAdapterRegistry.layerFromProviderInstanceRegistry, + ThreadCommandExecutor.layer, ), ), ); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 6521447763951..233139235a705 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -463,7 +463,6 @@ describe("orchestrator replay recovery", () => { assert.deepEqual(child?.thread.modelSelection, { instanceId: CLAUDE_MODEL_SELECTION.instanceId, model: "claude-sonnet-5", - options: [{ id: "effort", value: "high" }], }); const conversation = (child?.turnItems ?? []) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 0627435a09b00..ad3c7686b3e0d 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -521,6 +521,7 @@ export function layerWithRegistry( getThreadRecords: orchestrator.getThreadRecords, getThreadProjection: orchestrator.getThreadProjection, recoverDelegatedTask: orchestrator.recoverDelegatedTask, + cancelDelegatedTask: orchestrator.cancelDelegatedTask, delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, }); }), 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 e9b2636998386..83eee8f66a5dc 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -114,6 +114,7 @@ function makeTestAdapter(input: { readonly failure?: ReturnType; readonly failedRunOrdinals?: ReadonlySet; readonly interruptedRunOrdinals?: ReadonlySet; + readonly cancelledRunOrdinals?: ReadonlySet; readonly holdRunOrdinal?: number; readonly pendingBackgroundWork?: Ref.Ref; readonly subagentOnlyBackgroundWork?: boolean; @@ -295,7 +296,9 @@ function makeTestAdapter(input: { ? "failed" : input.interruptedRunOrdinals?.has(turnInput.runOrdinal) ? "interrupted" - : "completed"; + : input.cancelledRunOrdinals?.has(turnInput.runOrdinal) + ? "cancelled" + : "completed"; const response = input.responseByThreadId?.[turnInput.threadId]?.[turnInput.runOrdinal] ?? input.responseByRunOrdinal[turnInput.runOrdinal] ?? @@ -1587,6 +1590,8 @@ describe("orchestration v2 provider switching", () => { ["failed", true, true, true], ["interrupted", false, true, false], ["interrupted", true, false, true], + ["cancelled", false, false, false], + ["cancelled", true, true, false], ] as const ).map( ([status, queued, returning, native]) => @@ -1631,6 +1636,7 @@ describe("orchestration v2 provider switching", () => { interruptedRunOrdinals: new Set( status === "interrupted" ? [sourceOrdinal] : [], ), + cancelledRunOrdinals: new Set(status === "cancelled" ? [sourceOrdinal] : []), holdRunOrdinal: sourceOrdinal, holdFirstTurn: started, releaseFirstTurn: release, @@ -1644,7 +1650,7 @@ describe("orchestration v2 provider switching", () => { const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; const waitForRun = ( ordinal: number, - expectedStatus: "completed" | "failed" | "interrupted", + expectedStatus: "completed" | "failed" | "interrupted" | "cancelled", ) => orchestrator.streamStoredEvents.pipe( Stream.filter( diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson index 47838ffc8e35e..fddf2c83c5f33 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"thinking-delta","frame":{"type":"interaction.update","runId":"run-046488f0-f621-4d59-976c-c4c935ad1282","update":{"type":"thinking-delta","text":"The user requested the"}}} {"type":"expect_outbound","label":"run.cancel:1","frame":{"type":"run.cancel","runId":"run-046488f0-f621-4d59-976c-c4c935ad1282"}} {"type":"emit_inbound","label":"run.completed:1","frame":{"type":"run.completed","result":{"id":"run-046488f0-f621-4d59-976c-c4c935ad1282","requestId":"d3b97820-1cf3-448d-a4b1-5fa7edc21460","status":"cancelled","model":{"id":"composer-2.5"},"durationMs":3177}}} -{"type":"expect_outbound","label":"run.start:2","frame":{"type":"run.start","message":"Actually, respond with exactly: steering fixture observed","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}} +{"type":"expect_outbound","label":"run.start:2","frame":{"type":"run.start","message":"Your previous turn was interrupted and is missing from your history. Some of its work may already be done. Its request was:\n\nRespond with exactly: steering fixture initial response\n\nThe new message, which may continue or change that request:\n\nActually, respond with exactly: steering fixture observed","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}} {"type":"emit_inbound","label":"run.started:2","frame":{"type":"run.started","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","agentId":"agent-575fd75e-3fd7-412e-9b74-36f3a5aa5371"}} {"type":"emit_inbound","label":"thinking-delta","frame":{"type":"interaction.update","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","update":{"type":"thinking-delta","text":"The response will be"}}} {"type":"emit_inbound","label":"token-delta","frame":{"type":"interaction.update","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","update":{"type":"token-delta","tokens":5}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson index f7d7d8ed08c11..81b7dff010e32 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444611408}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5},"last":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6b-707e-7001-b2f4-23db74e60097","clientName":"t3_code"},"emittedAtMs":1791444611412}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16},"last":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444611661}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","status":"accepted","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3"},"emittedAtMs":1791444611674}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37},"last":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37}},"item":{"itemId":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","kind":"reminderChild","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:11.790301Z","fallbackText":"Reminder child session","childSessionId":"c13379db-6761-40b4-a974-b0f1a58717aa","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-72ac-7972-a1df-b343b7804b38"}},"emittedAtMs":1791444611795}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.1","item":{"itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","kind":"agentMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444618068}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.2","itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","field":"text","delta":"first fixture turn complete"},"emittedAtMs":1791444618068}} @@ -29,12 +29,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"idle","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16"},"emittedAtMs":1791444621046}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79},"last":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","terminal":"completed","durationMs":9378,"timeToFirstTokenMs":6406},"emittedAtMs":1791444621047}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:17","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80},"last":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444621054}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","status":"accepted","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18"},"emittedAtMs":1791444621333}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106},"last":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106}},"item":{"itemId":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","kind":"reminderChild","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:21.446966Z","fallbackText":"Reminder child session","childSessionId":"cd97261a-3ea9-480f-9e54-5e59bb077da2","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-9869-7061-a4f3-42daa3e3a8f1"}},"emittedAtMs":1791444621455}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.1","item":{"itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","kind":"agentMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444623127}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.2","itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","field":"text","delta":"second fixture turn complete"},"emittedAtMs":1791444623127}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson index a744381b64632..185cea4689875 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444728496}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5},"last":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a11a6d-39bb-7001-923e-425cc6129ce7","clientName":"t3_code"},"emittedAtMs":1791444728501}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16},"last":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444728769}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","status":"accepted","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3"},"emittedAtMs":1791444728783}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"},"emittedAtMs":1791444728783}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38},"last":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38}},"item":{"itemId":"29180105-84c4-4f9e-8bfd-003392b93b73","kind":"reminderChild","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:08.905989Z","fallbackText":"Reminder child session","childSessionId":"7c9485f9-26aa-4b56-8ca2-f36cb9cfb8c4","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-3c27-73f2-8c27-cd06a3b7dadf"}},"emittedAtMs":1791444728910}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32618,"outputTokens":315,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":227},"promptTokens":32618,"totalTokens":32933,"finishReason":"tool_calls","durationMs":2684,"cumulative":{"promptTokens":32618,"outputTokens":315,"totalTokens":32933,"cost":{"usd":0.0033248,"partial":false}}},"emittedAtMs":1791444731684}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:8","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"windowTokens":1007997,"usedTokens":32933,"pressure":"normal"},"emittedAtMs":1791444731684}} @@ -42,12 +42,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"idle","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23"},"emittedAtMs":1791444747386}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120},"last":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","terminal":"completed","durationMs":18608,"timeToFirstTokenMs":4791},"emittedAtMs":1791444747387}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:24","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121},"last":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444747394}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":5,"result":{"commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29","status":"accepted","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25"},"emittedAtMs":1791444747660}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:28","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148},"last":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148}},"item":{"itemId":"3b28955d-a87a-4acb-97e9-14cad87fd41f","kind":"reminderChild","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:27.787099Z","fallbackText":"Reminder child session","childSessionId":"efd21bf1-4fd1-49a2-9c55-91b7fc099765","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-85ed-7b91-8d91-c4ce76b6dfef"}},"emittedAtMs":1791444747794}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:29","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":33401,"outputTokens":201,"cachedTokens":33009,"cacheWriteTokens":0,"cacheReadTokens":33009,"reasoningTokens":108},"promptTokens":33401,"totalTokens":33602,"finishReason":"tool_calls","durationMs":2522,"cumulative":{"promptTokens":99045,"outputTokens":538,"totalTokens":99583,"cacheReadTokens":65506,"cost":{"usd":0.003592512,"partial":false}}},"emittedAtMs":1791444750395}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:30","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"windowTokens":1007997,"usedTokens":33602,"pressure":"normal"},"emittedAtMs":1791444750395}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson index 9d7c7e624126b..84de376486078 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758279294}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5},"last":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a12d1d-a1b8-7000-8d6c-f1324b724cdb","clientName":"t3_code"},"emittedAtMs":1791758279297}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","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."}],"displayText":"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.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","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."}],"displayText":"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.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:2","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15},"last":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758279531}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","status":"accepted","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","status":"running","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3"},"emittedAtMs":1791758279541}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo 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.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo 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.","displayText":"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.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo 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.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo 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.","displayText":"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.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37},"last":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37}},"item":{"itemId":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","kind":"reminderChild","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:37:59.660065Z","fallbackText":"Reminder child session","childSessionId":"0779d605-54b8-45dc-90b6-91dc58b9a52c","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d1d-a3d4-7712-8b98-26f3c4f9bf38"}},"emittedAtMs":1791758279666}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.1","item":{"itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","kind":"reasoning","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791758282481}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.2","itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","field":"summary.0","delta":"Spawning a subagent via muse_subagent_spawn to run a shell command and return its exact output, while avoiding direct command execution."},"emittedAtMs":1791758282481}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson index 589ac53afc7b6..28b8095f55210 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758989388}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5},"last":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d28-7788-7001-a223-c10e52187cdf","clientName":"t3_code"},"emittedAtMs":1791758989391}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:2","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15},"last":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758989619}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d28-7912-7000-993a-afd019e6ac5c","status":"accepted","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","status":"running","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3"},"emittedAtMs":1791758989632}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:6","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36},"last":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36}},"item":{"itemId":"44afb415-d6e4-4d51-9002-4deb196d463a","kind":"reminderChild","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:49:49.743418Z","fallbackText":"Reminder child session","childSessionId":"4dd3511e-2115-4fbb-a4c4-383cbb383d63","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d28-7997-79d0-8a69-8ac0acf92a56"}},"emittedAtMs":1791758989750}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:7","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32911,"outputTokens":264,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":143},"promptTokens":32911,"totalTokens":33175,"finishReason":"tool_calls","durationMs":3651,"cumulative":{"promptTokens":32911,"outputTokens":264,"totalTokens":33175,"cost":{"usd":0.0033439000000000003,"partial":false}}},"emittedAtMs":1791758993485}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:8","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"windowTokens":1007997,"usedTokens":33175,"pressure":"normal"},"emittedAtMs":1791758993485}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson index b8616cdee6a16..40039e9ee78e3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson @@ -8,13 +8,13 @@ {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","path":"~/.local/share/muse/sessions/2026/10/11/01a12d5d-405f-7000-89a3-5aa62af6ea23/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T23:47:28.587276Z","updatedAt":"2026-10-11T23:47:28.587281Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:1","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5},"last":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d5d-4060-7000-b6cd-aaabe26f80de","clientName":"t3_code"},"emittedAtMs":1791762448807}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6},"last":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762448807}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:3","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16},"last":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762449097}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","status":"accepted","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","status":"running","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4"},"emittedAtMs":1791762449102}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38},"last":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38}},"item":{"itemId":"95574bfe-853f-44cc-81f6-88b05d6c8c16","kind":"reminderChild","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","recordedAt":"2026-10-11T23:47:29.328939Z","fallbackText":"Reminder child session","childSessionId":"b16dbb52-7f62-4a5a-84c2-f68d7ea22b18","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d5d-4375-7d72-b5b2-4b096d0df507"}},"emittedAtMs":1791762449336}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.1","item":{"itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","kind":"reasoning","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791762451554}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.2","itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","field":"summary.0","delta":"Launching a two-agent ping-pong workflow via the workflow tool while skipping skill reading for a trivial task."},"emittedAtMs":1791762451554}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson index 1dc9f52719343..24a06be8595ff 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444565334}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5},"last":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6a-bbb6-7000-b1a4-a01ea59676ef","clientName":"t3_code"},"emittedAtMs":1791444565340}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16},"last":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444565612}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","status":"accepted","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","status":"running","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3"},"emittedAtMs":1791444565635}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37},"last":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37}},"item":{"itemId":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","kind":"reminderChild","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:29:25.781511Z","fallbackText":"Reminder child session","childSessionId":"9785bf9d-382d-4781-ab3b-3817c9e6a604","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6a-bef1-7032-974a-059b8765c487"}},"emittedAtMs":1791444565789}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.1","item":{"itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","kind":"agentMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444567707}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.2","itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","field":"text","delta":"fixture simple ok"},"emittedAtMs":1791444567707}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson index aac7fd36214a7..a75f744d01473 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791757874894}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5},"last":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d17-75fa-7001-a940-79b5d7c648a4","clientName":"t3_code"},"emittedAtMs":1791757874898}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","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."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"type":"text","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."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:2","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15},"last":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791757875120}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d17-7790-7000-967d-3f2246f38a60","status":"accepted","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","status":"running","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3"},"emittedAtMs":1791757875131}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:6","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36},"last":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36}},"item":{"itemId":"599a220a-d212-4a81-a60d-7284ff290284","kind":"reminderChild","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:31:15.249949Z","fallbackText":"Reminder child session","childSessionId":"0b3f4f01-850a-43bd-af36-9b011699354f","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d17-7819-7212-8057-3558c2bdd46d"}},"emittedAtMs":1791757875258}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:7","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32900,"outputTokens":129,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":36},"promptTokens":32900,"totalTokens":33029,"finishReason":"tool_calls","durationMs":2354,"cumulative":{"promptTokens":32900,"outputTokens":129,"totalTokens":33029,"cost":{"usd":0.0033158000000000003,"partial":false}}},"emittedAtMs":1791757877764}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:8","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"windowTokens":1007997,"usedTokens":33029,"pressure":"normal"},"emittedAtMs":1791757877764}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/claude_transcript.ndjson index cd24f67e98caa..dc9335bf5a553 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/claude_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/claude_transcript.ndjson @@ -8,7 +8,7 @@ {"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"84451d3f-e7ba-4d80-aea3-4fbc442f4b3b","session_id":"8d0dafab-45d3-45eb-abef-41b9e55d5ff9"}} {"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2066,"duration_api_ms":1815,"num_turns":1,"result":"source fork seed ok","stop_reason":"end_turn","session_id":"8d0dafab-45d3-45eb-abef-41b9e55d5ff9","total_cost_usd":0.01172175,"usage":{"input_tokens":3,"cache_creation_input_tokens":2339,"cache_read_input_tokens":9455,"output_tokens":7,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":2339,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[{"input_tokens":3,"output_tokens":7,"cache_read_input_tokens":9455,"cache_creation_input_tokens":2339,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2339},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":7,"cacheReadInputTokens":9455,"cacheCreationInputTokens":2339,"webSearchRequests":0,"costUSD":0.01172175,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"892cdc1f-e2d5-440d-ae57-ca27d32f63f1"}} {"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"8d0dafab-45d3-45eb-abef-41b9e55d5ff9","options":{"dir":"/tmp/claude-replay-thread_fork_native","upToMessageId":"68baa075-b7d4-4689-866b-96bd51e570c3"}}} +{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"8d0dafab-45d3-45eb-abef-41b9e55d5ff9","options":{"upToMessageId":"68baa075-b7d4-4689-866b-96bd51e570c3"}}} {"type":"emit_inbound","label":"session.forked","frame":{"type":"session.forked","sessionId":"7e7ad6ec-db4a-4510-85ff-59df35cf7fad"}} {"type":"expect_outbound","label":"query.open:fork","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"7e7ad6ec-db4a-4510-85ff-59df35cf7fad","settings":{"showThinkingSummaries":true}}}} {"type":"expect_outbound","label":"prompt.offer:2","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Respond with the following text: fork native ok"},"parent_tool_use_id":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson index 317d51456cb75..b08e908042b9a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson @@ -8,7 +8,7 @@ {"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1779846000,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"94e56646-7610-4a7a-8245-9301469861ae","session_id":"273fe17b-65fb-451f-ad99-ff19d323852d"}} {"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2928,"duration_api_ms":1977,"num_turns":1,"result":"fork local source alpha","stop_reason":"end_turn","session_id":"273fe17b-65fb-451f-ad99-ff19d323852d","total_cost_usd":0.0036539999999999997,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":11800,"output_tokens":7,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[{"input_tokens":3,"output_tokens":7,"cache_read_input_tokens":11800,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":7,"cacheReadInputTokens":11800,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0036539999999999997,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"684990a5-0d00-4928-a332-fdc2c66769c6"}} {"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"273fe17b-65fb-451f-ad99-ff19d323852d","options":{"dir":"/tmp/claude-replay-thread_fork_native_fork_local_rollback","upToMessageId":"7659a146-b729-47b6-aac2-8fc0c0981e92"}}} +{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"273fe17b-65fb-451f-ad99-ff19d323852d","options":{"upToMessageId":"7659a146-b729-47b6-aac2-8fc0c0981e92"}}} {"type":"emit_inbound","label":"session.forked","frame":{"type":"session.forked","sessionId":"c9784d72-7e4f-4994-8c21-2e7e670ff91c"}} {"type":"expect_outbound","label":"query.open:fork","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"c9784d72-7e4f-4994-8c21-2e7e670ff91c","settings":{"showThinkingSummaries":true}}}} {"type":"expect_outbound","label":"prompt.offer:fork-first","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"For this fork-local rollback fixture, respond with exactly: fork local first"},"parent_tool_use_id":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson index f65c071d7bf3a..e195e8db004bc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson @@ -12,7 +12,7 @@ {"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_0181K4CdzyVanM6Px26CQP7L","type":"message","role":"assistant","content":[{"type":"text","text":"fork boundary beta"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":23,"cache_read_input_tokens":11797,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"a91391e1-0de3-4107-a7c9-db7b1bbd8b99","uuid":"c517b74b-9537-4260-afcb-186c4056c0a4"}} {"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2545,"duration_api_ms":3001,"num_turns":1,"result":"fork boundary beta","stop_reason":"end_turn","session_id":"a91391e1-0de3-4107-a7c9-db7b1bbd8b99","total_cost_usd":0.01544235,"usage":{"input_tokens":3,"cache_creation_input_tokens":23,"cache_read_input_tokens":11797,"output_tokens":6,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":23,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[{"input_tokens":3,"output_tokens":6,"cache_read_input_tokens":11797,"cache_creation_input_tokens":23,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":23},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":6,"outputTokens":12,"cacheReadInputTokens":21252,"cacheCreationInputTokens":2365,"webSearchRequests":0,"costUSD":0.01544235,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"e4f26af4-3a60-45f3-8d5c-c0f8b968c400"}} {"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"a91391e1-0de3-4107-a7c9-db7b1bbd8b99","options":{"dir":"/tmp/claude-replay-thread_fork_native_prior_turn","upToMessageId":"be0e3276-8c27-44bf-8ed0-0b4424e9f902"}}} +{"type":"expect_outbound","label":"session.fork","frame":{"type":"session.fork","sessionId":"a91391e1-0de3-4107-a7c9-db7b1bbd8b99","options":{"upToMessageId":"be0e3276-8c27-44bf-8ed0-0b4424e9f902"}}} {"type":"emit_inbound","label":"session.forked","frame":{"type":"session.forked","sessionId":"f5cc58dd-0d74-4950-9fb5-2a9fded47ddd"}} {"type":"expect_outbound","label":"query.open:fork","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"f5cc58dd-0d74-4950-9fb5-2a9fded47ddd","settings":{"showThinkingSummaries":true}}}} {"type":"expect_outbound","label":"prompt.offer:3","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."},"parent_tool_use_id":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/claude_transcript.ndjson index 37ada38b28b58..00db6482c3070 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/claude_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/claude_transcript.ndjson @@ -8,7 +8,7 @@ {"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"04dac113-90a6-48fa-ab30-d02d34e76f76","session_id":"093127f0-d3a7-42f8-938e-b1162927a0ea"}} {"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2354,"duration_api_ms":1892,"ttft_ms":1845,"num_turns":1,"result":"merge sibling source stored","stop_reason":"end_turn","session_id":"093127f0-d3a7-42f8-938e-b1162927a0ea","total_cost_usd":0.005823599999999999,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":18982,"output_tokens":8,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":8,"cache_read_input_tokens":18982,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":8,"cacheReadInputTokens":18982,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.005823599999999999,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"148a66d3-6af8-42ec-95d9-51106ba6b80c"}} {"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"session.fork:1","frame":{"type":"session.fork","sessionId":"093127f0-d3a7-42f8-938e-b1162927a0ea","options":{"dir":"/tmp/claude-replay-thread_merge_back_siblings","upToMessageId":"1fc21da3-d746-4ed9-9a01-47a5bced6ad7"}}} +{"type":"expect_outbound","label":"session.fork:1","frame":{"type":"session.fork","sessionId":"093127f0-d3a7-42f8-938e-b1162927a0ea","options":{"upToMessageId":"1fc21da3-d746-4ed9-9a01-47a5bced6ad7"}}} {"type":"emit_inbound","label":"session.forked:1","frame":{"type":"session.forked","sessionId":"aeec447f-87e5-4874-8fe7-7f937f7c3311"}} {"type":"expect_outbound","label":"query.open:fork:1","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"aeec447f-87e5-4874-8fe7-7f937f7c3311","settings":{"showThinkingSummaries":true}}}} {"type":"expect_outbound","label":"prompt.offer:2","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Remember the fork-local marker merge-sibling-first-6V2J. Respond with exactly: first merge sibling stored"},"parent_tool_use_id":null}}} @@ -19,7 +19,7 @@ {"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"3b7a92ea-d3b5-47b8-94df-b214af9ccd75","session_id":"aeec447f-87e5-4874-8fe7-7f937f7c3311"}} {"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2344,"duration_api_ms":1855,"ttft_ms":1992,"num_turns":1,"result":"first merge sibling stored","stop_reason":"end_turn","session_id":"aeec447f-87e5-4874-8fe7-7f937f7c3311","total_cost_usd":0.005865,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":19120,"output_tokens":8,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":3,"output_tokens":8,"cache_read_input_tokens":19120,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":3,"outputTokens":8,"cacheReadInputTokens":19120,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.005865,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"64da0416-b03b-4e9e-9ae9-9c85eb8ac0ef"}} {"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"session.fork:2","frame":{"type":"session.fork","sessionId":"093127f0-d3a7-42f8-938e-b1162927a0ea","options":{"dir":"/tmp/claude-replay-thread_merge_back_siblings","upToMessageId":"1fc21da3-d746-4ed9-9a01-47a5bced6ad7"}}} +{"type":"expect_outbound","label":"session.fork:2","frame":{"type":"session.fork","sessionId":"093127f0-d3a7-42f8-938e-b1162927a0ea","options":{"upToMessageId":"1fc21da3-d746-4ed9-9a01-47a5bced6ad7"}}} {"type":"emit_inbound","label":"session.forked:2","frame":{"type":"session.forked","sessionId":"f85cf945-d406-4573-b2ae-0e799d9a9365"}} {"type":"expect_outbound","label":"query.open:fork:2","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"resume":"f85cf945-d406-4573-b2ae-0e799d9a9365","settings":{"showThinkingSummaries":true}}}} {"type":"expect_outbound","label":"prompt.offer:3","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Remember the fork-local marker merge-sibling-second-9X5B. Respond with exactly: second merge sibling stored"},"parent_tool_use_id":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson index 7557908979c6a..49236a5938f3c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson @@ -7,14 +7,14 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444725265}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5},"last":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6d-2d3e-7000-b0e7-f04c53ee070b","clientName":"t3_code"},"emittedAtMs":1791444725268}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"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."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\n"},{"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."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16},"last":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444725509}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","startedNewTurn":true,"disposition":"started"}}} {"type":"expect_outbound","label":"turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"method":"turn/interrupt","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","status":"running","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3"},"emittedAtMs":1791444725522}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link any other file you produce using its absolute path so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images, video, and audio in your response using Markdown image syntax with absolute file paths. Link every other file you cite or produce by its absolute path, as in [http.ts:42](/abs/path/http.ts:42), so the user can open it in T3 Code's files viewer.\n\n\nLink each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:05.638142Z","fallbackText":"Reminder child session","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725646}} {"type":"emit_inbound","label":"response:turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"3962d8d1-4608-4dda-8fdc-39a1804474aa","sequence":50}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"cancelled","recordedAt":"2026-10-08T07:32:05.727236Z","fallbackText":"Reminder child session","failureReason":"main run cancelled","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725734}} diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 8bc794ce36c4d..37b653c451fce 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -183,7 +183,7 @@ function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean { ); } -const DEFERRED_WORK_ITEM_TYPES = [ +export const DEFERRED_WORK_ITEM_TYPES = [ "command_execution", "reasoning", "file_change", diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index 35be1fa31ddbd..2e26e462d95a3 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -478,14 +478,14 @@ it.live("the agent asks without waiting, and the user links the box after its tu // The call returns at once: no tool call outlives a provider's timeout. expect(asked.isError).toBe(false); expect(asked.structuredContent).toEqual({ status: "pending", turnItemId: card.id }); - // The card names the box, found by asking its address. expect(card).toMatchObject({ url: b.url, reason: "Run the suite on the box.", - environmentId: box.environmentId, - label: "Box", requestedAccess: "approval-required", }); + // Anything can answer at an address as the box, so the card names no machine. + expect(card.environmentId).toBeUndefined(); + expect(card.label).toBeUndefined(); // The agent waits on the user, so the thread asks for input. expect((yield* a.orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest).toMatchObject( { kind: "user_input" }, @@ -684,7 +684,7 @@ it.live("a declined request links nothing and tells the agent", () => const { messages } = yield* outcomeMessages(a); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ - notification: { outcome: "cancelled", summary: "Declined to link Box" }, + notification: { outcome: "cancelled", summary: `Declined to link ${b.url}` }, }); expect(messages[0]!.text).toContain("declined"); }), @@ -913,6 +913,45 @@ it.live("a code the box refuses fails the request with its reason, and links not ), ); +it.live("a card an older server left naming a machine beside its address links no machine", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* a.orchestrator.dispatch({ + type: "link_request.record", + commandId: CommandId.make("command:pending:older"), + threadId, + runId, + nodeId: rootNode, + turnItemId: TurnItemId.make("turn-item:link-request:older"), + url: b.url, + environmentId: box.environmentId, + label: "Box", + reason: "", + linkStatus: "pending", + }); + const card = yield* pendingCard(a); + // An older client takes the machine from the card and mints a code on it. + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.requests.answer({ + threadId, + turnItemId: card.id, + answer: { + type: "link", + environmentId: box.environmentId, + label: "Box", + urls: [b.url], + access: "auto", + pairingCode: pairing.credential, + }, + }); + expect(yield* cardOf(a)).toMatchObject({ linkStatus: "failed" }); + expect(Option.isNone(yield* a.links.get(box.environmentId))).toBe(true); + expect(yield* b.linkedSessions).toEqual([]); + }), + ), +); + it.live("an agent below full access may not ask, and no card is shown", () => withBoth((a, b) => Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 390589a81b4e4..3d1129a599f84 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -39,7 +39,7 @@ type LinkRequestItem = Extract & { readonly runId: RunId; readonly nodeId: NodeId }; /** The notification that tells the agent how its card ended. */ @@ -129,7 +129,6 @@ const make = Effect.gen(function* () { ...(card.url === undefined ? {} : { url: card.url }), reason: card.reason, ...(card.environmentId === undefined ? {} : { environmentId: card.environmentId }), - ...(card.label === undefined ? {} : { label: card.label }), ...(card.hint === undefined ? {} : { hint: card.hint }), ...(card.requestedAccess === undefined ? {} : { requestedAccess: card.requestedAccess }), ...outcome, @@ -252,9 +251,8 @@ const make = Effect.gen(function* () { "Asking to link an environment requires an active run owned by this MCP provider session.", }); } - // Best effort: the card names the machine at an address the agent was - // given, and the client finds it by id. - const peer = input.url === undefined ? Option.none() : yield* peerLinks.describe(input.url); + // Anything can answer at an address as any machine, so a card for an + // address names none. The client names it from addresses it already has. yield* record( { id: turnItemId, @@ -263,11 +261,7 @@ const make = Effect.gen(function* () { nodeId: run.rootNodeId, ...(input.url === undefined ? {} : { url: input.url }), reason: input.reason ?? "", - ...(Option.isSome(peer) - ? { environmentId: peer.value.environmentId, label: peer.value.label } - : input.environmentId === undefined - ? {} - : { environmentId: input.environmentId }), + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), ...(input.hint === undefined ? {} : { hint: input.hint }), ...(input.requestedAccess === undefined ? {} @@ -357,7 +351,18 @@ const make = Effect.gen(function* () { if (card.linkStatus !== "pending") { return yield* new PeerLinkRequestError({ reason: "already_answered" }); } - const outcome = yield* answerOutcome(input.answer); + // An id beside an address is what answered there, recorded by an older + // server. Linking that machine could send its pairing code to the address. + const outcome: LinkRequestOutcome = + card.url !== undefined && + card.environmentId !== undefined && + input.answer.type === "link" && + input.answer.environmentId !== undefined + ? { + linkStatus: "failed", + failure: "This request is from before an update. Ask for the link again.", + } + : yield* answerOutcome(input.answer); // If Stop closed the card while linking, the card keeps that; the link // itself stands and Settings lists it. yield* record( @@ -416,16 +421,16 @@ const resultOf = (card: LinkRequestItem): OrchestratorMcpEnvironmentLinkResult = } }; -/** What the agent's words call the target: its name, its address, or the user's pick. */ -const linkTarget = (card: Pick) => - card.label ?? card.url ?? card.environmentId ?? "a machine"; +/** What the agent's words call the target: its address, its id, or the user's pick. */ +const linkTarget = (card: Pick) => + card.url ?? card.environmentId ?? "a machine"; /** * The notification an answer sends the agent: what the card says, and what * to do next. It carries the outcome only, never the pairing code. */ const outcomeNotice = ( - card: Pick, + card: Pick, outcome: LinkRequestOutcome, ): Omit => { const target = linkTarget(card); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 5198fc443f768..ecd4b9ac86ca7 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -440,6 +440,38 @@ it.effect("cancelling a descriptor probe aborts its request and leaves later res ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.live("one dropped descriptor request does not list a live peer as unreachable", () => + Effect.scoped( + Effect.gen(function* () { + const dropNext = yield* Ref.make(false); + const dropOnce = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if ( + request.url.startsWith("/.well-known/t3/environment") && + (yield* Ref.getAndSet(dropNext, false)) + ) { + return HttpServerResponse.empty({ status: 503 }); + } + return yield* httpEffect; + }), + { global: true }, + ); + const b = yield* servePeer( + box, + Layer.merge(McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers), dropOnce), + ); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + + yield* Ref.set(dropNext, true); + expect((yield* a.links.list).map((link) => link.status)).toEqual(["reachable"]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("reads one stored link without asking the peer", () => Effect.scoped( Effect.gen(function* () { @@ -603,10 +635,35 @@ it.effect( expectedEnvironmentId: box.environmentId, }); expect(linked).toMatchObject({ environmentId: box.environmentId, urls: [b.url] }); + // A picked machine is never linked at this machine's own loopback. + const own = yield* a.links + .link({ + url: b.url.replace("127.0.0.1", "localhost"), + pairingCode: pairing.credential, + access: "auto", + expectedEnvironmentId: box.environmentId, + }) + .pipe(Effect.flip); + expect(own.message).toContain("is this machine"); yield* probe(a); expect(yield* linkedSessions(impostor)).toEqual([]); expect(yield* Ref.get(impostor.bearers)).toEqual([]); expect((yield* linkedSessions(b)).length).toBe(1); + + // An address alone that answers as the linked box never takes over its link. + yield* Ref.set(impostor.descriptor, box); + const second = yield* impostor.auth.issuePairingCredential(); + const taken = yield* a.links + .link({ url: impostor.url, pairingCode: second.credential, access: "auto" }) + .pipe(Effect.flip); + expect(taken.message).toContain("already linked"); + expect(yield* linkedSessions(impostor)).toEqual([]); + expect(Option.getOrUndefined(yield* a.links.get(box.environmentId))?.urls).toEqual([b.url]); + // The address the link already has still renews it. + const renewal = yield* b.auth.issuePairingCredential(); + expect( + yield* a.links.link({ url: b.url, pairingCode: renewal.credential, access: "auto" }), + ).toMatchObject({ urls: [b.url] }); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 9fa7b74d340c6..7ba3d144ef104 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -184,6 +184,8 @@ export const layerLinkingEnvironment = ( Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), Layer.provideMerge(database), Layer.provide(Layer.mergeAll(secrets, FetchHttpClient.layer)), + // Peers here are served on 127.0.0.1, so only `localhost` stands for this machine. + Layer.provide(Layer.succeed(PeerLinks.LoopbackHost, (host) => host === "localhost")), // Services that act for this environment, such as delegation, name it too. Layer.provideMerge( Layer.succeed(ServerEnvironment.ServerEnvironment, { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index f52faecfcc3e2..b05cf0a0afa5c 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -26,6 +26,7 @@ import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; @@ -92,15 +93,6 @@ export class PeerLinks extends Context.Service< /** Whether the Linked environments beta (Settings → Labs) is on here. */ readonly enabled: Effect.Effect; readonly link: (input: PeerLinkCreateInput) => Effect.Effect; - /** - * The environment answering at `url`, or nothing when it is not a usable - * address or does not answer as T3 Code. Sends no credential. - */ - readonly describe: ( - url: string, - ) => Effect.Effect< - Option.Option<{ readonly environmentId: EnvironmentId; readonly label: string }> - >; readonly list: Effect.Effect, PeerLinkError>; /** The stored link, without asking the peer whether it answers. */ readonly get: ( @@ -182,6 +174,12 @@ const peerOrigin = (url: string) => { return Effect.succeed(parsed.origin); }; +/** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */ +export const LoopbackHost = Context.Reference<(host: string) => boolean>( + "t3/peer/PeerLinks/LoopbackHost", + { defaultValue: () => isLocalLoopbackHost }, +); + /** Whether a peer holds a linked agent to its limits and keeps its work within its link. */ const linkLimitsHold = (peer: { readonly capabilities: ExecutionEnvironmentCapabilities }) => peer.capabilities.mcpModeLimitHeader === true && peer.capabilities.linkFence === true; @@ -194,6 +192,7 @@ const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const environment = yield* ServerEnvironment.ServerEnvironment; const serverSettings = yield* ServerSettings.ServerSettingsService; + const isOwnLoopback = yield* LoopbackHost; const enabled = serverSettings.getSettings.pipe( Effect.map((settings) => settings.linkedEnvironmentsEnabled), Effect.orElseSucceed(() => false), @@ -224,6 +223,12 @@ const make = Effect.gen(function* () { const describe = (origin: string) => clientFor(origin).pipe( Effect.flatMap((client) => client.metadata.descriptor()), + // A brief network drop must not list a live peer as down; the timeout still bounds both tries. + Effect.retry({ + times: 1, + schedule: Schedule.spaced("1 second"), + while: (error) => certificateErrorCode(error) === undefined, + }), Effect.timeout(probeTimeout(origin)), Effect.asSome, Effect.catch((error) => { @@ -409,6 +414,13 @@ const make = Effect.gen(function* () { (url) => Effect.gen(function* () { const origin = yield* peerOrigin(url); + // Loopback is this machine, where anything may answer as the one picked. + if (isOwnLoopback(new URL(origin).hostname)) { + return yield* new PeerLinkError({ + reason: "unreachable", + message: `${origin} is this machine, not ${name}.`, + }); + } return { origin, descriptor: yield* describe(origin) }; }).pipe(Effect.result), { concurrency: "unbounded" }, @@ -457,8 +469,8 @@ const make = Effect.gen(function* () { return yield* new PeerLinkError({ reason: "disabled", message: LINKED_ENVIRONMENTS_OFF }); } const urls = [input.url, ...(input.alternateUrls ?? [])]; - // The user picked a machine: link through the addresses that prove to - // be it, so a reused LAN address never receives the pairing code. + // The user picked a machine: link through the addresses that answer as + // it, so one that now answers as another never receives the pairing code. const { primary, peer, origins } = input.expectedEnvironmentId === undefined ? yield* answeringAt(urls) @@ -467,6 +479,22 @@ const make = Effect.gen(function* () { input.expectedEnvironmentId, input.expectedLabel ?? input.expectedEnvironmentId, ); + // Anything can answer as a linked machine, so an address alone renews its + // link only where the link already reaches it, and never moves it. + if (input.expectedEnvironmentId === undefined) { + const row = yield* readRow(peer.environmentId); + const existing = Option.isSome(row) ? yield* toLink(row.value) : undefined; + if ( + existing !== undefined && + DateTime.isGreaterThan(existing.expiresAt, yield* DateTime.now) && + !existing.urls.includes(primary) + ) { + return yield* new PeerLinkError({ + reason: "pairing_rejected", + message: `${primary} answers as ${existing.label}, which is already linked at another address. If ${existing.label} moved, forget that link first.`, + }); + } + } const self = yield* environment.getDescriptor; if (peer.environmentId === self.environmentId) { return yield* new PeerLinkError({ @@ -532,13 +560,6 @@ const make = Effect.gen(function* () { return linked; }); - const describeUrl: PeerLinks["Service"]["describe"] = (url) => - peerOrigin(url).pipe( - Effect.flatMap(describe), - Effect.map(Option.map((peer) => ({ environmentId: peer.environmentId, label: peer.label }))), - Effect.orElseSucceed(() => Option.none()), - ); - /** A peer's label follows its machine name, so the link takes the one its own address answers with. */ const withLabel = (link: PeerLink, label: string) => label === link.label @@ -621,8 +642,8 @@ const make = Effect.gen(function* () { message: `The link to ${link.label} lost its credential. Link it again.`, }); } - // The token goes only to an address that proves it is this peer, so a - // reused LAN address or a moved hostname never receives it. + // The token goes only to an address that still answers as this peer, + // so one that now answers as another machine never receives it. let fenced = true; let label = link.label; const url = yield* Effect.findFirst(link.urls, (candidate) => @@ -678,7 +699,6 @@ const make = Effect.gen(function* () { return PeerLinks.of({ enabled, link, - describe: describeUrl, list, get, unlink, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7d8033332460b..b8e233b6754b5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -83,6 +83,7 @@ import Migration0077 from "./Migrations/077_RepairBooleanSettledOverride.ts"; import Migration0078 from "./Migrations/078_DropOrchestrationEventCorrelationIndex.ts"; import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts"; import Migration0080 from "./Migrations/080_ScheduledTaskThreadMode.ts"; +import Migration0081 from "./Migrations/081_ProjectionMessageSearchText.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -166,6 +167,7 @@ const migrationEntries = [ [78, "DropOrchestrationEventCorrelationIndex", Migration0078], [79, "DelegatedRecoveryIndexes", Migration0079], [80, "ScheduledTaskThreadMode", Migration0080], + [81, "ProjectionMessageSearchText", Migration0081], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -214,6 +216,25 @@ const assertV2MigrationsCanRun = Effect.fn("assertV2MigrationsCanRun")(function* }); }); +const latestMigrationId = Math.max(...migrationEntries.map(([id]) => id)); + +// Mirrors the Migrator, which runs only ids above the highest recorded, plus the V2-preview reconciliation. +export const hasPendingMigrations = Effect.fn("hasPendingMigrations")(function* () { + const sql = yield* SqlClient.SqlClient; + const tables = yield* sql` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations' + `; + if (tables.length === 0) return true; + const rows = yield* sql<{ readonly latestId: number | null; readonly previewRows: number }>` + SELECT + MAX(migration_id) AS latestId, + SUM(name = 'OrchestrationV2' AND migration_id IN (53, 54)) AS previewRows + FROM effect_sql_migrations + `; + const row = rows[0]; + return (row?.latestId ?? 0) < latestMigrationId || (row?.previewRows ?? 0) > 0; +}); + const run = Migrator.make({}); export interface RunMigrationsOptions { diff --git a/apps/server/src/persistence/Migrations/081_ProjectionMessageSearchText.test.ts b/apps/server/src/persistence/Migrations/081_ProjectionMessageSearchText.test.ts new file mode 100644 index 0000000000000..84ca7f9de23a4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/081_ProjectionMessageSearchText.test.ts @@ -0,0 +1,96 @@ +import { assert, it } from "@effect/vitest"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +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 createdAt = "2026-10-01T00:00:00.000Z"; +const quote = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment:local"), + threadId: ThreadId.make("thread:quoted"), + messageId: MessageId.make("quoted-answer"), + text: "A lighthouse guides ships safely through the darkness.", + comment: "what are you talking about", + start: 0, + end: 54, + prefix: "beacon01", + suffix: "harbor", +}); +const readable = + "A lighthouse guides ships safely through the darkness.\nComment: what are you talking about"; +const indexed = (sql: SqlClient.SqlClient, phrase: string) => + sql<{ readonly message_id: string }>` + SELECT messages.message_id + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages + ON messages.rowid = search.rowid + WHERE orchestration_v2_message_search MATCH ${phrase} + `.pipe(Effect.map((rows) => rows.map((row) => row.message_id))); + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))( + "081_ProjectionMessageSearchText", + (it) => { + it.effect("backfills readable text only for messages whose citations read differently", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 80 }); + const insertMessage = (messageId: string, text: unknown) => sql` + INSERT INTO orchestration_v2_projection_messages ( + message_id, thread_id, role, streaming, created_at, updated_at, payload_json + ) VALUES ( + ${messageId}, 'thread:quote', 'user', 0, ${createdAt}, ${createdAt}, + ${JSON.stringify({ text })} + ) + `; + yield* insertMessage("quoted", `${quote} quiet silver lantern`); + yield* insertMessage("plain", "quiet silver lantern"); + yield* insertMessage("invalid", "[Assistant quote](t3-citation://v1/not-a-citation)"); + yield* insertMessage("unreadable", 20261001); + yield* insertMessage( + "accented", + serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment:local"), + threadId: ThreadId.make("thread:quoted"), + messageId: MessageId.make("quoted-answer"), + text: "The kittiwake's résumé", + start: 0, + end: 22, + prefix: "", + suffix: "", + }), + ); + yield* Effect.forEach( + Array.from({ length: 501 }, (_, index) => `page-${String(index).padStart(3, "0")}`), + (messageId) => insertMessage(messageId, quote), + { discard: true }, + ); + + assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 81 }), [ + [81, "ProjectionMessageSearchText"], + ]); + const rows = yield* sql<{ + readonly message_id: string; + readonly search_text: string | null; + }>`SELECT message_id, search_text FROM orchestration_v2_projection_messages`; + const searchText = new Map(rows.map((row) => [row.message_id, row.search_text])); + assert.equal(searchText.get("quoted"), `${readable} quiet silver lantern`); + assert.isNull(searchText.get("plain")); + assert.isNull(searchText.get("invalid")); + assert.isNull(searchText.get("unreadable")); + assert.equal(searchText.get("page-000"), readable); + assert.equal(searchText.get("page-500"), readable); + + // Messages indexed as stored are indexed again as they read. + assert.deepEqual(yield* indexed(sql, '"résumé"'), ["accented"]); + assert.deepEqual(yield* indexed(sql, `"kittiwake's"`), ["accented"]); + assert.deepEqual(yield* indexed(sql, '"beacon01"'), []); + assert.lengthOf(yield* indexed(sql, '"what are you talking about"'), 502); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/081_ProjectionMessageSearchText.ts b/apps/server/src/persistence/Migrations/081_ProjectionMessageSearchText.ts new file mode 100644 index 0000000000000..4bbcec4d61822 --- /dev/null +++ b/apps/server/src/persistence/Migrations/081_ProjectionMessageSearchText.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + readableSearchText, + recreateMessageSearchTriggers, +} from "../../orchestration-v2/messageSearchIndex.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE orchestration_v2_projection_messages ADD COLUMN search_text TEXT`; + + // Migration 74's triggers index the stored text, where a quote's words are + // percent-encoded. Recreated now that search_text exists, they index it, and + // the backfill below re-indexes every message that reads differently. + yield* recreateMessageSearchTriggers(sql); + + // Only messages holding a citation link read differently. The raw check + // first skips parsing the JSON of every other message. + const pageSize = 500; + let afterMessageId = ""; + while (true) { + const rows = yield* sql<{ readonly message_id: string; readonly text: string }>` + SELECT message_id, json_extract(payload_json, '$.text') AS text + FROM orchestration_v2_projection_messages + WHERE message_id > ${afterMessageId} + AND payload_json LIKE '%t3-citation://%' + AND json_type(payload_json, '$.text') = 'text' + ORDER BY message_id + LIMIT ${pageSize} + `; + for (const row of rows) { + const searchText = readableSearchText(row.text); + if (searchText === null) continue; + yield* sql` + UPDATE orchestration_v2_projection_messages + SET search_text = ${searchText} + WHERE message_id = ${row.message_id} + `; + } + if (rows.length < pageSize) break; + afterMessageId = rows.at(-1)!.message_id; + } +}); diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index a2d29f12f1810..411efde3a87f9 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -5,14 +5,21 @@ import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; import type * as NodeWorkerThreads from "node:worker_threads"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; 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 { vi } from "vite-plus/test"; +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerOwnership from "../serverOwnership.ts"; +import { migrationManifest, runMigrations } from "./Migrations.ts"; import * as SqlitePersistence from "./Sqlite.ts"; const workers = vi.hoisted((): Array => []); @@ -183,3 +190,65 @@ it.effect("drops planner statistics a database already has", () => { 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. + const home = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + yield* ServerOwnership.acquireServerOwnership(serverRuntimeStatePath).pipe( + Effect.provide(ProcessRunner.layer), + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Environment, {}), + ); +}); + +const migrateTo = (toMigrationInclusive?: number) => + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig.ServerConfig; + yield* runMigrations({ toMigrationInclusive }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: dbPath })), + ); + }); + +const latestRecordedMigrationId = Effect.gen(function* () { + const { dbPath } = yield* ServerConfig.ServerConfig; + const rows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly id: number }>` + SELECT MAX(migration_id) AS id FROM effect_sql_migrations + `; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath }))); + return rows[0]?.id; +}); + +const layerT3Home = ServerConfig.layerTest(process.cwd(), { prefix: "t3-sqlite-cli-" }); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("layerCliConfig", (it) => { + it.effect("does not migrate a T3 home a running server owns", () => + Effect.gen(function* () { + const recorded = Math.max(...migrationManifest.map(([id]) => id)) - 1; + yield* migrateTo(recorded); + yield* ownT3Home(); + + const opened = yield* Effect.void.pipe( + Effect.provide(SqlitePersistence.layerCliConfig), + Effect.result, + ); + + assert.equal(yield* latestRecordedMigrationId, recorded); + assert.equal(opened._tag, "Failure"); + if (opened._tag === "Failure") { + assert.instanceOf(opened.failure, SqlitePersistence.ServerOwnsDatabaseError); + } + }).pipe(Effect.scoped, Effect.provide(layerT3Home)), + ); + + it.effect("opens a current T3 home while a running server owns it", () => + Effect.gen(function* () { + yield* migrateTo(); + yield* ownT3Home(); + + yield* Effect.void.pipe(Effect.provide(SqlitePersistence.layerCliConfig)); + }).pipe(Effect.scoped, Effect.provide(layerT3Home)), + ); +}); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 1eae68dd0e3d3..839b9ce9d45d7 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -2,10 +2,11 @@ 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 Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import { runMigrations } from "./Migrations.ts"; +import { hasPendingMigrations, runMigrations } from "./Migrations.ts"; import { initializeV2Database } from "./initializeV2Database.ts"; import { ensureThreadTabsSchema } from "../threadTabs/schema.ts"; import { ensureLinearThreadLinksSchema } from "../linear/threadLinksSchema.ts"; @@ -19,84 +20,124 @@ import { ensureThreadLinkWatchesSchema } from "../threadLinkWatch/threadLinkWatc import { ensureDiscordThreadLinksSchema } from "../discord/threadLinksSchema.ts"; import { ensureMessageDraftsSchema } from "../messageDrafts/schema.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerOwnership from "../serverOwnership.ts"; const WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024; -const layerSetup = Layer.effectDiscard( - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. - yield* sql`PRAGMA busy_timeout = 5000;`; - yield* sql`PRAGMA foreign_keys = ON;`; - // Existing databases need doctor's offline VACUUM to add pointer maps for this mode. - const tables = yield* sql<{ - readonly count: number; - }>`SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table'`; - if (tables[0]?.count === 0) yield* sql`PRAGMA auto_vacuum = INCREMENTAL;`; - yield* sql`PRAGMA journal_mode = WAL;`; - // FULL fsyncs every event commit on the main thread; NORMAL syncs only at WAL checkpoints. - // App crashes lose nothing; OS crashes or power loss can roll back post-checkpoint commits without corruption. - yield* sql`PRAGMA synchronous = NORMAL;`; - // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. - yield* sql`PRAGMA checkpoint_fullfsync = ON;`; - // Keep statement journals and sort spills off the main thread's disk path. - // Large statements and open savepoints can increase peak memory use. - yield* sql`PRAGMA temp_store = MEMORY;`; - // PASSIVE checkpoints retain peak WAL size until the last connection closes. - yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); - yield* runMigrations(); - // Fork tables live outside the upstream ledger; thread tabs creates fork_schema_migrations. - yield* ensureThreadTabsSchema(); - yield* ensureLinearThreadLinksSchema(); - yield* ensureLinearAssignmentTriggerSchema(); - yield* ensureGitHubIssueThreadLinksSchema(); - yield* ensureSlackThreadLinksSchema(); - yield* ensureDiscordThreadLinksSchema(); - yield* ensureNotionThreadLinksSchema(); - yield* ensureGoogleThreadLinksSchema(); - yield* ensureGranolaThreadLinksSchema(); - yield* ensureThreadLinkWatchesSchema(); - yield* ensureMessageDraftsSchema(); - // Missing statistics favor indexes; stale statistics can cause full-table scans, - // and collecting fresh ones reads every page. Exceptions use INDEXED BY. - // Clear older builds' statistics and reload this connection's cached copy. - const statTables = yield* sql`SELECT 1 FROM sqlite_schema WHERE name = 'sqlite_stat1'`; - if (statTables.length > 0) { - yield* sql`DELETE FROM sqlite_stat1;`; - yield* sql`ANALYZE sqlite_schema;`; - } - }).pipe(Effect.withSpan("Sqlite.setup")), -); - -export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); +export class ServerOwnsDatabaseError extends Schema.TaggedError()( + "ServerOwnsDatabaseError", + { stateDir: Schema.String }, +) { + override get message(): string { + return `Another T3 Code process, usually a running server, owns ${this.stateDir}, and this CLI would change its database schema. Use the \`t3\` that matches the running server, or stop that server, then retry. The database was not changed.`; + } +} - return Layer.provideMerge( - layerSetup, - NodeSqliteClient.layer({ - filename: dbPath, - readerWorker: true, - // Run checkpoint fsyncs off the event loop so slow disk flushes cannot stall clients. - checkpointWorker: true, - spanAttributes: { - "db.name": path.basename(dbPath), - "service.name": "t3code-server", - }, +// 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. + if (!(yield* hasPendingMigrations())) return yield* runMigrations(); + const { serverRuntimeStatePath } = yield* ServerConfig.ServerConfig; + yield* ServerOwnership.holdServerOwnershipLock(serverRuntimeStatePath).pipe( + Effect.catchTags({ + ServerAlreadyRunningError: ({ stateDir }) => + Effect.fail(new ServerOwnsDatabaseError({ stateDir })), }), ); -}, Layer.unwrap); + yield* runMigrations(); +}, Effect.scoped); + +const makeLayerSetup = (migrate: Effect.Effect) => + Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. + yield* sql`PRAGMA busy_timeout = 5000;`; + yield* sql`PRAGMA foreign_keys = ON;`; + // Existing databases need doctor's offline VACUUM to add pointer maps for this mode. + const tables = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table'`; + if (tables[0]?.count === 0) yield* sql`PRAGMA auto_vacuum = INCREMENTAL;`; + yield* sql`PRAGMA journal_mode = WAL;`; + // FULL fsyncs every event commit on the main thread; NORMAL syncs only at WAL checkpoints. + // App crashes lose nothing; OS crashes or power loss can roll back post-checkpoint commits without corruption. + yield* sql`PRAGMA synchronous = NORMAL;`; + // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. + yield* sql`PRAGMA checkpoint_fullfsync = ON;`; + // Keep statement journals and sort spills off the main thread's disk path. + // Large statements and open savepoints can increase peak memory use. + yield* sql`PRAGMA temp_store = MEMORY;`; + // PASSIVE checkpoints retain peak WAL size until the last connection closes. + yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); + yield* migrate; + // Fork tables live outside the upstream ledger; thread tabs creates fork_schema_migrations. + yield* ensureThreadTabsSchema(); + yield* ensureLinearThreadLinksSchema(); + yield* ensureLinearAssignmentTriggerSchema(); + yield* ensureGitHubIssueThreadLinksSchema(); + yield* ensureSlackThreadLinksSchema(); + yield* ensureDiscordThreadLinksSchema(); + yield* ensureNotionThreadLinksSchema(); + yield* ensureGoogleThreadLinksSchema(); + yield* ensureGranolaThreadLinksSchema(); + yield* ensureThreadLinkWatchesSchema(); + yield* ensureMessageDraftsSchema(); + + // Collecting stats scans every page, and stale small-table stats cause scans after growth; use indexes by default. + // Connections cache stats; reload after deleting them. + const statTables = yield* sql`SELECT 1 FROM sqlite_schema WHERE name = 'sqlite_stat1'`; + if (statTables.length > 0) { + yield* sql`DELETE FROM sqlite_stat1;`; + yield* sql`ANALYZE sqlite_schema;`; + } + }).pipe(Effect.withSpan("Sqlite.setup")), + ); + +const layerSetup = makeLayerSetup(runMigrations()); + +const makeLayerFromPath = (setup: Layer.Layer) => + Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + + return Layer.provideMerge( + setup, + NodeSqliteClient.layer({ + filename: dbPath, + readerWorker: true, + // Run checkpoint fsyncs off the event loop so slow disk flushes cannot stall clients. + checkpointWorker: true, + spanAttributes: { + "db.name": path.basename(dbPath), + "service.name": "t3code-server", + }, + }), + ); + }, Layer.unwrap); + +export const layerFromPath = makeLayerFromPath(layerSetup); export const layerMemory = Layer.provideMerge( layerSetup, NodeSqliteClient.layer({ filename: ":memory:" }), ); -export const layerConfig = Layer.unwrap( - Effect.gen(function* () { - const { dbPath } = yield* ServerConfig.ServerConfig; - yield* initializeV2Database(dbPath); - return layerFromPath(dbPath); - }), +const makeLayerConfig = ( + fromPath: (dbPath: string) => Layer.Layer, +) => + Layer.unwrap( + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig.ServerConfig; + yield* initializeV2Database(dbPath); + return fromPath(dbPath); + }), + ); + +export const layerConfig = makeLayerConfig(layerFromPath); + +/** For CLI commands: pending migrations run only while no server owns the T3 home. */ +export const layerCliConfig = makeLayerConfig( + makeLayerFromPath(makeLayerSetup(runMigrationsAsOwner())), ); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 84fa4ddb44c77..8acfd0cb6b9c4 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -60,6 +60,7 @@ describe("V2 preview upgrade", () => { [78, "DropOrchestrationEventCorrelationIndex"], [79, "DelegatedRecoveryIndexes"], [80, "ScheduledTaskThreadMode"], + [81, "ProjectionMessageSearchText"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -163,6 +164,7 @@ describe("V2 preview upgrade", () => { [78, "DropOrchestrationEventCorrelationIndex"], [79, "DelegatedRecoveryIndexes"], [80, "ScheduledTaskThreadMode"], + [81, "ProjectionMessageSearchText"], ]); }).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 2e3cf0f031c55..ad5a28d164ac1 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -220,7 +220,7 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " interactiveElements: [], })), ariaSnapshot: vi.fn(async () => '- button "delete" [ref=e1]'), - locator: vi.fn(() => { + locator: vi.fn((): unknown => { throw new Error("Unexpected locator action"); }), isClosed: () => closed, @@ -1083,6 +1083,40 @@ it.live( ).pipe(Effect.provide(layer)), ); +it.live( + "reports a valid CSS selector that matches several elements as ambiguous, not invalid", + () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const strict = new Error( + "locator.scrollIntoViewIfNeeded: Error: strict mode violation: locator('button[aria-label=\\'Permissions: Full access\\']') resolved to 2 elements:", + ); + contexts[0]!.page.locator.mockImplementation( + () => + new Proxy( + {}, + { + get: () => async () => { + throw strict; + }, + }, + ), + ); + const failure = yield* broker + .invoke({ + scope, + tabId, + operation: "click", + input: { selector: "button[aria-label='Permissions: Full access']", timeoutMs: 1_000 }, + }) + .pipe(Effect.flip); + expect(failure.message).not.toMatch(/invalid selector/); + expect(failure.message).toMatch(/2 elements/); + }), + ).pipe(Effect.provide(layer)), +); + it.live("reports a pending dialog without evaluating the page and resolves it explicitly", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index f28933fdb79e8..095bf7defc81d 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -1,3 +1,5 @@ +import { PreviewAutomationEvaluateInput } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { chromium, type Browser, @@ -18,6 +20,8 @@ import { import * as ServerBrowserPage from "./ServerBrowserPage.ts"; +const decodeEvaluateInput = Schema.decodeUnknownSync(PreviewAutomationEvaluateInput); + describe("server browser element refs", () => { let browser: Browser; let context: BrowserContext; @@ -236,7 +240,6 @@ describe("server browser element refs", () => { ); expect(result.visibleText).not.toContain("editable-pass"); expect(result.visibleText).toContain("Order ••••••••-2"); - expect(tree).toMatch(/row "password: •+"/); expect(tree).toMatch(/textbox "Username" [^\n]*: maria$/m); const line = tree.split("\n").find((line) => line.includes('textbox "Password"')); expect(line).toMatch(/: •+$/); @@ -354,6 +357,15 @@ describe("server browser element refs", () => { expect(result).toBe("Ready"); }); + it("returns object contents even when a legacy caller asks for a remote reference", async () => { + const input = decodeEvaluateInput({ + expression: "({ answer: 42, nested: { ready: true } })", + returnByValue: false, + }); + const result = await ServerBrowserPage.evaluate(cdp, input, { timeoutMs: 5_000 }); + expect(result).toEqual({ answer: 42, nested: { ready: true } }); + }); + it("still awaits the promise an async function returns", async () => { const result = await ServerBrowserPage.evaluate( cdp, diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 8bf9b3f5003a0..15ab70d4ee2ae 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -64,9 +64,7 @@ export const toOperationError = ( detail: timeoutMs === undefined ? undefined : { timeoutMs }, }); } - if ( - /while parsing selector|Unknown engine|Unexpected token|strict mode violation/i.test(message) - ) { + if (/while parsing selector|Unknown engine|Unexpected token/i.test(message)) { return new ServerBrowserOperationError({ tag: "PreviewAutomationInvalidSelectorError", message: firstLine, @@ -1023,7 +1021,7 @@ export const evaluate = async ( const params = { expression: input.expression, awaitPromise: input.awaitPromise ?? true, - returnByValue: input.returnByValue ?? true, + returnByValue: true, }; return read("Runtime.evaluate", async (timeoutMs) => { const classic = await cdp.send("Runtime.evaluate", { ...params, timeout: timeoutMs }); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 91fb72b37137f..4f6542f8c0bf4 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -115,6 +115,39 @@ const layerTest = (input: { ); }; +// macOS command lookup cannot search a Windows drive-letter PATH. +it.effect.skipIf(windowsHost)( + "opens home-relative app bundles using the server home directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bin = yield* fs.makeTempDirectoryScoped({ prefix: "t3-app-open-" }); + yield* fs.writeFileString(path.join(bin, "open"), "#!/bin/sh\n"); + yield* fs.chmod(path.join(bin, "open"), 0o755); + const spawned: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + for (const cwd of ["~/Applications/My App.app", "/Applications/My App.app"]) { + yield* launcher.launchEditor({ editor: "file-manager", cwd }); + } + }).pipe( + Effect.provide( + layerTest({ + platform: "darwin", + env: { PATH: bin }, + onSpawn: (command) => spawned.push(command), + }), + ), + Effect.provideService(HostProcess.HomeDirectory, "/Users/server"), + ); + assert.deepEqual( + spawned.map((command) => command.args), + [["/Users/server/Applications/My App.app"], ["/Applications/My App.app"]], + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect.skipIf(windowsHost)( "launched browsers and editors do not inherit ELECTRON_RUN_AS_NODE", () => @@ -504,7 +537,7 @@ it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directo ] as const) { yield* fs.makeDirectory(dir, { recursive: true }); const executable = path.join(dir, name); - yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.writeFileString(executable, "#!/bin/sh\necho 'Zed 0.200.0'\n"); yield* fs.chmod(executable, 0o755); } const editor = EDITORS.find((editor) => editor.id === "zed")!; @@ -532,6 +565,83 @@ it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directo }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); +it.effect.skipIf(windowsHost)("does not offer the OpenZFS zed daemon as Zed on Linux", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-zfs-" }); + const binDir = path.join(home, "bin"); + const systemBin = path.join(home, "system-bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + yield* fs.makeDirectory(systemBin, { recursive: true }); + const zfsZed = path.join(binDir, "zed"); + // OpenZFS zed only takes short options: `--version` is rejected with usage on stderr. + yield* fs.writeFileString( + zfsZed, + "#!/bin/sh\necho \"Invalid option '--'\" >&2\necho 'Usage: zed [OPTION]...' >&2\nexit 1\n", + ); + yield* fs.chmod(zfsZed, 0o755); + const editor = EDITORS.find((editor) => editor.id === "zed")!; + const resolve = resolveEditorCommand(editor, { HOME: home, PATH: binDir }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(Path.Path, { + ...path, + join: (...parts) => + path.join( + ...parts.map((part) => + ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, + ), + ), + }), + ); + assert.deepEqual(yield* resolve, Option.none()); + + const zedEditor = path.join(home, ".local/bin/zed"); + yield* fs.makeDirectory(path.dirname(zedEditor), { recursive: true }); + yield* fs.writeFileString(zedEditor, "#!/bin/sh\necho 'Zed 0.200.0'\n"); + yield* fs.chmod(zedEditor, 0o755); + assert.deepEqual(yield* resolve, Option.some({ command: zedEditor, baseArgs: [] })); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.skipIf(windowsHost)("does not open Zed with the OpenZFS zed daemon on Linux", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-zfs-launch-" }); + const binDir = path.join(home, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + yield* fs.writeFileString(path.join(binDir, "zed"), "#!/bin/sh\nexit 1\n"); + yield* fs.chmod(path.join(binDir, "zed"), 0o755); + + const spawned: ChildProcess.StandardCommand[] = []; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" }).pipe(Effect.exit); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { HOME: home, PATH: binDir }, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => (command.args[0] === "--version" ? { exitCode: 1 } : undefined), + }), + ), + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.instanceOf( + Cause.squash(exit.cause), + ExternalLauncher.ExternalLauncherCommandNotFoundError, + ); + } + assert.deepEqual( + spawned.map((command) => command.args), + [["--version"]], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.each([ { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" }, { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" }, diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 4ecc108b99335..f406de0a0be72 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -1,3 +1,4 @@ +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as NodePath from "@effect/platform-node/NodePath"; import { EDITORS, @@ -523,13 +524,14 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( } if (editorDef.commands) { - const { command, baseArgs } = Option.getOrElse( - yield* resolveEditorCommand(editorDef, env), - () => ({ + const resolved = yield* resolveEditorCommand(editorDef, env); + if (Option.isNone(resolved)) { + return yield* new ExternalLauncherCommandNotFoundError({ + editor: editorDef.id, command: editorDef.commands[0], - baseArgs: "baseArgs" in editorDef ? editorDef.baseArgs : [], - }), - ); + }); + } + const { command, baseArgs } = resolved.value; return { editor: editorDef.id, target: input.cwd, @@ -547,26 +549,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const target = expandHomePath( + Option.match(parseTargetPathAndPosition(input.cwd), { + onNone: () => input.cwd, + onSome: (parsed) => parsed.path, + }), + yield* HostProcess.HomeDirectory, + ); if (input.reveal === true) { - return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + return yield* resolveFileManagerRevealLaunch(target, platform, env, command); } - const filePath = Option.match(parseTargetPathAndPosition(input.cwd), { - onNone: () => input.cwd, - onSome: (target) => target.path, - }); return { editor: editorDef.id, - target: input.cwd, + target, command, args: command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined - ? [resolveWslFileManagerPath(filePath, env.WSL_DISTRO_NAME)] + ? [resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME)] : platform === "win32" - ? // Explorer reads `/` as a switch, so `C:/repo` silently opens - // a default folder instead of the target. - [normalizeWindowsFileManagerPath(filePath)] - : [filePath], + ? // Explorer interprets / as a switch and silently opens a fallback folder. + [normalizeWindowsFileManagerPath(target)] + : [target], }; }); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index b6aec2a6ecc28..b81cc2a9f382a 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -7,6 +7,8 @@ import { ProviderDriverKind, ProviderInstanceId, ProviderThreadId, + MessageId, + RunId, ThreadId, type ApplicationStoredEvent, type OrchestrationV2AppThread, @@ -14,6 +16,9 @@ import { type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -23,11 +28,14 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; +import * as TestClock from "effect/testing/TestClock"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { CLAUDE_MODEL_SELECTION, CODEX_MODEL_SELECTION, @@ -175,6 +183,8 @@ const seedOwner = Effect.fn(function* ( const makeImporter = Effect.fn(function* ( candidates: ReadonlyArray>, beforeTransaction: Effect.Effect = Effect.void, + getAdapter?: ProviderAdapterRegistry.ProviderAdapterRegistryV2Shape["get"], + threadCommands?: KeyedLock.KeyedLock, settings: Layer.Layer< ServerSettings.ServerSettingsService, ServerSettingsError @@ -246,12 +256,23 @@ const makeImporter = Effect.fn(function* ( ), ), }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)( + getAdapter === undefined ? {} : { get: getAdapter }, + ), + Layer.succeed( + ThreadCommandExecutor.ThreadCommandExecutor, + threadCommands ?? (yield* KeyedLock.make()), + ), ), ), ), ); const importer = Context.get(context, AgentSessionImporter.AgentSessionImporter); - return { run: importer.importRecentAgentThreads({ projectId }), published }; + return { + run: importer.importRecentAgentThreads({ projectId }), + importNativeHistory: importer.importNativeHistory, + published, + }; }); const providerInstanceId = ProviderInstanceId.make("codex-main"); @@ -390,6 +411,10 @@ it.effect("imports one picked session and lists sessions with the threads holdin }); const testLayer = AgentSessionImporter.layer.pipe( Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide( + Layer.effect(ThreadCommandExecutor.ThreadCommandExecutor, KeyedLock.make()), + ), Layer.provideMerge( Layer.mergeAll( Layer.succeed(AgentSessionScanner.AgentSessionScanner, scanner), @@ -468,6 +493,10 @@ it.effect("caps the listing and reports truncation", () => { ); const testLayer = AgentSessionImporter.layer.pipe( Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide( + Layer.effect(ThreadCommandExecutor.ThreadCommandExecutor, KeyedLock.make()), + ), Layer.provide( Layer.mergeAll( Layer.succeed( @@ -670,6 +699,321 @@ it.effect( }).pipe(Effect.scoped, Effect.provide(persistence)), ); +it.effect( + "copies native session history into the imported thread once, retrying a failed read", + () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const source = candidate(CODEX_MODEL_SELECTION, "native-history"); + const threadId = yield* seedOwner(source); + const resolved: Array = []; + const opened: Array = []; + let closed = false; + let failRead = true; + const replayed = (role: "user" | "assistant", text: string) => ({ + createdBy: role === "user" ? ("user" as const) : ("agent" as const), + creationSource: "provider" as const, + id: MessageId.make(`replayed:${text}`), + threadId, + runId: null, + nodeId: null, + role, + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }); + const adapter = { + openSession: (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) => + Effect.acquireRelease( + Effect.sync(() => { + opened.push(input); + return { + readThreadSnapshot: ({ providerThread }) => + failRead + ? Effect.fail( + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: providerThread.driver, + providerThreadId: providerThread.id, + }), + ) + : Effect.succeed({ + providerThread, + providerTurns: [], + runtimeRequests: [], + reasoningMessageIds: [MessageId.make("replayed:Check the build log")], + messages: [ + replayed("user", "Fix the build"), + replayed("assistant", "Check the build log"), + replayed("assistant", "Fixed it"), + replayed("assistant", " "), + ], + }), + } as Pick; + }), + () => + Effect.sync(() => { + closed = true; + }), + ), + } as unknown as ProviderAdapter.ProviderAdapterV2["Service"]; + const importer = yield* makeImporter([], Effect.void, (instanceId) => + Effect.sync(() => { + resolved.push(instanceId); + return adapter; + }), + ); + + const importHistory = importer.importNativeHistory({ + threadId, + workspaceRoot: "/workspace/project", + }); + + expect((yield* Effect.flip(importHistory))._tag).toBe( + "ProviderAdapterReadThreadSnapshotError", + ); + expect(closed).toBe(true); + expect( + (yield* projections.getThreadRecords(threadId, [])).thread.historyOrigin, + ).toBeUndefined(); + + failRead = false; + expect(yield* importHistory).toBe(3); + expect(resolved).toEqual([ + CODEX_MODEL_SELECTION.instanceId, + CODEX_MODEL_SELECTION.instanceId, + ]); + expect(opened[1]).toMatchObject({ + threadId, + initialNativeThreadId: source.source.providerSessionId, + runtimePolicy: { cwd: "/workspace/project" }, + }); + // Forks keep run-less history only for this origin. + expect((yield* projections.getThreadRecords(threadId, [])).thread.historyOrigin).toBe( + "v1_import", + ); + expect(yield* importHistory).toBe(0); + expect(opened).toHaveLength(2); + const page = yield* projections.getTimelinePage(threadId, { view: "activity", limit: 10 }); + expect( + page.items.map(({ item }) => [ + item.type, + item.runId, + "text" in item ? item.text : undefined, + ]), + ).toEqual([ + ["user_message", null, "Fix the build"], + ["reasoning", null, "Check the build log"], + ["assistant_message", null, "Fixed it"], + ]); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + +it.effect("releases the ACP import lock when a native session never answers", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; + const threadId = yield* seedOwner(candidate(CODEX_MODEL_SELECTION, "native-history-hang")); + const opened = yield* Deferred.make(); + let closed = false; + // An agent that only resumes sessions and never answers its first session/resume. + const adapter = { + openSession: () => + Effect.suspend(() => { + if (!closed) { + return Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ).pipe( + Effect.andThen(Deferred.succeed(opened, undefined)), + Effect.andThen(Effect.never), + ); + } + return Effect.succeed({ + readThreadSnapshot: ({ providerThread }) => + Effect.succeed({ + providerThread, + providerTurns: [], + runtimeRequests: [], + messages: [ + { + createdBy: "user", + creationSource: "provider", + id: MessageId.make("replayed:hang"), + threadId, + runId: null, + nodeId: null, + role: "user", + text: "Fix the build", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + }), + } as Pick); + }), + } as unknown as ProviderAdapter.ProviderAdapterV2["Service"]; + const importer = yield* makeImporter([], Effect.void, () => Effect.succeed(adapter)); + const importHistory = coordinator.withSessionMutation( + importer.importNativeHistory({ threadId, workspaceRoot: "/workspace/project" }), + ); + + const importing = yield* importHistory.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(opened); + // Any other ACP import or delete waits for the same lock. + const next = yield* coordinator + .withSessionMutation(Effect.succeed("next")) + .pipe(Effect.forkChild); + yield* TestClock.adjust("2 minutes"); + + expect((yield* Fiber.join(importing))._tag).toBe("TimeoutError"); + expect(closed).toBe(true); + expect(yield* Fiber.join(next)).toBe("next"); + expect( + (yield* projections.getThreadRecords(threadId, [])).thread.historyOrigin, + ).toBeUndefined(); + expect(yield* importHistory).toBe(1); + expect((yield* projections.getThreadRecords(threadId, [])).thread.historyOrigin).toBe( + "v1_import", + ); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(persistence, AcpRegistryRuntimeCoordinator.layer)), + ), +); + +/** + * Runs a thread command that read the thread before the history load ended and + * writes once the importer is about to commit, as an unserialized write would. + */ +const importDuringCommand = Effect.fn(function* ( + name: string, + command: (thread: OrchestrationV2AppThread) => ReadonlyArray, +) { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = yield* seedOwner(candidate(CODEX_MODEL_SELECTION, name)); + const lock = yield* KeyedLock.make(); + const loaded = yield* Deferred.make(); + const importerCommitting = yield* Deferred.make(); + const commandWritten = yield* Deferred.make(); + const committing = Deferred.succeed(importerCommitting, undefined); + const adapter = { + openSession: () => + Effect.succeed({ + readThreadSnapshot: ({ providerThread }) => + Deferred.await(loaded).pipe( + Effect.as({ + providerThread, + providerTurns: [], + runtimeRequests: [], + messages: [ + { + createdBy: "user" as const, + creationSource: "provider" as const, + id: MessageId.make(`replayed:${name}`), + threadId, + runId: null, + nodeId: null, + role: "user" as const, + text: "Fix the build", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + }), + ), + } as Pick), + } as unknown as ProviderAdapter.ProviderAdapterV2["Service"]; + const importer = yield* makeImporter( + [], + committing.pipe(Effect.andThen(Deferred.await(commandWritten))), + () => Effect.succeed(adapter), + { + ...lock, + withLock: (key, effect) => committing.pipe(Effect.andThen(lock.withLock(key, effect))), + }, + ); + const commanding = yield* Effect.forkScoped( + lock.withLock( + threadId, + Effect.gen(function* () { + const { thread } = yield* projections.getThreadRecords(threadId, []); + yield* Deferred.succeed(loaded, undefined); + yield* Deferred.await(importerCommitting); + yield* sink.write({ events: command(thread) }); + yield* Deferred.succeed(commandWritten, undefined); + }), + ), + ); + const imported = yield* importer.importNativeHistory({ + threadId, + workspaceRoot: "/workspace/project", + }); + yield* Fiber.join(commanding); + const records = yield* projections.getThreadRecords(threadId, ["runs"]); + const page = yield* projections.getTimelinePage(threadId, { view: "messages", limit: 10 }); + return { imported, thread: records.thread, runs: records.runs, items: page.items }; +}); + +it.effect("keeps a thread edit made while the native history loads", () => + Effect.gen(function* () { + const result = yield* importDuringCommand("renamed-during-load", (thread) => [ + { + id: EventId.make(`${thread.id}:renamed`), + type: "thread.metadata-updated", + threadId: thread.id, + occurredAt: now, + payload: { ...thread, title: "Renamed" }, + }, + ]); + expect(result.imported).toBe(1); + expect(result.thread.title).toBe("Renamed"); + expect(result.thread.historyOrigin).toBe("v1_import"); + expect(result.items).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + +it.effect("skips the native history when a run starts while it loads", () => + Effect.gen(function* () { + const result = yield* importDuringCommand("run-during-load", (thread) => [ + { + id: EventId.make(`${thread.id}:run`), + type: "run.updated", + threadId: thread.id, + occurredAt: now, + payload: { + id: RunId.make(`${thread.id}:run`), + threadId: thread.id, + ordinal: 1, + providerInstanceId: thread.providerInstanceId, + modelSelection: thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`${thread.id}:prompt`), + rootNodeId: null, + activeAttemptId: null, + status: "queued", + requestedAt: now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + ]); + expect(result.imported).toBe(0); + expect(result.runs).toHaveLength(1); + expect(result.thread.historyOrigin).toBeUndefined(); + expect(result.items).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + it.effect("imports under a policy ceiling in the project's default, kept under the ceiling", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -679,6 +1023,8 @@ it.effect("imports under a policy ceiling in the project's default, kept under t const importer = yield* makeImporter( [source], Effect.void, + undefined, + undefined, ServerSettings.layerTest( { defaultRuntimeMode: "full-access" }, { diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 2d1dfd8325bc1..eac09a074f204 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -42,6 +42,8 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; @@ -115,10 +117,30 @@ function previewText(thread: AgentSessionScanner.AgentSessionThread): string { return (firstLine ?? "").slice(0, MAX_PREVIEW_CHARS); } +// ACP imports read the history while holding the session-mutation lock, and an agent can +// leave session/resume or initialize unanswered. Longer than session/load's own deadline. +const NATIVE_HISTORY_READ_TIMEOUT = "2 minutes"; + +/** + * Whether a thread bound to a native session still lacks its native history. + * Once a turn has run, the native session also holds that turn's messages, so + * its history can no longer be imported as run-less items. + */ +export function isNativeHistoryPending(input: { + readonly historyOrigin: OrchestrationV2AppThread["historyOrigin"]; + readonly hasRun: boolean; +}): boolean { + return input.historyOrigin !== "v1_import" && !input.hasRun; +} + function dateTime(value: string): DateTime.Utc { return DateTime.makeUnsafe(value); } +type HistoryMessage = Omit & { + readonly role: AgentSessionScanner.AgentSessionThreadMessage["role"] | "reasoning"; +}; + /** The id of the message an import writes at `index` of the thread's history. */ export function importedMessageId(threadId: ThreadId, index: number): MessageId { return MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`); @@ -128,7 +150,7 @@ export function importedMessageId(threadId: ThreadId, index: number): MessageId export function messageEvents(input: { readonly threadId: ThreadId; readonly index: number; - readonly message: AgentSessionScanner.AgentSessionThreadMessage; + readonly message: HistoryMessage; /** Files already persisted in the attachment store; user messages only. */ readonly attachments?: ReadonlyArray; }): ReadonlyArray { @@ -140,20 +162,6 @@ export function messageEvents(input: { `${IMPORT_EVENT_PREFIX}:turn-item:${input.threadId}:${suffix}`, ); const at = dateTime(input.message.createdAt); - const message: OrchestrationV2ConversationMessage = { - createdBy: input.message.role === "user" ? "user" : "agent", - creationSource: "server", - id: messageId, - threadId: input.threadId, - runId: null, - nodeId: null, - role: input.message.role, - text: input.message.text, - attachments, - streaming: false, - createdAt: at, - updatedAt: at, - }; const common = { id: turnItemId, threadId: input.threadId, @@ -170,6 +178,32 @@ export function messageEvents(input: { completedAt: at, updatedAt: at, }; + const turnItemEvent = (payload: OrchestrationV2TurnItem): OrchestrationV2DomainEvent => ({ + id: EventId.make(turnItemId), + type: "turn-item.updated", + threadId: input.threadId, + occurredAt: at, + payload, + }); + if (input.message.role === "reasoning") { + return [ + turnItemEvent({ ...common, type: "reasoning", text: input.message.text, streaming: false }), + ]; + } + const message: OrchestrationV2ConversationMessage = { + createdBy: input.message.role === "user" ? "user" : "agent", + creationSource: "server", + id: messageId, + threadId: input.threadId, + runId: null, + nodeId: null, + role: input.message.role, + text: input.message.text, + attachments, + streaming: false, + createdAt: at, + updatedAt: at, + }; const turnItem: OrchestrationV2TurnItem = input.message.role === "user" ? { @@ -197,13 +231,7 @@ export function messageEvents(input: { occurredAt: at, payload: message, }, - { - id: EventId.make(`${IMPORT_EVENT_PREFIX}:turn-item:${input.threadId}:${suffix}`), - type: "turn-item.updated", - threadId: input.threadId, - occurredAt: at, - payload: turnItem, - }, + turnItemEvent(turnItem), ]; } @@ -214,6 +242,8 @@ const make = Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; + const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const settingsService = yield* ServerSettings.ServerSettingsService; const ceiling = settingsService.managedPolicy?.limits?.maxRuntimeMode; const sql = yield* SqlClient.SqlClient; @@ -576,7 +606,101 @@ const make = Effect.gen(function* () { } satisfies AgentSessionListResult; }); - return { importRecentAgentThreads, listProjectAgentSessions }; + // Native history lives in the adapter snapshot until imported as run-less history. + // v1_import marks completion so failed reads can be retried without duplicates. + const importNativeHistory = Effect.fn("importNativeHistoryV2")(function* (input: { + readonly threadId: ThreadId; + readonly workspaceRoot: string; + }) { + const pendingThread = Effect.map( + orchestrator.getThreadRecords(input.threadId, ["providerThreads", "runs"]), + (records) => + isNativeHistoryPending({ + historyOrigin: records.thread.historyOrigin, + hasRun: records.runs.length > 0, + }) + ? records + : null, + ); + // Thread commands write whole thread rows, so hold their lock from the + // re-read through the write to keep both this marker and their changes. + const commit = (messages: ReadonlyArray) => + threadCommands.withLock( + input.threadId, + Effect.gen(function* () { + const current = yield* pendingThread; + if (current === null) return 0; + yield* eventSink.write({ + events: [ + ...messages.flatMap((message, index) => + messageEvents({ threadId: input.threadId, index, message }), + ), + { + id: EventId.make(`${IMPORT_EVENT_PREFIX}:thread:${input.threadId}:native-history`), + type: "thread.metadata-updated", + threadId: input.threadId, + providerInstanceId: current.thread.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { ...current.thread, historyOrigin: "v1_import" }, + }, + ], + }); + return messages.length; + }), + ); + const records = yield* pendingThread; + if (records === null) return 0; + const { thread, providerThreads } = records; + const providerThread = providerThreads.find( + (candidate) => candidate.appThreadId === input.threadId && candidate.nativeThreadRef !== null, + ); + const nativeThreadId = providerThread?.nativeThreadRef?.nativeId; + if (providerThread === undefined || nativeThreadId == null) return 0; + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: providerThread.providerInstanceId, + threadId: input.threadId, + }); + // The registry admits this open against sign-in changes, like any other open. + const adapter = yield* registry.get(thread.modelSelection.instanceId); + const snapshot = yield* adapter + .openSession({ + threadId: input.threadId, + providerSessionId, + modelSelection: thread.modelSelection, + runtimePolicy: { + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + cwd: thread.worktreePath ?? input.workspaceRoot, + }, + initialNativeThreadId: nativeThreadId, + ...(providerThread.nativeMetadata?.itemIdentityVersion === undefined + ? {} + : { + initialProviderItemIdentityVersion: providerThread.nativeMetadata.itemIdentityVersion, + }), + }) + .pipe( + Effect.flatMap((session) => session.readThreadSnapshot({ providerThread })), + Effect.timeout(NATIVE_HISTORY_READ_TIMEOUT), + Effect.scoped, + ); + const reasoningIds = new Set(snapshot.reasoningMessageIds); + const messages = snapshot.messages.flatMap((message) => + (message.role === "user" || message.role === "assistant") && message.text.trim() !== "" + ? [ + { + role: reasoningIds.has(message.id) ? ("reasoning" as const) : message.role, + text: message.text, + createdAt: DateTime.formatIso(message.createdAt), + }, + ] + : [], + ); + // Loading the session can take a while, and the thread is usable meanwhile. + return yield* commit(messages); + }); + + return { importRecentAgentThreads, listProjectAgentSessions, importNativeHistory }; }); type AgentSessionImporterShape = Effect.Success; diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index c4ba5e674e85e..e28b0c9a0b81e 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodeSqlite from "node:sqlite"; import { describe, expect, it } from "@effect/vitest"; import { AgentSessionImportSource, @@ -1087,6 +1088,38 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ]); }), ); + + it.effect("keeps Codex rollout cwds when the Codex state database is unreadable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "05", + "03", + "rollout-2026-05-03T00-00-00-019a5f00-0000-7000-8000-00000000000a.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + yield* fileSystem.writeFileString( + path.join(codexHomePath, "state_5.sqlite"), + "not a database", + ); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: workspace, threadCount: 1 }, + ]); + }), + ); }); describe("recentThreads for the conversation picker", () => { @@ -1482,6 +1515,140 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports Codex chats moved from a scratch folder into a Codex project", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-11-08T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const homeDir = yield* makeTempDir("t3code-home-"); + const projectDir = path.join(homeDir, "code", "my-project"); + const scratchDir = path.join(homeDir, "Documents", "Codex", "2026-11-08", "new-chat"); + const otherDir = path.join(homeDir, "code", "other-project"); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + yield* fileSystem.makeDirectory(scratchDir, { recursive: true }); + yield* fileSystem.makeDirectory(otherDir, { recursive: true }); + + const projectId = "019a5f00-0000-7000-8000-000000000001"; + const chats = [ + { + id: "019a5f00-0000-7000-8000-00000000000a", + cwd: scratchDir, + message: "Started with no project", + }, + { + id: "019a5f00-0000-7000-8000-00000000000b", + cwd: projectDir, + message: "Started in the project", + }, + { + id: "019a5f00-0000-7000-8000-00000000000c", + cwd: otherDir, + message: "Started in another folder", + }, + { + id: "019a5f00-0000-7000-8000-00000000000d", + rolloutId: "019a5f00-0000-7000-8000-0000000000d1", + cwd: scratchDir, + message: "Rewound with no project", + }, + ]; + const rolloutPaths: Array = []; + for (const [index, chat] of chats.entries()) { + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "11", + "08", + `rollout-2026-11-08T08-0${index}-00-${"rolloutId" in chat ? `${chat.id}_${chat.rolloutId}` : chat.id}.jsonl`, + ); + rolloutPaths.push(filePath); + yield* writeTranscript({ + filePath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: chat.id, cwd: chat.cwd }, + }), + encodeTranscriptRecord({ type: "turn_context", payload: { cwd: chat.cwd } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: chat.message }, + }), + ].join("\n"), + mtimeMs: nowMs - 60_000 + index, + }); + } + // Codex records a chat's project only in its state DB; the rollout keeps the original cwd. + yield* Effect.sync(() => { + const db = new NodeSqlite.DatabaseSync(path.join(codexHomePath, "state_5.sqlite")); + try { + db.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + position INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE TABLE project_roots ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (project_id, position) + ); + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + cwd TEXT NOT NULL, + project_id TEXT REFERENCES projects(id) ON DELETE SET NULL + ); + `); + db.prepare("INSERT INTO projects VALUES (?, ?, '{}', 0, ?, ?)").run( + projectId, + "my-project", + nowMs, + nowMs, + ); + db.prepare("INSERT INTO project_roots VALUES (?, 0, ?)").run(projectId, projectDir); + const insertThread = db.prepare("INSERT INTO threads VALUES (?, ?, ?, ?)"); + for (const [index, chat] of chats.entries()) { + insertThread.run(chat.id, rolloutPaths[index]!, chat.cwd, projectId); + } + } finally { + db.close(); + } + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: projectDir, + }).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); + expect(threads.map((thread) => thread.title).toSorted()).toEqual([ + "Rewound with no project", + "Started in the project", + "Started with no project", + ]); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(HostProcess.HomeDirectory, homeDir), + ); + expect( + result.candidates + .map(({ path, threadCount }) => ({ path, threadCount })) + .toSorted((left, right) => left.path.localeCompare(right.path)), + ).toEqual([ + { path: projectDir, threadCount: 3 }, + { path: otherDir, threadCount: 1 }, + ]); + }), + ); + it.effect("imports a rewound Codex thread with the history its new rollout inherits", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index df413f8240852..2ee7ed54b0a52 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as NodeSqlite from "node:sqlite"; import { AgentSessionScanError, @@ -56,9 +57,19 @@ class TranscriptParseError extends Schema.TaggedError()( return `Could not parse the transcript at ${this.filePath}.`; } } + +class CodexStateReadError extends Schema.TaggedError()("CodexStateReadError", { + databasePath: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Could not read Codex projects from ${this.databasePath}.`; + } +} const METADATA_READ_BYTES = 8 * 1024; const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; const MAX_CODEX_SESSION_INDEX_BYTES = 1024 * 1024; +const MAX_CODEX_PROJECT_ROOT_ROWS = 100_000; const MAX_TRANSCRIPTS_PER_SOURCE = 5000; @@ -160,7 +171,9 @@ const decodeCodexHistoryBase = Schema.decodeUnknownOption( ); // Original rollouts end in their thread ID, rewound ones in `_`. const CODEX_ROLLOUT_ID_PATTERN = - /[-_]([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl$/i; +// Codex records a chat's project only here; moving a chat leaves its rollout cwd unchanged. +const CODEX_STATE_DATABASE = "state_5.sqlite"; const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( Schema.fromJsonString(CodexSessionIndexEntry), ); @@ -236,6 +249,7 @@ interface RawCandidate { readonly transcripts: ReadonlyArray<{ readonly filePath: string; readonly mtimeMs: number | null; + readonly projectRoots?: ReadonlyArray | undefined; }>; } @@ -245,6 +259,7 @@ interface TranscriptCandidate { readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; readonly size: number; + readonly projectRoots?: ReadonlyArray | undefined; } interface MetadataReadBudget { @@ -1259,6 +1274,47 @@ export const make = Effect.gen(function* () { }, ); + const readCodexProjectRoots = Effect.fn("AgentSessionScanner.readCodexProjectRoots")(function* ( + homePath: string, + ) { + const rootsByThread = new Map>(); + const databasePath = path.join(homePath, CODEX_STATE_DATABASE); + return yield* Effect.try({ + try: () => { + const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + database.exec("PRAGMA busy_timeout = 100"); + const rows = database + .prepare( + `SELECT threads.id AS id, project_roots.path AS path + FROM threads JOIN project_roots ON project_roots.project_id = threads.project_id + ORDER BY threads.rowid DESC, project_roots.position LIMIT ?`, + ) + .all(MAX_CODEX_PROJECT_ROOT_ROWS); + for (const { id, path: root } of rows) { + if (typeof id !== "string" || typeof root !== "string" || root.trim().length === 0) { + continue; + } + const roots = rootsByThread.get(id); + if (roots === undefined) rootsByThread.set(id, [root]); + else roots.push(root); + } + return rootsByThread; + } finally { + database.close(); + } + }, + catch: (cause) => new CodexStateReadError({ databasePath, cause }), + }).pipe(Effect.orElseSucceed((): ReadonlyMap> => new Map())); + }); + + // Only chats left in an excluded folder, such as Codex's scratch folder, take their project's root. + const projectCwd = (cwd: string, projectRoots: ReadonlyArray | undefined) => + projectRoots !== undefined && + isExcludedProjectPath(path.resolve(expandHomePath(cwd.trim(), homeDir))) + ? projectRoots[0]! + : cwd; + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { const sessionsDir = path.join(homePath, "sessions"); @@ -1320,7 +1376,15 @@ export const make = Effect.gen(function* () { } } } - return { transcripts, truncated }; + const projectRoots = yield* readCodexProjectRoots(homePath); + return { + transcripts: transcripts.map((transcript) => { + const threadId = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath)?.[1]; + const roots = threadId === undefined ? undefined : projectRoots.get(threadId); + return roots === undefined ? transcript : { ...transcript, projectRoots: roots }; + }), + truncated, + }; }, ); @@ -1390,13 +1454,14 @@ export const make = Effect.gen(function* () { providerInstanceId: ProviderInstanceId; homePath: string; lastActiveAtMs: number; - transcripts: Array<{ filePath: string; mtimeMs: number }>; + transcripts: Array; } >(); for (const transcript of transcripts) { - const cwd = yield* readCwd(source, transcript, budget); - if (cwd === null || cwd === false) continue; + const rolloutCwd = yield* readCwd(source, transcript, budget); + if (rolloutCwd === null || rolloutCwd === false) continue; + const cwd = projectCwd(rolloutCwd, transcript.projectRoots); const key = `${transcript.providerInstanceId}\0${cwd}`; const existing = byOwnerAndCwd.get(key); if (existing) { @@ -1791,7 +1856,8 @@ export const make = Effect.gen(function* () { candidates.flatMap((candidate) => candidate.source === "codex" && candidate.providerInstanceId === providerInstanceId ? candidate.transcripts.flatMap((transcript) => { - const id = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath)?.[1]; + const match = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath); + const id = match?.[2] ?? match?.[1]; return id === undefined ? [] : [[id, transcript.filePath] as const]; }) : [], @@ -1877,7 +1943,10 @@ export const make = Effect.gen(function* () { if (Option.isNone(snapshot.cwd)) { return Option.some({ _tag: "Skipped" }); } - const expandedCwd = expandHomePath(snapshot.cwd.value.trim(), homeDir); + const expandedCwd = expandHomePath( + projectCwd(snapshot.cwd.value, transcript.projectRoots).trim(), + homeDir, + ); if (!path.isAbsolute(expandedCwd) || !(yield* isOwnedByRoot(expandedCwd))) { return Option.some({ _tag: "Skipped" }); } diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 7295a0327de51..046ea3683b22c 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -113,23 +114,38 @@ const requireRoot = Effect.gen(function* () { return Option.getOrThrow(yield* scratch.scratchRoot); }); -it.effect("offers nothing when the data dir sits inside a Git checkout", () => - Effect.gen(function* () { +it.effect("offers nothing, and says why once, when the data dir sits inside a Git checkout", () => { + const warnings: string[] = []; + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn") warnings.push(String(message)); + }); + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const checkout = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-scratch-repo-" }); yield* git(checkout, ["init", "--quiet"]); const baseDir = path.join(checkout, ".t3"); yield* fileSystem.makeDirectory(baseDir); + const repositoryRoot = (yield* git(baseDir, ["rev-parse", "--show-toplevel"])).stdout.trim(); yield* Effect.gen(function* () { const scratch = yield* ManagedProjectFolders.ManagedProjectFolders; assert.isTrue(Option.isNone(yield* scratch.scratchRoot)); assert.isFalse(yield* scratch.isScratchProject(ProjectId.make("project:any"))); const failure = yield* Effect.flip(scratch.ensureScratchProject); assert.equal(failure._tag, "ScratchUnavailableError"); - }).pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), -); + assert.include(failure.message, baseDir); + // The root prefixes the data dir, so it must appear on its own. + assert.include(failure.message.replaceAll(baseDir, ""), repositoryRoot); + yield* Effect.flip(scratch.ensureScratchProject); + }).pipe( + Effect.provide( + Layer.mergeAll(layer(baseDir), Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + assert.equal(warnings.length, 1); + assert.include(warnings[0], baseDir); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); +}); it.effect("offers a Scratch folder when Git is not installed", () => withScratch( diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 4b0991e1a64ce..92cc1dc0d69c1 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; 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 ServerConfig from "../config.ts"; @@ -27,10 +28,16 @@ import * as ProjectService from "./ProjectService.ts"; export class ScratchUnavailableError extends Schema.TaggedError()( "ScratchUnavailableError", - {}, + { + baseDir: Schema.String, + repositoryRoot: Schema.optional(Schema.String), + }, ) { override get message(): string { - return "Threads without a project are not available on this environment."; + if (this.repositoryRoot === undefined) { + return "Threads without a project are not available on this environment."; + } + return `Threads without a project are off because the T3 home ${this.baseDir} is inside the Git repository ${this.repositoryRoot}. If that repository was created by mistake, move ${this.repositoryRoot}/.git out of the way; otherwise point T3CODE_HOME (or --base-dir) at a folder outside it. Then restart T3 Code.`; } } @@ -232,18 +239,30 @@ const make = Effect.gen(function* () { // Scratch inside a checkout would inherit its git status and checkpoints; require a data dir outside work trees. const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL( - gitWorkflow.isRepository(config.baseDir).pipe( - Effect.catchIf(isGitMissing, () => Effect.succeed(false)), - Effect.map((isRepository) => - isRepository ? Option.none() : Option.some(path.resolve(config.baseDir, "scratch")), + gitWorkflow.repositoryRoot(config.baseDir).pipe( + Effect.catchIf(isGitMissing, () => Effect.succeed(null)), + Effect.flatMap( + (repositoryRoot): Effect.Effect> => { + if (repositoryRoot === null) { + return Effect.succeed(Result.succeed(path.resolve(config.baseDir, "scratch"))); + } + const unavailable = new ScratchUnavailableError({ + baseDir: config.baseDir, + repositoryRoot, + }); + return Effect.logWarning(unavailable.message).pipe(Effect.as(Result.fail(unavailable))); + }, ), Effect.catchCause((cause) => - Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(Option.none()), + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.succeed(Result.fail(new ScratchUnavailableError({ baseDir: config.baseDir }))), ), ), Duration.infinity, ); - const scratchRoot = probe.pipe(Effect.onInterrupt(() => invalidate)); + const scratchProbe = probe.pipe(Effect.onInterrupt(() => invalidate)); + const scratchRoot = scratchProbe.pipe(Effect.map(Result.getSuccess)); const makeScratchFolder = (folder: string) => fileSystem @@ -252,14 +271,7 @@ const make = Effect.gen(function* () { const ensureScratchProject: ManagedProjectFolders["Service"]["ensureScratchProject"] = Effect.gen( function* () { - const workspaceRoot = yield* scratchRoot.pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new ScratchUnavailableError()), - onSome: Effect.succeed, - }), - ), - ); + const workspaceRoot = yield* scratchProbe.pipe(Effect.flatMap(Effect.fromResult)); yield* makeScratchFolder(workspaceRoot); const id = yield* random .uuidV4() diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index ed775e55a985a..0d0a614e467ab 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -16,6 +16,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as ProjectCloneTracker from "./ProjectCloneTracker.ts"; +import { ProjectConflictError } from "./ProjectService.ts"; import { parseGitCloneProgressLine } from "./gitCloneProgress.ts"; const projectId = ProjectId.make("project-1"); @@ -282,6 +283,35 @@ describe("ProjectCloneTracker", () => { }).pipe(Effect.provide(harness.layer)); }); + it.effect("clones for the project that already owns the empty destination", () => { + const owner = ProjectId.make("project-stranded"); + const harness = makeHarness(); + const hooks: ProjectCloneTracker.ProjectCloneHooks = { + ...harness.hooks, + createProject: (input) => + Effect.fail( + new ProjectConflictError({ + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + conflictingProjectId: owner, + }), + ), + }; + return Effect.gen(function* () { + const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; + const result = yield* tracker.start(startInput, hooks); + expect(result.projectId).toBe(owner); + yield* Effect.yieldNow; + expect(yield* tracker.get(owner)).toMatchObject({ + projectId: owner, + destinationPath: "/workspace/t3code", + phase: "done", + }); + expect(harness.cloned).toEqual([owner]); + expect(yield* tracker.get(projectId)).toBeNull(); + }).pipe(Effect.provide(harness.layer)); + }); + it.effect("does not create a project when the clone cannot be prepared", () => { const harness = makeHarness(); const layer = ProjectCloneTracker.layer.pipe( diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 0a512bde2aa9b..0a24f11ffd89d 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -31,6 +31,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import type { ProjectConflictError } from "./ProjectService.ts"; export class ProjectCloneTracker extends Context.Service< ProjectCloneTracker, @@ -53,12 +54,13 @@ export class ProjectCloneTracker extends Context.Service< >()("t3/project/ProjectCloneTracker") {} export interface ProjectCloneHooks { + /** Fails with `ProjectConflictError` when another project already owns the destination. */ readonly createProject: (input: { readonly projectId: ProjectId; readonly title: string; readonly workspaceRoot: string; readonly createdAt: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly onCloned: (input: { readonly projectId: ProjectId; readonly workspaceRoot: string; @@ -266,6 +268,16 @@ export const make = Effect.gen(function* () { }); }); + const moveClaim = (from: ProjectId, to: ProjectId) => + Ref.modify(clones, (current) => { + const claim = current.get(from); + if (!claim || current.has(to)) return [false, current] as const; + const next = new Map(current); + next.delete(from); + next.set(to, { ...claim, snapshot: { ...claim.snapshot, projectId: to } }); + return [true, next] as const; + }); + const start: ProjectCloneTracker["Service"]["start"] = Effect.fn("ProjectCloneTracker.start")( function* (input, hooks) { const prepared = yield* repositories.prepareClone(input); @@ -306,30 +318,44 @@ export const make = Effect.gen(function* () { }); return [true, next] as const; }); - if (!claimed) { - return yield* new SourceControlRepositoryError({ - operation: "cloneRepository", - provider: input.provider ?? SourceControlProviderKind.make("unknown"), - detail: "A clone into this destination is already in progress.", - }); - } + const alreadyInProgress = new SourceControlRepositoryError({ + operation: "cloneRepository", + provider: input.provider ?? SourceControlProviderKind.make("unknown"), + detail: "A clone into this destination is already in progress.", + }); + if (!claimed) return yield* alreadyInProgress; // Finish or release the claim despite disconnects; a claim without a fiber cannot be cancelled or retried. - yield* Effect.uninterruptible( + const projectId = yield* Effect.uninterruptible( Effect.gen(function* () { yield* clearRetention(input.projectId); // Register before creation so racing thread.create and project.delete see the clone. - yield* hooks.createProject({ - projectId: input.projectId, - title: input.title, - workspaceRoot: prepared.destinationPath, - createdAt: input.createdAt, - }); + const projectId = yield* hooks + .createProject({ + projectId: input.projectId, + title: input.title, + workspaceRoot: prepared.destinationPath, + createdAt: input.createdAt, + }) + .pipe( + Effect.as(input.projectId), + // A failed clone's project still owns its empty folder after a restart + // forgets the clone; clone for that project instead. + Effect.catchTags({ + ProjectConflictError: (conflict) => + moveClaim(input.projectId, conflict.conflictingProjectId).pipe( + Effect.flatMap((moved) => + moved ? Effect.succeed(conflict.conflictingProjectId) : alreadyInProgress, + ), + ), + }), + ); yield* publish; - yield* launch(input.projectId); + yield* launch(projectId); + return projectId; }).pipe(Effect.tapError(() => remove(input.projectId))), ); return { - projectId: input.projectId, + projectId, cwd: prepared.destinationPath, remoteUrl: prepared.remoteUrl, repository: prepared.repository, diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 6da9ae31203bf..1db904c3b4c4d 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -1,10 +1,13 @@ import { assert, it } from "@effect/vitest"; import type { RepositoryIdentity } from "@t3tools/contracts"; 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 PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -257,3 +260,131 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r ); }), ); + +it.effect( + "keeps the last resolved repository identity after it expires and while it refreshes", + () => + Effect.gen(function* () { + const refreshStarted = yield* Deferred.make(); + const releaseRefresh = yield* Deferred.make(); + const resolveCalls = yield* Ref.make(0); + const layerMetadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => + Effect.gen(function* () { + if ((yield* Ref.updateAndGet(resolveCalls, (count) => count + 1)) > 1) { + yield* Deferred.succeed(refreshStarted, undefined); + yield* Deferred.await(releaseRefresh); + } + return identity(workspaceRoot); + }), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, + resolvePath: () => Effect.succeed(null), + }), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectEnrichment.ProjectEnrichmentService; + const changes = yield* service.subscribeChanges; + yield* service.request("/widgets"); + const resolved = yield* PubSub.take(changes); + assert.equal( + resolved.enrichment.repositoryIdentity?.canonicalKey, + "example.test/v1/widgets", + ); + + yield* TestClock.adjust(Duration.seconds(61)); + + const expired = yield* service.getAvailable("/widgets"); + assert.equal( + expired.repositoryIdentity?.canonicalKey, + "example.test/v1/widgets", + "an expired repository identity should still be returned until a refresh replaces it", + ); + + yield* Deferred.await(refreshStarted); + const refreshing = yield* service.peek("/widgets"); + assert.equal( + refreshing.repositoryIdentity?.canonicalKey, + "example.test/v1/widgets", + "a repository identity should still be returned while its refresh is pending", + ); + }).pipe(Effect.provide(layer(layerMetadata))); + }), +); + +it.effect("does not let a lookup invalidated mid-flight replace a newer last identity", () => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const resolveCalls = yield* Ref.make(0); + const layerMetadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(resolveCalls, (count) => count + 1); + if (call === 1) { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(releaseFirst); + } + return identity(workspaceRoot, call); + }), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, + resolvePath: () => Effect.succeed(null), + }), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectEnrichment.ProjectEnrichmentService; + const firstLookup = yield* Effect.forkChild(service.readRepositoryIdentity("/widgets")); + yield* Deferred.await(firstStarted); + yield* service.invalidate(["/widgets"]); + const newer = yield* service.readRepositoryIdentity("/widgets"); + assert.equal(newer?.canonicalKey, "example.test/v2/widgets"); + + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(firstLookup); + yield* TestClock.adjust(Duration.seconds(61)); + + const expired = yield* service.peek("/widgets"); + assert.equal(expired.repositoryIdentity?.canonicalKey, "example.test/v2/widgets"); + }).pipe(Effect.provide(layer(layerMetadata))); + }), +); + +it.effect("keeps a lookup's identity when another root is invalidated mid-flight", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const layerMetadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(identity(workspaceRoot)), + ), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, + resolvePath: () => Effect.succeed(null), + }), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectEnrichment.ProjectEnrichmentService; + const lookup = yield* Effect.forkChild(service.readRepositoryIdentity("/widgets")); + yield* Deferred.await(started); + yield* service.invalidate(["/other"]); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(lookup); + yield* TestClock.adjust(Duration.seconds(61)); + + const expired = yield* service.peek("/widgets"); + assert.equal(expired.repositoryIdentity?.canonicalKey, "example.test/v1/widgets"); + }).pipe(Effect.provide(layer(layerMetadata))); + }), +); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 538beed6f1f06..f609bc53fa39a 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -91,8 +91,44 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( const maxPending = Math.max(1, options.maxPending ?? DEFAULT_MAX_PENDING); const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY); + // Outlives the cache entry so an expired or refreshing identity still reads as its last value. + const lastRepositoryIdentities = new Map(); + // `invalidate` drops a root's in-flight lookup so its late result cannot overwrite a newer one. + const lookupsInFlight = new Map(); + const rememberRepositoryIdentity = ( + workspaceRoot: string, + repositoryIdentity: RepositoryIdentity | null, + ) => { + lastRepositoryIdentities.delete(workspaceRoot); + if (repositoryIdentity === null) return; + if (lastRepositoryIdentities.size >= cacheCapacity) { + const oldestRoot = lastRepositoryIdentities.keys().next().value; + if (oldestRoot !== undefined) lastRepositoryIdentities.delete(oldestRoot); + } + lastRepositoryIdentities.set(workspaceRoot, repositoryIdentity); + }; + const repositoryIdentityCache = yield* Cache.makeWith( - (workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)), + (workspaceRoot: string) => + Effect.suspend(() => { + const lookup = {}; + lookupsInFlight.set(workspaceRoot, lookup); + const isCurrent = () => lookupsInFlight.get(workspaceRoot) === lookup; + return Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)).pipe( + Effect.tap((result) => + Effect.sync(() => { + if (Exit.isSuccess(result) && isCurrent()) { + rememberRepositoryIdentity(workspaceRoot, result.value); + } + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (isCurrent()) lookupsInFlight.delete(workspaceRoot); + }), + ), + ); + }), { capacity: cacheCapacity, timeToLive: Exit.match({ @@ -238,10 +274,13 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( ] as const, { concurrency: "unbounded" }, ); + const repositoryIdentityResolved = isSuccessfullyResolved(repositoryIdentity); return { - repositoryIdentity: availableValue(repositoryIdentity), + repositoryIdentity: repositoryIdentityResolved + ? availableValue(repositoryIdentity) + : (lastRepositoryIdentities.get(workspaceRoot) ?? null), faviconPath: availableValue(faviconPath), - repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), + repositoryIdentityResolved, }; }, ); @@ -287,6 +326,10 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( Effect.all( [ Cache.invalidate(repositoryIdentityCache, workspaceRoot), + Effect.sync(() => { + lookupsInFlight.delete(workspaceRoot); + lastRepositoryIdentities.delete(workspaceRoot); + }), Cache.invalidate(faviconCache, workspaceRoot), // The resolver keeps its own misses, which would outlive a clone filling the folder. faviconResolver.invalidate(workspaceRoot), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 988578f49d101..1517bed31ba23 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -205,8 +205,8 @@ const layerProjectServiceDependencies = Layer.mergeAll( Layer.provideMerge(layerMetadata), Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSettings.layerTest()), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), - Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* ( diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 9afaf22087a33..70f12adf05f1f 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -12,6 +12,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; 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 Result from "effect/Result"; @@ -32,6 +33,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -142,6 +144,11 @@ export class ProjectService extends Context.Service< workspaceRoot: string, options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; + /** + * Where to scan `cwd`'s workspace inventory. A thread's removed managed worktree scans its + * project, which turn start recreates the worktree from; anything else, or a failure, is `cwd`. + */ + readonly workspaceScanDirectory: (cwd: string) => Effect.Effect; readonly snapshot: Effect.Effect; /** * A page of active projects in creation order, each with its repository @@ -173,6 +180,8 @@ export const make = Effect.gen(function* () { const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const serverSettings = yield* ServerSettings.ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const isManagedWorktreePath = yield* makeIsManagedWorktreePath; const projectLocks = yield* KeyedLock.make(); const workspaceLocks = yield* KeyedLock.make(); @@ -331,6 +340,22 @@ export const make = Effect.gen(function* () { }, ); + const workspaceScanDirectory = Effect.fn("ProjectService.workspaceScanDirectory")( + function* (cwd: string) { + if (yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true))) return cwd; + if (!(yield* isManagedWorktreePath(cwd))) return cwd; + const snapshot = yield* Effect.flatten( + threadProjections.readShellSnapshot({ worktreePath: cwd }), + ); + const thread = [...snapshot.threads, ...snapshot.archivedThreads].find( + (candidate) => candidate.branch !== null, + ); + if (thread === undefined) return cwd; + return Option.getOrUndefined(yield* readRow(thread.projectId))?.workspaceRoot ?? cwd; + }, + (effect, cwd) => Effect.orElseSucceed(effect, () => cwd), + ); + const getByWorkspaceRoot: ProjectService["Service"]["getByWorkspaceRoot"] = Effect.fn( "ProjectService.getByWorkspaceRoot", )(function* (workspaceRoot, options) { @@ -611,6 +636,7 @@ export const make = Effect.gen(function* () { delete: deleteProject, getById, getByWorkspaceRoot, + workspaceScanDirectory, snapshot, listPage, getShell, diff --git a/apps/server/src/provider/AcpRegistrySessions.ts b/apps/server/src/provider/AcpRegistrySessions.ts new file mode 100644 index 0000000000000..9cf869cb78123 --- /dev/null +++ b/apps/server/src/provider/AcpRegistrySessions.ts @@ -0,0 +1,229 @@ +import { + AcpRegistryOperationError, + CommandId, + type AcpRegistryImportSessionInput, + type AcpRegistryImportSessionResult, + type Project, + type ProjectId, + type ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; + +import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; +import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "./ProviderRegistry.ts"; + +export const importedThreadId = (input: { + readonly driver: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly sessionId: string; +}) => + IdAllocator.deriveThreadFromProviderThread({ + driver: input.driver, + providerInstanceId: input.instanceId, + nativeThreadId: input.sessionId, + }); + +export class AcpRegistrySessions extends Context.Service< + AcpRegistrySessions, + { + readonly project: (projectId: ProjectId) => Effect.Effect; + readonly sessionManager: (instanceId: ProviderInstanceId) => Effect.Effect< + { + readonly instance: ProviderInstance; + readonly manager: NonNullable; + }, + AcpRegistryOperationError + >; + /** + * Binds a native ACP session to a T3 thread and copies its history in. Importing a + * session whose thread still lacks its history repairs that thread instead. + */ + readonly importSession: ( + input: AcpRegistryImportSessionInput, + ) => Effect.Effect; + } +>()("t3/provider/AcpRegistrySessions") {} + +const make = Effect.gen(function* () { + const projectService = yield* ProjectService.ProjectService; + const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter; + const random = yield* SecureRandom.SecureRandom; + + const project = Effect.fn("AcpRegistrySessions.project")(function* (projectId: ProjectId) { + const found = yield* projectService.getById(projectId).pipe( + Effect.mapError( + (cause) => + new AcpRegistryOperationError({ + reason: "project_not_found", + message: `Project ${projectId} is unavailable.`, + cause, + }), + ), + ); + return yield* Effect.fromOption( + found, + () => + new AcpRegistryOperationError({ + reason: "project_not_found", + message: `Project ${projectId} was not found.`, + }), + ); + }); + + const sessionManager = Effect.fn("AcpRegistrySessions.sessionManager")(function* ( + instanceId: ProviderInstanceId, + ) { + const instance = yield* providerInstances.getInstance(instanceId); + if (instance === undefined) { + return yield* new AcpRegistryOperationError({ + reason: "instance_not_found", + message: `Provider instance ${instanceId} was not found.`, + }); + } + if (instance.acpSessionManagement === undefined) { + return yield* new AcpRegistryOperationError({ + reason: "session_list_unsupported", + message: `Provider instance ${instanceId} does not expose ACP session management.`, + }); + } + return { instance, manager: instance.acpSessionManagement }; + }); + + const importSession = Effect.fn("AcpRegistrySessions.importSession")(function* ( + input: AcpRegistryImportSessionInput, + ) { + const { workspaceRoot } = yield* project(input.projectId); + const { instance } = yield* sessionManager(input.instanceId); + const providerSnapshot = yield* instance.snapshot.getSnapshot; + if ( + providerSnapshot.nativeSessions?.canLoad !== true && + providerSnapshot.nativeSessions?.canResume !== true + ) { + return yield* new AcpRegistryOperationError({ + reason: "session_resume_unsupported", + message: "The ACP agent cannot load or resume native sessions.", + }); + } + const threadId = importedThreadId({ + driver: instance.driverKind, + instanceId: input.instanceId, + sessionId: input.sessionId, + }); + const existing = yield* threadManagement.getThreadShell(threadId).pipe( + Effect.mapError( + (cause) => + new AcpRegistryOperationError({ + reason: "session_import_failed", + message: "Could not inspect the imported ACP session mapping.", + cause, + }), + ), + ); + if (existing !== null) { + const repairing = AgentSessionImporter.isNativeHistoryPending({ + historyOrigin: existing.historyOrigin, + hasRun: existing.latestRunId !== null, + }); + yield* agentSessionImporter.importNativeHistory({ threadId, workspaceRoot }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryOperationError({ + reason: "session_import_failed", + message: "Could not import the ACP session history.", + cause, + }), + ), + ); + return { threadId, imported: repairing } as const; + } + + const provider = (yield* providerRegistry.getProviders).find( + (candidate) => candidate.instanceId === input.instanceId, + ); + const model = + provider?.models.find((candidate) => candidate.isDefault)?.slug ?? + provider?.models[0]?.slug ?? + "default"; + const commandId = CommandId.make(yield* random.uuidV4()); + const launched = yield* Effect.result( + startup.enqueueCommand( + threadLaunch.launch({ + commandId, + threadId, + projectId: input.projectId, + title: input.title ?? "Imported ACP session", + modelSelection: { instanceId: input.instanceId, model }, + runtimeMode: "approval-required", + interactionMode: "default", + workspaceStrategy: { type: "root" }, + importedNativeThread: { + ref: { + driver: instance.driverKind, + nativeId: input.sessionId, + strength: "strong", + }, + metadata: { + itemIdentityVersion: 2, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.updatedAt === undefined ? {} : { updatedAt: input.updatedAt }), + }, + }, + createdBy: "user", + creationSource: "web", + }), + ), + ); + if (Result.isFailure(launched)) { + const racedImport = yield* threadManagement.getThreadShell(threadId).pipe( + Effect.mapError( + (cause) => + new AcpRegistryOperationError({ + reason: "session_import_failed", + message: "Could not inspect the imported ACP session after launch failed.", + cause, + }), + ), + ); + if (racedImport !== null) return { threadId, imported: false } as const; + return yield* new AcpRegistryOperationError({ + reason: "session_import_failed", + message: "Could not create a T3 thread for the ACP session.", + cause: launched.failure, + }); + } + // The thread is usable without its history, so a failed read only logs. + yield* agentSessionImporter.importNativeHistory({ threadId, workspaceRoot }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import ACP session history", { + instanceId: input.instanceId, + sessionId: input.sessionId, + cause, + }), + ), + ); + return { threadId, imported: true } as const; + }, coordinator.withSessionMutation); + + return AcpRegistrySessions.of({ project, sessionManager, importSession }); +}); + +export const layer = Layer.effect(AcpRegistrySessions, make); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index e8a4120b7d08c..bc354129b0337 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -41,11 +41,16 @@ import { } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; import { + claudeSignedOutMessage, hasClaudeManagedMcpConfig, makeClaudeEnvironment, resolveClaudeHomePath, } from "./Drivers/ClaudeHome.ts"; -import { discoverClaudeSkills, readClaudeSettingsEnv } from "./Drivers/ClaudeSkills.ts"; +import { + customClaudeApiEndpointHost, + discoverClaudeSkills, + readClaudeSettingsEnv, +} from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { @@ -706,13 +711,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const baseUrl = (yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ?? resolvedEnvironment.ANTHROPIC_BASE_URL; - const endpoint = URL.parse(baseUrl?.trim() ?? ""); - const customApiEndpoint = - endpoint !== null && - (endpoint.protocol === "http:" || endpoint.protocol === "https:") && - endpoint.hostname !== "" && - endpoint.hostname !== "api.anthropic.com"; + const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined; if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") { + const path = yield* Path.Path; + const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment)) + .CLAUDE_CONFIG_DIR; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -727,7 +730,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: "error", auth: { status: "unauthenticated" }, message: - "Claude Agent CLI is not authenticated. Sign in from the Setup section or run `claude auth login` on this environment.", + configDir === undefined + ? "Claude Code is not authenticated. Run `claude auth login` and try again." + : claudeSignedOutMessage({ configDir, cwd: path.resolve(cwd ?? ".") }), }, }); } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 70bc4db40fc93..ed3fe6d7508da 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -40,7 +40,7 @@ describe("Codex runtime context", () => { }); NodeAssert.match( instructions, - /.*embed images, video, and audio.*Markdown image syntax with absolute file paths.*Link any other file.*absolute path.*files viewer.*<\/runtime_info>/, + /.*embed images, video, and audio.*Markdown image syntax with absolute file paths.*Link every other file you cite or produce by its absolute path.*files viewer.*<\/runtime_info>/, ); } }); diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318e..c9a992bcf5160 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + codexRoutesSpendNativeQuota, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -153,3 +157,28 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("reports native quota only for an OpenAI login on a native route", () => { + const chatgpt = { type: "chatgpt", email: "a@example.com", planType: "pro" } as const; + const hub = { + model_provider: "hub", + model_providers: { hub: { base_url: "https://hub.example.com/v1" } }, + }; + const cases = [ + ["default route", chatgpt, true, {}, true], + ["openai base url", chatgpt, true, { openai_base_url: "https://chatgpt.com/x" }, true], + ["openai proxy url", chatgpt, true, { openai_base_url: "https://proxy.example.com" }, false], + ["custom provider", chatgpt, true, hub, false], + ["unknown provider id", chatgpt, true, { model_provider: "missing" }, false], + ["unreadable config", chatgpt, true, null, false], + ["login not required", chatgpt, false, {}, false], + ["api key", { type: "apiKey" }, true, {}, false], + ] as const; + for (const [name, account, requiresOpenaiAuth, config, expected] of cases) { + assert.strictEqual( + codexRoutesSpendNativeQuota({ account, requiresOpenaiAuth }, config), + expected, + name, + ); + } +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 0df2cac1341bd..db408154df0d5 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -70,6 +70,44 @@ type CodexRateLimitsProbe = } | { readonly failure: string }; +const CodexRoutingConfig = Schema.Struct({ + model_provider: Schema.optionalKey(Schema.NullOr(Schema.String)), + openai_base_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + model_providers: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ base_url: Schema.optionalKey(Schema.NullOr(Schema.String)) }), + ), + ), +}); +const decodeCodexRoutingConfig = Schema.decodeUnknownOption(CodexRoutingConfig); + +function routesToNativeOpenai(config: unknown): boolean { + const decoded = decodeCodexRoutingConfig(config); + if (Option.isNone(decoded)) return false; + const routing = decoded.value; + const providerId = routing.model_provider ?? "openai"; + const provider = routing.model_providers?.[providerId]; + if (providerId !== "openai" && !provider) return false; + const baseUrl = + providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url; + // A provider without an override uses Codex's auth-dependent OpenAI default. + if (baseUrl == null) return true; + if (!URL.canParse(baseUrl)) return false; + const url = new URL(baseUrl); + return ( + url.protocol === "https:" && + (url.hostname === "api.openai.com" || url.hostname === "chatgpt.com") + ); +} + +// A stored login does not mean this provider's traffic spends that login's quota. +export const codexRoutesSpendNativeQuota = ( + account: CodexSchema.V2GetAccountResponse, + config: unknown, +): boolean => + account.requiresOpenaiAuth && account.account?.type !== "apiKey" && routesToNativeOpenai(config); + const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; @@ -90,6 +128,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly nativeUsage?: boolean; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; readonly models: ReadonlyArray | undefined; @@ -526,6 +565,34 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu return { client, initialize }; }); +// Usage is an enrichment: a failure or a slow answer degrades to "no usage this +// probe" rather than costing the account and models. +const readCodexRateLimits = ( + client: CodexClient.CodexAppServerClient["Service"], + deadlineMs: number, +) => + withCodexDiscoveryDeadline( + client.request("account/rateLimits/read", null), + deadlineMs, + RATE_LIMITS_PROBE_TIMEOUT_MS, + ).pipe( + Effect.map((response): CodexRateLimitsProbe => + response + ? { + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, + } + : { failure: "Codex did not answer the usage request." }, + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), + ); + const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; @@ -552,7 +619,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Optional discovery must finish before the outer timeout can discard the account result. const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; - const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( + const [skillsResponse, models, { serviceTier, nativeUsage, rateLimits }] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" @@ -569,40 +636,32 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun deadlineMs, CODEX_CONFIG_PROBE_TIMEOUT_MS, ).pipe( - Effect.map((response) => response?.config.service_tier ?? undefined), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), + Effect.flatMap((config) => { + // Only a confirmed native route may probe or publish the login's quota. + const nativeUsage = + !input.skipNativeUsage && + config !== undefined && + codexRoutesSpendNativeQuota(accountResponse, config.config); + const readUsage: Effect.Effect = nativeUsage + ? readCodexRateLimits(client, deadlineMs) + : Effect.undefined; + return readUsage.pipe( + Effect.map((rateLimits) => ({ + serviceTier: config?.config.service_tier ?? undefined, + nativeUsage, + rateLimits, + })), + ); + }), ), - // Usage is an enrichment: a failure or a slow answer degrades to "no - // usage this probe" rather than costing the account and models. - input.skipNativeUsage - ? Effect.undefined - : withCodexDiscoveryDeadline( - client.request("account/rateLimits/read", null), - deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, - ).pipe( - Effect.map((response): CodexRateLimitsProbe => - response - ? { - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - accountId: response.accountId, - } - : { failure: "Codex did not answer the usage request." }, - ), - Effect.catch((error) => - Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( - Effect.as({ failure: codexRateLimitsFailureMessage(error) }), - ), - ), - ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, models, @@ -848,8 +907,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } : accountProbeStatus(snapshot); + // Sign-in guidance still applies when Codex itself says it needs an OpenAI login. const usageLimits = - snapshot.account.account?.type === "apiKey" + !snapshot.nativeUsage && !accountStatus.message ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 1cab7cf5efecd..357604798646f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -418,9 +418,15 @@ export const ClaudeDriver: ProviderDriver = Effect.fn( "ClaudeDriver.consumeResetCredit", )( - function* () { + function* (creditId) { const current = yield* snapshot.getSnapshot; - const grantId = current.usageLimits?.resetCredits?.nextCreditId; + const credits = current.usageLimits?.resetCredits; + const grantId = + creditId === undefined + ? credits?.nextCreditId + : credits?.options?.some((option) => option.creditId === creditId) + ? creditId + : undefined; if (!grantId || !current.version) return "noCredit" as const; const version = current.version; return yield* resetCreditCoordinator.redeem( diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 0d62109cce828..ada8a39e1ed5b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -472,6 +472,17 @@ export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function return settingsEnv; }); +/** The host of an `ANTHROPIC_BASE_URL` that is not Anthropic's own API. */ +export function customClaudeApiEndpointHost(baseUrl: string | undefined): string | undefined { + const endpoint = URL.parse(baseUrl?.trim() ?? ""); + return endpoint !== null && + (endpoint.protocol === "http:" || endpoint.protocol === "https:") && + endpoint.hostname !== "" && + endpoint.hostname !== "api.anthropic.com" + ? endpoint.hostname + : undefined; +} + // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { const directories: string[] = []; diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index aca7696d31df5..acb697d2941de 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -16,9 +16,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; export const CLAUDE_SYNTHETIC_MODEL = ""; /** - * T3 Code writes one record per assistant *content block*, and every one of those records repeats - * the same complete `usage` object for the parent message. Summing them overcounts by roughly - * 2.4x on a real workload, so the caller must drop repeats by `dedupeKey` and keep the first. + * Claude Code repeats a message's `dedupeKey` for each assistant content block. + * Subagent blocks can progress from stream-start to final usage, so callers + * must count each key once using the maximum of each token channel. */ export function parseClaudeLine(line: string): readonly UsageRecord[] { let parsed: unknown; diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 9be799112cc23..be899ae540037 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -478,8 +478,8 @@ describe("Grok usage limits", () => { }; it("shows zero usage for complete weekly and monthly periods with no percentage", () => { - for (const [currentPeriod, kind, label, resetsAt] of [ - [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z"], + for (const [currentPeriod, kind, label, resetsAt, windowDurationMins] of [ + [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z", 7 * 24 * 60], [ { type: "USAGE_PERIOD_TYPE_MONTHLY", @@ -489,12 +489,15 @@ describe("Grok usage limits", () => { "monthly", "Monthly", "2026-10-01T00:00:00.000Z", + 30 * 24 * 60, ], ] as const) { const response = { config: { currentPeriod } }; expect .soft(grokUsageResponseToLimits(response, checkedAt).windows) - .toEqual([{ id: "subscription", kind, label, usedPercent: 0, resetsAt }]); + .toEqual([ + { id: "subscription", kind, label, usedPercent: 0, resetsAt, windowDurationMins }, + ]); } }); @@ -551,6 +554,60 @@ describe("Grok usage limits", () => { ).windows, ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); }); + + it("measures the window from the reported period bounds", () => { + const window = (type: string, start: string, end: string) => + grokUsageResponseToLimits( + { config: { creditUsagePercent: 4, currentPeriod: { type, start, end } } }, + checkedAt, + ).windows[0]; + expect( + window( + "USAGE_PERIOD_TYPE_WEEKLY", + "2026-10-14T20:51:37.352+00:00", + "2026-10-21T20:51:37.352+00:00", + ), + ).toEqual({ + id: "subscription", + kind: "weekly", + label: "Weekly", + usedPercent: 4, + resetsAt: "2026-10-21T20:51:37.352Z", + windowDurationMins: 7 * 24 * 60, + }); + expect( + window( + "USAGE_PERIOD_TYPE_MONTHLY", + "2026-02-01T00:00:00.000+00:00", + "2026-03-01T00:00:00.000+00:00", + )?.windowDurationMins, + ).toBe(28 * 24 * 60); + }); + + it("leaves the window length unknown when the period start is unusable", () => { + const end = "2026-10-21T20:51:37.352+00:00"; + for (const start of [ + undefined, + null, + 42, + "invalid", + "2026-02-30T00:00:00.000Z", + end, + "2026-10-28T20:51:37.352+00:00", + ]) { + const [window] = grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 4, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end }, + }, + }, + checkedAt, + ).windows; + expect(window?.resetsAt).toBe("2026-10-21T20:51:37.352Z"); + expect(window).not.toHaveProperty("windowDurationMins"); + } + }); }); it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount", (it) => { diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index c8199c8d11437..5a217b61b9d63 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -526,7 +526,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const codex = yield* registry.getInstance(codexId); expect(codex).toBeDefined(); yield* codex!.snapshot.refresh; - expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); + expect(yield* codex!.consumeResetCredit!(undefined)).toBe("alreadyRedeemed"); }).pipe(Effect.provide(layerTest)), ); @@ -831,7 +831,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const before = yield* instance!.snapshot.refresh; expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); - const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); + const outcome = yield* instance!.consumeResetCredit!(undefined).pipe(Effect.result); return { outcome, after: yield* instance!.snapshot.getSnapshot }; }).pipe( // macOS logins live in the Keychain, where resets are never read. diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 037f12d09605a..396d1d4792867 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1927,6 +1927,97 @@ it.layer( }), ); + it.effect("scans scanCwd and keys the workspace snapshot by cwd", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const scannedDirectories = yield* Ref.make>([]); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd: (directory) => + Ref.update(scannedDirectories, (directories) => [...directories, directory]).pipe( + Effect.as({ + ...provider, + skills: [{ name: "finalize", path: `${directory}/finalize`, enabled: true }], + }), + ), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-scan-cwd-", + }), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const providers = yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/worktree", + scanCwd: "/project", + }); + assert.deepStrictEqual(yield* Ref.get(scannedDirectories), ["/project"]); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots + ?.find((snapshot) => snapshot.cwd === "/worktree") + ?.skills?.map((skill) => skill.path), + ["/project/finalize"], + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -3541,6 +3632,64 @@ it.layer( ), ); + it.effect.each([ + { + name: "its own home path", + homePath: "/srv/claude-personal", + environment: { ANTHROPIC_BASE_URL: "" }, + configDir: "/srv/claude-personal", + }, + { + name: "an inherited CLAUDE_CONFIG_DIR", + homePath: "", + environment: { ANTHROPIC_BASE_URL: "", CLAUDE_CONFIG_DIR: "/srv/claude-work" }, + configDir: "/srv/claude-work", + }, + { + name: "the default config dir", + homePath: "", + environment: { ANTHROPIC_BASE_URL: "" }, + configDir: undefined, + }, + ])( + "tells a logged-out instance on $name where to sign in", + ({ homePath, environment, configDir }) => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, homePath }, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + environment, + ); + assert.strictEqual(status.auth.status, "unauthenticated"); + if (configDir === undefined) { + assert.strictEqual( + status.message, + "Claude Code is not authenticated. Run `claude auth login` and try again.", + ); + } else { + // A home path is resolved to a native path; an inherited value is kept literal. + const expected = homePath ? (yield* Path.Path).resolve(configDir) : configDir; + assert.include( + status.message ?? "", + `CLAUDE_CONFIG_DIR set to ${JSON.stringify(expected)}`, + "a bare `claude auth login` signs in ~/.claude, not this instance's config dir", + ); + } + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("tells a logged-out CLI which models need a Claude Code update", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index d55b8723dc6b2..5d6609ba0ea1b 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -78,6 +78,8 @@ export class ProviderRegistry extends Context.Service< readonly refreshWorkspaceSnapshot: (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; + /** Directory the driver scans; the snapshot stays keyed by `cwd`. Defaults to `cwd`. */ + readonly scanCwd?: string; readonly fresh?: boolean; }) => Effect.Effect>; @@ -976,6 +978,7 @@ export const layer = Layer.effect( const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; + readonly scanCwd?: string; readonly fresh?: boolean; }) { if (input.fresh) { @@ -1014,7 +1017,7 @@ export const layer = Layer.effect( ) : Effect.void; const runScan = refreshMachineSnapshot.pipe( - Effect.andThen(instance.snapshotForCwd(input.cwd)), + Effect.andThen(instance.snapshotForCwd(input.scanCwd ?? input.cwd)), // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => instanceRegistry.getInstance(input.instanceId).pipe( diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 64ddb5d42525e..702f0630eb411 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 6515, - "codexAdditionalContextBytes": 8245, - "mcpToolListJsonBytes": 78539 + "claudeSystemAppendBytes": 6536, + "codexAdditionalContextBytes": 8266, + "mcpToolListJsonBytes": 78453 } diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 612ea11efcefa..9defd9df90dce 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -65,7 +65,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( userId?: string; userInfoAt?: number; missingToken?: boolean; - authMethod?: "oauth-personal" | "gemini-api-key"; + authMethod?: "oauth-personal" | "oauth-business" | "gemini-api-key"; } = {}, ) { const fs = yield* FileSystem.FileSystem; @@ -330,11 +330,12 @@ describe("Antigravity subscription limits", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - // Oracle: the existing endpoint merge keeps the most-used reading. - it.effect("keeps the used endpoint reading and its reset over an untouched reading", () => + // Oracle: workspace sign-ins are metered on cloudcode-pa, so its reading wins even when less used. + it.effect("reports a business account's quota from the endpoint it is metered on", () => Effect.gen(function* () { yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-21T10:16:04Z"))); const { probe } = yield* fixture({ + authMethod: "oauth-business", summaryByHost: { "daily-cloudcode-pa.googleapis.com": { buckets: [ @@ -383,9 +384,8 @@ describe("Antigravity subscription limits", () => { id: "3p-weekly", label: "Claude & GPT (shared) · Weekly", kind: "weekly", - usedPercent: 50, + usedPercent: 0, windowDurationMins: 10080, - resetsAt: "2026-10-27T10:16:04.000Z", }, ]); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), @@ -474,11 +474,47 @@ describe("Antigravity subscription limits", () => { const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(70); - expect(byId.get("3p-weekly")?.usedPercent).toBe(100); + expect(byId.get("3p-weekly")?.usedPercent).toBe(50); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("labels Argon's weekly quota apart from the default Gemini weekly quota", () => + // Oracle: reported payloads for a personal sign-in, whose runtime meters on the daily endpoint. + it.effect("reports a personal account's quota from the daily endpoint it is metered on", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-07T12:00:00Z"))); + const argon = (remainingFraction: number, resetTime: string) => ({ + groups: [ + { + displayName: "Gemini 4 Argon", + buckets: [ + { + bucketId: "gemini-argon-wtu-weekly", + window: "weekly", + remainingFraction, + resetTime, + }, + ], + }, + ], + }); + const { probe } = yield* fixture({ + authMethod: "oauth-personal", + summaryByHost: { + "daily-cloudcode-pa.googleapis.com": argon(0.8623059, "2026-11-14T08:41:35Z"), + "cloudcode-pa.googleapis.com": argon(0, "2026-11-08T18:27:14Z"), + }, + }); + expect((yield* probe).windows).toEqual([ + expect.objectContaining({ + id: "gemini-argon-wtu-weekly", + usedPercent: expect.closeTo(13.77, 2), + resetsAt: "2026-11-14T08:41:35.000Z", + }), + ]); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("labels Argon and Carbon quotas apart from the default Gemini quota", () => Effect.gen(function* () { const { probe } = yield* fixture({ summary: { @@ -490,6 +526,16 @@ describe("Antigravity subscription limits", () => { { bucketId: "gemini-argon-wtu-weekly", window: "weekly", remainingFraction: 0.6 }, ], }, + { + displayName: "Gemini 4 Carbon", + buckets: [ + { bucketId: "gemini-carbon-wtu-weekly", window: "weekly", remainingFraction: 0.4 }, + ], + }, + { + displayName: " ", + buckets: [{ bucketId: "gemini-carbon-5h", window: "5h", remainingFraction: 0.8 }], + }, ], }, }); @@ -497,6 +543,8 @@ describe("Antigravity subscription limits", () => { const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); expect(byId.get("gemini-argon-wtu-weekly")?.label).toBe("Gemini 4 Argon · Weekly"); + expect(byId.get("gemini-carbon-wtu-weekly")?.label).toBe("Gemini 4 Carbon · Weekly"); + expect(byId.get("gemini-carbon-5h")?.label).toBe("Gemini Carbon · 5-hour"); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 5445c01432a2f..ede0e5c47d20e 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -15,10 +15,18 @@ import { makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; -const ENDPOINTS = [ - "https://daily-cloudcode-pa.googleapis.com", - "https://cloudcode-pa.googleapis.com", -]; +export function antigravityUserAgent(platform: NodeJS.Platform, architecture: NodeJS.Architecture) { + return `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`; +} + +const DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com"; +const PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com"; +// The ACP runtime meters consumer tiers on the daily host and GCP-terms tiers on prod. +// The sign-in method stands in for the tier. +const ENDPOINTS = { + "oauth-personal": [DAILY_ENDPOINT, PROD_ENDPOINT], + "oauth-business": [PROD_ENDPOINT, DAILY_ENDPOINT], +}; const SUMMARY_WINDOW_MINS = new Map([ ["5h", 5 * 60], ["weekly", 7 * 24 * 60], @@ -116,6 +124,22 @@ function summaryResetTime( return untouched ? undefined : bucket.resetTime; } +const sha256 = Effect.fnUntraced(function* (text: string) { + const digest = yield* (yield* Digest.Digest).digest( + Digest.sha256, + new TextEncoder().encode(text), + ); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}); + +/** Keys an account by its Google ID, which matches across machines and hubs; refresh tokens do not. */ +export const antigravityAccountFingerprint = (userInfo: unknown) => + decodeUserInfo(userInfo).pipe(Effect.flatMap((info) => sha256(`google-account\0${info.id}`))); + +/** Windows from a `retrieveUserQuotaSummary` answer requested at `requestedAt`. */ +export const antigravitySummaryWindows = (response: unknown, requestedAt: number) => + decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt))); + function summaryWindows(summary: typeof Summary.Type, requestedAt: number) { const windows = new Map(); const groups = summary.groups?.some((group) => group.buckets?.length) @@ -124,14 +148,16 @@ function summaryWindows(summary: typeof Summary.Type, requestedAt: number) { for (const group of groups ?? []) { for (const bucket of group.buckets ?? []) { if (bucket.disabled) continue; - // Argon is metered apart from the default Gemini buckets; keep its group name. + // Argon and Carbon are metered separately from the default Gemini buckets. const family = bucket.bucketId.startsWith("gemini-argon-") ? group.displayName?.trim() || "Gemini Argon" - : bucket.bucketId.startsWith("gemini-") - ? "Gemini" - : bucket.bucketId.startsWith("3p-") - ? "Claude & GPT (shared)" - : group.displayName?.trim() || bucket.bucketId; + : bucket.bucketId.startsWith("gemini-carbon-") + ? group.displayName?.trim() || "Gemini Carbon" + : bucket.bucketId.startsWith("gemini-") + ? "Gemini" + : bucket.bucketId.startsWith("3p-") + ? "Claude & GPT (shared)" + : group.displayName?.trim() || bucket.bucketId; const weekly = bucket.window === "weekly"; const session = bucket.window === "5h"; const windowDurationMins = SUMMARY_WINDOW_MINS.get(bucket.window ?? ""); @@ -197,15 +223,6 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( | undefined; let generation = 0; - const sha256 = (text: string) => - crypto - .digest(Digest.sha256, new TextEncoder().encode(text)) - .pipe( - Effect.map((digest) => - Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const request = (request: HttpClientRequest.HttpClientRequest) => client.execute(request).pipe( Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), @@ -253,8 +270,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }), ), ).pipe( - Effect.flatMap(decodeUserInfo), - Effect.flatMap((info) => sha256(`google-account\0${info.id}`)), + Effect.flatMap(antigravityAccountFingerprint), Effect.orElseSucceed(() => fingerprint), ); if (generation === startedGeneration) { @@ -269,7 +285,8 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } - // Merge endpoints because quota groups differ; keep each bucket's most-used reading. + const endpoints = ENDPOINTS[input.authMethod]; + // Merge endpoints because quota groups differ; the metered host's reading of a bucket wins. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, parse: ( @@ -278,7 +295,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ) => Effect.Effect, ) { const responses = yield* Effect.forEach( - ENDPOINTS, + endpoints, (endpoint) => Effect.gen(function* () { // Capture each request after auth, so refresh latency does not widen the tolerance. @@ -287,7 +304,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}`, - "User-Agent": `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`, + "User-Agent": antigravityUserAgent(platform, architecture), }), HttpClientRequest.bodyJsonUnsafe( token.project_id ? { project: token.project_id } : {}, @@ -302,14 +319,11 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ); const windows = new Map(); for (const window of responses.flat()) { - const previous = windows.get(window.id); - if (!previous || window.usedPercent > previous.usedPercent) windows.set(window.id, window); + if (!windows.has(window.id)) windows.set(window.id, window); } return [...windows.values()]; }); - const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response, requestedAt) => - decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt))), - ); + const summary = yield* fetchWindows("retrieveUserQuotaSummary", antigravitySummaryWindows); const windows = summary.length ? summary : yield* fetchWindows("fetchAvailableModels", (response) => @@ -325,6 +339,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account }; }); const probe = read.pipe( + Effect.provideService(Digest.Digest, crypto), Effect.timeout("30 seconds"), Effect.catch(() => Effect.gen(function* () { diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8db751657454c..a3a1110d87d34 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -62,9 +62,51 @@ describe("claudeResetCreditsToContract", () => { availableCount: 2, nextCreditId: "grant_a", nextExpiresAt: "2026-10-01T00:00:00.000Z", + options: [ + expect.objectContaining({ + creditId: "grant_a", + label: "Full reset", + availableCount: 2, + expiresAt: "2026-10-01T00:00:00.000Z", + }), + ], }); }); + it("offers each kind of reset by its own grant", () => { + const credits = ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "weekly_a", + grants: [ + grant({ id: "weekly_a", clears: ["seven_day"], ends_at: "2026-12-10T22:00:00Z" }), + grant({ + id: "fable_a", + clears: ["seven_day_overage_included"], + gives_back_weekly: true, + ends_at: "2027-03-09T07:59:00Z", + }), + grant({ id: "fable_b", gives_back_weekly: true, type: "weekly" }), + grant({ id: "session_a", type: "session" }), + grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }), + ], + }, + NOW, + ); + expect( + credits?.options?.map(({ creditId, label, availableCount }) => [ + creditId, + label, + availableCount, + ]), + ).toEqual([ + ["session_a", "5-hour reset", 1], + ["weekly_a", "Weekly reset", 1], + ["fable_a", "Fable reset", 2], + ]); + expect(credits).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" }); + }); + it("offers nothing to redeem without a usable next grant or an eligible account", () => { expect( ClaudeResetCredits.claudeResetCreditsToContract( @@ -111,7 +153,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); - expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + expect(credits).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); }), ); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edada..e1dbacc34ba71 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -1,5 +1,6 @@ import type { ProviderConsumeResetCreditOutcome, + ServerProviderResetCreditOption, ServerProviderResetCredits, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -31,6 +32,9 @@ const Grant = Schema.Struct({ ends_at: Schema.optional(Schema.NullOr(Schema.String)), paused: Schema.optional(Schema.Boolean), usable_now: Schema.optional(Schema.Boolean), + clears: Schema.optional(Schema.Array(Schema.Unknown)), + type: Schema.optional(Schema.Unknown), + gives_back_weekly: Schema.optional(Schema.Boolean), }); const decodeGrant = Schema.decodeUnknownOption(Grant); const CedarEmber = Schema.Struct({ @@ -95,6 +99,31 @@ const isFutureTimestamp = (value: string, nowMs: number) => { ); }; +// Claude Code's own grouping: a grant that gives back weekly usage is a Fable reset, else its +// declared type, else what it clears. +const RESET_KINDS = { + session: { label: "5-hour reset", description: "Refills your 5-hour limit." }, + weekly: { label: "Weekly reset", description: "Refills your weekly limit." }, + fable: { + label: "Fable reset", + description: + "Refills your Fable allowance and gives back what Fable used on your weekly limit. It doesn't reset your whole week.", + }, + full: { label: "Full reset", description: "Refills your 5-hour and weekly limits." }, +} as const; +type ResetKind = keyof typeof RESET_KINDS; + +const grantKind = (grant: typeof Grant.Type): ResetKind => { + if (grant.gives_back_weekly) return "fable"; + if (grant.type === "session" || grant.type === "weekly" || grant.type === "full") { + return grant.type; + } + const clears = grant.clears ?? []; + const session = clears.includes("five_hour"); + const other = clears.some((limit) => limit !== "five_hour"); + return session && !other ? "session" : other && !session ? "weekly" : "full"; +}; + export function claudeResetCreditsToContract( block: unknown, nowMs: number, @@ -110,13 +139,38 @@ export function claudeResetCreditsToContract( (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), ); const next = live.find((grant) => grant.id === parsed.value.next_grant_id); - const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + const expiresAt = (grant: typeof Grant.Type | undefined) => { + const at = grant?.ends_at ? DateTime.make(grant.ends_at) : Option.none(); + return Option.isSome(at) ? DateTime.formatIso(at.value) : undefined; + }; + const byKind = new Map>(); + for (const grant of live) { + if (grant.resets_left === 0) continue; + const kind = grantKind(grant); + byKind.set(kind, [...(byKind.get(kind) ?? []), grant]); + } + const options = (Object.keys(RESET_KINDS) as Array).flatMap( + (kind): Array => { + const grants = byKind.get(kind); + const target = grants?.[0]; + if (!grants || !target) return []; + const optionExpiresAt = expiresAt(target); + return [ + { + creditId: target.id, + ...RESET_KINDS[kind], + availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), + ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), + }, + ]; + }, + ); + const nextExpiresAt = expiresAt(next); return { availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, - ...(Option.isSome(nextExpiresAt) - ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } - : {}), + ...(nextExpiresAt ? { nextExpiresAt } : {}), ...(next ? { nextCreditId: next.id } : {}), + ...(next ? { options } : {}), }; } diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index dc7166acdf882..9a02a0f7f29e3 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-09T19:07:56Z", + "updatedAt": "2026-11-10T17:25:47Z", "compatibility": [ { "driver": "codex", @@ -132,7 +132,14 @@ "gpt-daybreak-red-latest" ], "claudeAgent": ["claude-fable-5-2", "claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"], - "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] + "antigravity": [ + "gemini-4-carbon", + "gemini-4-argon", + "gemini-3.8-flash", + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low" + ] }, "providers": { "codex": { @@ -881,6 +888,14 @@ }, "profiles": {}, "models": [ + { + "slug": "gemini-4-carbon", + "name": "Gemini 4 Carbon", + "status": "current", + "releasedAt": "2026-11-10" + }, + { "slug": "gemini-4-argon", "name": "Gemini 4 Argon", "status": "current" }, + { "slug": "gemini-3.8-flash", "name": "Gemini 3.8 Flash", "status": "current" }, { "slug": "gemini-3.8-flash-high", "name": "Gemini 3.8 Flash (High)", diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index adf53c5267203..b44b6c3749789 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -978,6 +978,12 @@ describe("providerMaintenanceRunner", () => { }); it.effect.each([ + { + mode: "default-precurrent", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, { mode: "default", version: "1.14.19", @@ -1033,7 +1039,11 @@ it.effect.each([ ...registry, refreshInstance: () => Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, checkedAt: "2026-04-10T00:00:01.000Z" })), + providers.map((provider) => ({ + ...provider, + ...(mode === "default-precurrent" ? installation : {}), + checkedAt: "2026-04-10T00:00:01.000Z", + })), ), getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), refreshAfterUpdate: () => @@ -1059,17 +1069,27 @@ it.effect.each([ const result = yield* updater.updateProvider({ provider: OPENCODE_DRIVER, - ...(mode === "default" ? {} : { targetVersion: "1.14.20" }), + ...(mode.startsWith("default") ? {} : { targetVersion: "1.14.20" }), }); - assert.deepStrictEqual(calls, [ - `npm install -g opencode-ai@${mode === "default" ? "latest" : "1.14.20"}`, - ]); + assert.deepStrictEqual( + calls, + mode === "default-precurrent" + ? [] + : [`npm install -g opencode-ai@${mode.startsWith("default") ? "latest" : "1.14.20"}`], + ); const provider = result.providers[0]; assert.ok(provider); assert.strictEqual(provider.updateState?.status, status); + if (mode === "default" || mode === "default-precurrent" || mode === "targeted") { + assert.strictEqual( + provider.updateState?.message, + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version.", + ); + } assert.strictEqual(provider.version, version); assert.strictEqual("installedVersion" in provider, mode !== "legacy"); + if (mode === "default-precurrent") return; assert.strictEqual( provider.versionAdvisory?.status, mode === "unknown" ? "unknown" : "current", diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index d3fe7fdf86739..b37fbccd8ce20 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -94,6 +94,18 @@ interface VerifiedProviderRefresh { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const OPENCODE_SERVER_RESTART_MESSAGE = + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version."; + +const needsOpenCodeServerRestart = (providers: ReadonlyArray) => + providers.some( + (provider) => + provider.driver === ProviderDriverKind.make("opencode") && + provider.installedVersion != null && + provider.version != null && + provider.installedVersion !== provider.version, + ); + const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( function* (input: { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -527,7 +539,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "succeeded", startedAt, finishedAt: yield* nowIso, - message: "Already up to date.", + message: needsOpenCodeServerRestart(precheck.verifiedProviders) + ? OPENCODE_SERVER_RESTART_MESSAGE + : "Already up to date.", }), ); } @@ -600,7 +614,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? "Update command completed, but T3 Code could not verify the provider version." : stillOutdated ? "Update command completed, but T3 Code still detects an outdated provider version." - : "Provider updated.", + : needsOpenCodeServerRestart(verifiedProviders) + ? OPENCODE_SERVER_RESTART_MESSAGE + : "Provider updated.", output: commandOutput(result), }), ); diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 8994837fcdd80..450d42ebc3cb1 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -403,6 +403,7 @@ const make = Effect.gen(function* () { ...reviewGuideInputFromPullRequest(detail, activity, checkout.baseCommit), cwd: checkout.path, modelSelection, + projectId: input.projectId, instructions: projectSettings.reviewGuideInstructions, stack: checkout.stack, }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 963b319dd5b8d..f512aae1c70d2 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -905,6 +905,173 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ); +it.effect("retains an unavailable host's summary when it is the only queried host", () => + Effect.gen(function* () { + for (const reason of ["missing-tool", "unauthenticated"] as const) { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "t3code", + workspaceRoot: "/a", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("github"), reason)), + }), + ], + }); + + for (const filter of [{}, { host: "github.com" }]) { + const error = yield* service.list({ state: "open", ...filter }).pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual( + error._tag === "PullRequestUnavailableError" ? error.reason : null, + reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + ); + assert.isDefined(error.listResult); + const result = error.listResult!; + assert.deepStrictEqual(result.entries, []); + assert.deepStrictEqual(result.viewers, {}); + assert.deepStrictEqual( + result.providers.map(({ host, configured, projectIds }) => ({ + host, + configured, + projectIds: projectIds?.map(String), + })), + [{ host: "github.com", configured: false, projectIds: undefined }], + ); + assert.strictEqual(result.providers[0]?.detail?.includes("github"), true); + assert.deepStrictEqual( + result.errors.map(({ projectId }) => projectId), + ["p1"], + ); + } + } + }), +); + +it.effect("retains a Bitbucket host summary when the provider rejects saved credentials", () => + Effect.gen(function* () { + const provider = fakePullRequestProvider(SourceControlProviderKind.make("bitbucket"), { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("bitbucket"), + operation: "getViewer", + reason: "failed", + detail: "HTTP 403", + }), + ), + }); + const service = yield* makeService({ + projects: [ + project({ + id: "bitbucket", + title: "Widget", + workspaceRoot: "/widget", + repository: "acme/widget", + provider: "bitbucket", + host: "bitbucket.org", + }), + ], + providers: [provider], + }); + for (const filter of [{}, { host: "bitbucket.org" }]) { + const error = yield* service.list({ state: "open", ...filter }).pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isDefined(error.listResult); + const result = error.listResult!; + assert.deepStrictEqual( + result.providers.map(({ host, configured, projectIds }) => ({ + host, + configured, + projectIds: projectIds?.map(String), + })), + [{ host: "bitbucket.org", configured: false, projectIds: undefined }], + ); + assert.deepStrictEqual(result.entries, []); + assert.deepStrictEqual( + result.errors.map(({ projectId }) => projectId), + ["bitbucket"], + ); + } + }), +); + +it.effect.each([ + { + provider: "forgejo", + host: "ssh.forge.example", + remoteUrl: "ssh://git@ssh.forge.example:2222/team/repo.git", + }, + { + provider: "unknown", + host: "forge.example", + remoteUrl: "http://forge.example:3000/team/repo.git", + }, +])( + "identifies every worktree behind an unavailable refined Forgejo authority", + ({ provider, host, remoteUrl }) => + Effect.gen(function* () { + const service = yield* makeService({ + projects: ["ssh", "ssh-worktree"].map((id) => + project({ + id, + title: id, + workspaceRoot: `/${id}`, + repository: "team/repo", + provider, + host, + remoteUrl, + }), + ), + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { + getViewer: ({ host }) => { + assert.strictEqual(host, "forge.example:3000"); + return Effect.fail( + unusable(SourceControlProviderKind.make("forgejo"), "unauthenticated"), + ); + }, + listChangeRequests: () => Effect.die("An unauthenticated host must not be queried"), + }), + ], + resolveHandle: ({ context }) => + Effect.succeed({ + context: { + ...context!, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "http://forge.example:3000", + }, + }, + provider: undefined as never, + }), + }); + const error = yield* service.list({ state: "open" }).pipe(Effect.flip); + assert.isDefined(error.listResult); + const result = error.listResult!; + assert.deepStrictEqual( + result.providers.map(({ host, configured, projectIds }) => ({ + host, + configured, + projectIds: projectIds?.map(String), + })), + [{ host: "forge.example:3000", configured: false, projectIds: ["ssh", "ssh-worktree"] }], + ); + assert.deepStrictEqual(result.entries, []); + assert.deepStrictEqual( + result.errors.map(({ projectId }) => projectId), + ["ssh"], + ); + }), +); + it.effect("reads a repository once when several worktrees share it", () => Effect.gen(function* () { let calls = 0; @@ -936,6 +1103,7 @@ it.effect("reads a repository once when several worktrees share it", () => assert.strictEqual(calls, 1); assert.strictEqual(result.entries.length, 1); + assert.isUndefined(result.providers[0]?.projectIds); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 473fa70120bb1..325bafb66fee0 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -236,6 +236,8 @@ export class PullRequestService extends Context.Service< readonly activity: ( input: PullRequestRef, ) => Effect.Effect; + /** Emits when the host reports a change; empty where the host has no event feed. */ + readonly changes: (input: PullRequestRef) => Stream.Stream; readonly threadComments: ( input: PullRequestThreadCommentsInput, ) => Effect.Effect; @@ -342,6 +344,7 @@ interface WorkspaceProjects { { readonly kind: SourceControlProviderKind; readonly projectCount: number } >; readonly viewerRoots: ReadonlyMap>; + readonly refinedProjectIdsByHost: ReadonlyMap>; } // Inherited by fibers forked inside the scope, so it is closed, not just dropped, when the scope ends. @@ -570,6 +573,10 @@ function withRateLimitBackoff( api.getChangeRequestWatchFingerprint, ), }), + // A long-lived feed, not a read: the quota guard would hold its lease for hours. + ...(api.changeRequestEvents === undefined + ? {} + : { changeRequestEvents: api.changeRequestEvents }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -924,6 +931,7 @@ export const make = Effect.gen(function* () { { kind: SourceControlProviderKind; projectCount: number } >(); const viewerRoots = new Map(); + const refinedProjectIdsByHost = new Map(); const seen = new Set(); for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; @@ -944,6 +952,11 @@ export const make = Effect.gen(function* () { if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } + if (host !== sourceControlDefinitions.changeRequestHost(identity, identity.provider)) { + const projectIds = refinedProjectIdsByHost.get(host); + if (projectIds === undefined) refinedProjectIdsByHost.set(host, [project.id]); + else projectIds.push(project.id); + } const registered = registry.get(kind); const api = registered?.servesHost?.(host) === false ? null : registered; if (api !== null) { @@ -977,7 +990,7 @@ export const make = Effect.gen(function* () { : sourceControlDefinitions.canonicalRepositoryKey(repositoryKey.toLowerCase()), }); } - return { supported, unimplemented, viewerRoots }; + return { supported, unimplemented, viewerRoots, refinedProjectIdsByHost }; }), ); @@ -1084,6 +1097,23 @@ export const make = Effect.gen(function* () { }; }); + const changes: PullRequestService["Service"]["changes"] = (input) => + Stream.unwrap( + requireProject(input).pipe( + Effect.map((project) => + project.api.changeRequestEvents === undefined + ? Stream.empty + : project.api.changeRequestEvents({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }), + ), + Effect.orElseSucceed(() => Stream.empty), + ), + ); + // Access can change after page load; recheck every write. const viewerPermissionsOf = ( project: SupportedProject, @@ -1284,7 +1314,12 @@ export const make = Effect.gen(function* () { const involvement = input.involvement ?? "all"; const epochAtStart = epochCounter; const continuation = yield* decodeCursors(input.cursors); - const { supported: projects, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); + const { + supported: projects, + unimplemented, + viewerRoots, + refinedProjectIdsByHost, + } = yield* listWorkspaceProjects(input); const projectCounts = new Map(); for (const { host } of projects) { projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); @@ -1306,6 +1341,7 @@ export const make = Effect.gen(function* () { searchesOnHost: capabilities?.search ?? false, actions: capabilities?.actions ?? [], projectCount: projectCounts.get(result.host) ?? 1, + projectIds: refinedProjectIdsByHost.get(result.host), configured: result.viewer !== null, detail: result.error === null ? null : providerDetail(requirementOf, result.error), }; @@ -1316,6 +1352,7 @@ export const make = Effect.gen(function* () { searchesOnHost: false, actions: [], projectCount, + projectIds: refinedProjectIdsByHost.get(host), configured: false, detail: "This host cannot be browsed here yet.", })), @@ -1334,6 +1371,14 @@ export const make = Effect.gen(function* () { message: `${repository} could not be read.`, })); if (readable.length === 0) { + const listResult: PullRequestListResult = { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: [], + errors: unreadable, + truncated: false, + nextCursors: {}, + }; const errors = viewerResults.flatMap((result) => result.error === null || !selected.some(({ host }) => host === result.host) ? [] @@ -1341,17 +1386,13 @@ export const make = Effect.gen(function* () { ); const blocking = errors.find(isProviderUnusable) ?? errors[0]; if (blocking) { - return yield* toPullRequestError("list")(blocking); + // Preserve failure/retry semantics while letting clients try another server for these hosts. + const error = toPullRequestError("list")(blocking); + return yield* error._tag === "PullRequestUnavailableError" + ? new PullRequestUnavailableError({ ...error, listResult }) + : new PullRequestOperationError({ ...error, listResult }); } - - return { - viewers: viewers as PullRequestListResult["viewers"], - providers, - entries: [], - errors: [], - truncated: false, - nextCursors: {}, - }; + return listResult; } const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; @@ -1915,6 +1956,9 @@ export const make = Effect.gen(function* () { ? {} : { reviewThreadsTruncated: activity.reviewThreadsTruncated }), reviewThreads: activity.reviewThreads, + ...(activity.overviewThreads === undefined + ? {} + : { overviewThreads: activity.overviewThreads }), commits: activity.commits, ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), })), @@ -3819,6 +3863,7 @@ export const make = Effect.gen(function* () { Cache.get(watchFingerprintCache, refCacheKey(input)), ), activity: credentialCached(activity), + changes, preview: credentialCached(preview), threadComments, diff: credentialCached(diff), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index ccd28c165f14e..85b9b44f4daf7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -261,6 +261,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( stopDelegatedTasks: unused, getThreadEventSequence: unused, recoverDelegatedTask: unused, + cancelDelegatedTask: unused, delegatedTaskResultPending: unused, streamStoredEvents: Stream.empty, streamStoredEventsFrom: () => Stream.empty, @@ -318,6 +319,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( update: unused, delete: unused, getByWorkspaceRoot: unused, + workspaceScanDirectory: unused, snapshot: Effect.succeed({ projects: [project], updatedAt: NOW }), listPage: unused, getShell: unused, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index ace9fceaff3b9..b83a2365667dd 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1284,7 +1284,10 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ tasks })), + Effect.map((tasks) => ({ + tasks, + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 565f8111849e0..8644b7fd65c47 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -174,12 +174,53 @@ const olderBootServiceHome = Effect.fn("olderBootServiceHome")( Effect.orElseSucceed(() => undefined), ); +const ownershipLockPath = (stateDir: string) => NodePath.join(stateDir, "server-owner.sqlite"); + /** - * Hold an exclusive lock on the state directory until the scope closes. The - * lock is an exclusive transaction on `server-owner.sqlite`, which never holds - * data and must never be unlinked: the OS releases it when the process exits, - * even on SIGKILL, so a crashed owner never blocks the next start. + * The lock is an exclusive transaction on `server-owner.sqlite`, which never + * holds data and must never be unlinked: the OS releases it when the process + * exits, even on SIGKILL, so a crashed owner never blocks the next start. */ +const openOwnershipLock = (stateDir: string) => + Effect.try({ + try: () => { + const lockPath = ownershipLockPath(stateDir); + NodeFS.mkdirSync(stateDir, { recursive: true }); + let before = NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + if (before === undefined) { + NodeFS.closeSync(NodeFS.openSync(lockPath, "a", 0o644)); + before = NodeFS.statSync(lockPath, { bigint: true }); + } + const db = new NodeSqlite.DatabaseSync(lockPath); + try { + db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); + const identity = NodeFS.statSync(lockPath, { bigint: true }); + return { + db, + identity, + replacedDuringAcquire: before.dev !== identity.dev || before.ino !== identity.ino, + }; + } catch (cause) { + db.close(); + throw cause; + } + }, + catch: (cause) => + isSqliteBusy(cause) + ? new ServerAlreadyRunningError({ stateDir }) + : new ServerOwnershipError({ stateDir, cause }), + }); + +/** Hold only the ownership lock until the scope closes, without claiming the runtime state. */ +export const holdServerOwnershipLock = Effect.fn("holdServerOwnershipLock")(function* ( + statePath: string, +) { + yield* Effect.acquireRelease(openOwnershipLock(NodePath.dirname(statePath)), ({ db }) => + Effect.sync(() => db.close()), + ); +}); + +/** Hold an exclusive lock on the state directory until the scope closes. */ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(function* ( statePath: string, ) { @@ -188,45 +229,17 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi const ownerId = yield* random .uuidV4() .pipe(Effect.mapError((cause) => new ServerOwnershipError({ stateDir, cause }))); - const lockPath = NodePath.join(stateDir, "server-owner.sqlite"); - const lock = yield* Effect.acquireRelease( - Effect.try({ - try: () => { - NodeFS.mkdirSync(stateDir, { recursive: true }); - let before = NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); - if (before === undefined) { - NodeFS.closeSync(NodeFS.openSync(lockPath, "a", 0o644)); - before = NodeFS.statSync(lockPath, { bigint: true }); - } - const db = new NodeSqlite.DatabaseSync(lockPath); - try { - db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); - const identity = NodeFS.statSync(lockPath, { bigint: true }); - return { - db, - identity, - replacedDuringAcquire: before.dev !== identity.dev || before.ino !== identity.ino, - }; - } catch (cause) { - db.close(); - throw cause; - } - }, - catch: (cause) => - isSqliteBusy(cause) - ? new ServerAlreadyRunningError({ stateDir }) - : new ServerOwnershipError({ stateDir, cause }), - }), - ({ db }) => - readPersistedServerRuntimeState(statePath).pipe( - Effect.flatMap((state) => - Option.isSome(state) && state.value.ownerId === ownerId - ? clearPersistedServerRuntimeState(statePath) - : Effect.void, - ), - Effect.ignore({ log: true }), - Effect.ensuring(Effect.sync(() => db.close())), + const lockPath = ownershipLockPath(stateDir); + const lock = yield* Effect.acquireRelease(openOwnershipLock(stateDir), ({ db }) => + readPersistedServerRuntimeState(statePath).pipe( + Effect.flatMap((state) => + Option.isSome(state) && state.value.ownerId === ownerId + ? clearPersistedServerRuntimeState(statePath) + : Effect.void, ), + Effect.ignore({ log: true }), + Effect.ensuring(Effect.sync(() => db.close())), + ), ); const previous = yield* readPersistedServerRuntimeState(statePath).pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 885c643ada00c..50cc9a2e1019a 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -10,6 +10,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/hostClassification"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; @@ -51,12 +52,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { getServerOwners } from "./telemetry/Identify.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { - formatHeadlessServeOutput, - formatHostForUrl, - isWildcardHost, - issueHeadlessServeAccessInfo, -} from "./startupAccess.ts"; +import { formatHeadlessServeOutput, issueHeadlessServeAccessInfo } from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Schema.TaggedError()( "ServerRuntimeStartupError", diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 49d9a48f991bc..259a17000796e 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -5,9 +5,9 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/hostClassification"; import packageJson from "../package.json" with { type: "json" }; import type * as ServerConfig from "./config.ts"; -import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ version: Schema.Literal(1), diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 274b86573ed3c..3304211d9e7c6 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -2,6 +2,9 @@ import type * as NodeChildProcess from "node:child_process"; // @effect-diagnostics-next-line nodeBuiltinImport:off - the free-space probe is a plain Promise callback. import * as NodeFS from "node:fs"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - models the service manager reading disk through the launcher's Promise callback. +import * as NodeFSP from "node:fs/promises"; +import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -12,10 +15,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; +import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { databaseBackupBlocker, Launcher, - main, readServiceEnvironment, type BackupSpaceProbe, readServiceState, @@ -111,6 +114,13 @@ it("rejects contradictory service state", () => { ); }); +const unitPlan = (root: string, version: string) => ({ + program: [`${root}/runtime/versions/${version}/t3`, "__service-launcher"], + baseDir: root, + logPath: `${root}/logs/boot-service.log`, + unitPath: `${root}/unit/t3code.service`, +}); + const writeFakeRuntime = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -129,6 +139,32 @@ const writeFakeRuntime = ( return entryPath; }); +const fakeServiceUnit = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + version: string, + render = renderBootServiceUnit, +) => + Effect.gen(function* () { + const plan = unitPlan(root, version); + yield* fs.makeDirectory(path.dirname(plan.unitPath), { recursive: true }); + const contents = render(plan); + yield* fs.writeFileString(plan.unitPath, contents); + const unit = { + path: plan.unitPath, + reloads: 0, + loadedContents: contents, + failReload: false, + reload: async () => { + unit.reloads += 1; + if (unit.failReload) throw new Error("daemon-reload failed"); + unit.loadedContents = await NodeFSP.readFile(unit.path, "utf8"); + }, + }; + return unit; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect( "loads optional service.env literally without changing inherited or managed variables", @@ -216,6 +252,105 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect.each([ + { unitVersion: "1.0.0", replacement: "an intentional downgrade" }, + { unitVersion: "1.2.0", replacement: "another T3 home" }, + ])( + "recovers a rolled-back update with unit $unitVersion and preserves $replacement", + ({ unitVersion, replacement }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-repoint-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.1.0"), + "setInterval(() => {}, 1_000);\n", + ); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-1", + fromVersion: "1.1.0", + targetVersion: "1.2.0", + status: "rolled-back", + }, + }), + ); + const unit = yield* fakeServiceUnit(fs, path, root, unitVersion); + const expected = renderBootServiceUnit( + unitPlan(root, unitVersion === "1.0.0" ? "1.1.0" : unitVersion), + ); + const createLauncher = Effect.gen(function* () { + return new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)), { + serviceUnit: unit, + }); + }); + const run = (launcher: Launcher) => + Effect.gen(function* () { + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + + yield* run(yield* createLauncher); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + + yield* run(yield* createLauncher); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-2", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "committed", + }, + }), + ); + const staleLauncher = yield* createLauncher; + // A local install can replace the selection or service owner without stopping this launcher. + const selectedRoot = + replacement === "another T3 home" ? path.join(root, "other-home") : root; + yield* writeFakeRuntime( + fs, + path, + path.join(selectedRoot, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); + const selectedStatePath = path.join(selectedRoot, "runtime", "service-state.json"); + yield* Effect.promise(() => + writeServiceState(selectedStatePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + const selectedUnit = renderBootServiceUnit({ + ...unitPlan(selectedRoot, "1.0.0"), + unitPath: unit.path, + }); + yield* fs.writeFileString(unit.path, selectedUnit); + yield* Effect.promise(() => unit.reload()); + const oldHomeState = yield* fs.readFileString(statePath); + const selectedState = yield* fs.readFileString(selectedStatePath); + + yield* run(staleLauncher); + assert.equal(yield* fs.readFileString(statePath), oldHomeState); + assert.equal(yield* fs.readFileString(selectedStatePath), selectedState); + assert.equal(yield* fs.readFileString(unit.path), selectedUnit, replacement); + assert.equal(unit.loadedContents, selectedUnit, replacement); + }), + ); + it.effect("stays idle instead of exiting when another server owns the T3 home", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -256,25 +391,49 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("commits only after the trial reports prepared", () => + const commitUpdate = ( + render = renderBootServiceUnit, + missingUnit = false, + legacyInstall = false, + ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service 100% & -" }); const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); + const activatedPath = path.join(root, "activated"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, "before trial"); - const encodedDatabasePath = JSON.stringify(databasePath); + const unit = yield* fakeServiceUnit(fs, path, root, "1.0.0", render); + if (missingUnit) yield* fs.remove(unit.path); const childSource = ` +import assert from "node:assert/strict"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +assert.equal(context.protocol, ${SERVICE_LAUNCHER_PROTOCOL}); if (context.update?.status === "pending") { - process.send({ type: "prepared", updateId: context.update.id }); + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, "utf8")); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update.status, "pending"); + ${ + missingUnit + ? `assert.equal(existsSync(${JSON.stringify(unit.path)}), false);` + : `assert.equal(readFileSync(${JSON.stringify(unit.path)}, "utf8"), ${JSON.stringify(unit.loadedContents)});` + } process.on("message", (message) => { - if (message.type === "committed") process.exit(0); + if (message.type === "committed") { + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, "utf8")); + assert.equal(state.protocol, ${SERVICE_LAUNCHER_PROTOCOL}); + assert.equal(state.activeVersion, "1.1.0"); + assert.equal(state.update.status, "committed"); + writeFileSync(${JSON.stringify(activatedPath)}, message.updateId); + process.exit(0); + } }); + process.send({ type: "prepared", updateId: context.update.id }); } else if (context.update === undefined) { - process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${JSON.stringify(databasePath)} }); setInterval(() => {}, 1_000); } else { process.exit(0); @@ -288,25 +447,97 @@ if (context.update?.status === "pending") { childSource, ); } - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - }), + // The old CLI installs the new runtime but writes state with its own protocol. + yield* fs.writeFileString( + statePath, + `${JSON.stringify( + { + protocol: legacyInstall ? 3 : SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }, + null, + 2, + )}\n`, ); - - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); - yield* Effect.promise(() => + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { serviceUnit: unit }, + ); + const failure = yield* Effect.promise(() => launcher.run().then( - () => Promise.reject(new Error("launcher unexpectedly completed")), - () => Promise.resolve(), + () => "launcher unexpectedly completed", + (cause: unknown) => String(cause), ), ); - + assert.include(failure, "Active child exited unexpectedly (0)"); const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.1.0"); assert.equal(state.update?.status, "committed"); - }), + assert.equal(yield* fs.readFileString(activatedPath), state.update?.id); + const recover = () => + Effect.gen(function* () { + const fresh = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { serviceUnit: unit }, + ); + const running = fresh.run(); + yield* Effect.promise(() => fresh.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + return { fs, root, unit, recover }; + }); + + it.effect.each([ + { + name: "systemd after a protocol-3 local upgrade", + render: renderBootServiceUnit, + legacyInstall: true, + }, + { + name: "launchd", + legacyInstall: false, + render: (plan: Parameters[0]) => + renderBootServicePlist(plan, { + homeDir: "/Users/test & ", + environmentPath: "/usr/bin:/custom/runtime/versions/1.0.0", + }), + }, + ])( + "commits and repoints $name only after the trial reports prepared", + ({ render, legacyInstall }) => + Effect.gen(function* () { + const { fs, root, unit } = yield* commitUpdate(render, false, legacyInstall); + const expected = render(unitPlan(root, "1.1.0")); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + assert.equal(unit.reloads, 1); + }), + ); + + it.effect( + "keeps a durable commit through unit failure and retries a failed reload on restart", + () => + Effect.gen(function* () { + const { fs, root, unit, recover } = yield* commitUpdate(renderBootServiceUnit, true); + assert.equal(unit.reloads, 0); + const original = renderBootServiceUnit(unitPlan(root, "1.0.0")); + const expected = renderBootServiceUnit(unitPlan(root, "1.1.0")); + assert.equal(unit.loadedContents, original); + yield* fs.writeFileString(unit.path, original); + unit.failReload = true; + yield* recover(); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.reloads, 1); + assert.equal(unit.loadedContents, original); + + unit.failReload = false; + yield* recover(); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + assert.equal(unit.reloads, 2); + }), ); it.effect("rolls back invalid trials with the startup environment", () => @@ -356,9 +587,18 @@ if (context.update?.status === "pending") { activeVersion: "1.0.0", }), ); + const unit = yield* fakeServiceUnit(fs, path, root, "0.9.0"); + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { + serviceUnit: unit, + environment: yield* Effect.promise(() => readServiceEnvironment(root)), + }, + ); yield* Effect.promise(() => - main().then( + launcher.run().then( () => Promise.reject(new Error("launcher unexpectedly completed")), () => Promise.resolve(), ), @@ -367,6 +607,11 @@ if (context.update?.status === "pending") { const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.0.0"); assert.equal(state.update?.status, "rolled-back"); + assert.equal( + yield* fs.readFileString(unit.path), + renderBootServiceUnit(unitPlan(root, "1.0.0")), + ); + assert.equal(unit.reloads, 1); assert.equal( state.update?.status === "rolled-back" ? state.update.reason : undefined, "invalid-prepared", @@ -464,6 +709,125 @@ if (context.update?.status === "pending") { }), ); + it.effect.each(["wal", "delete"] as const)( + "backs up a %s-mode database (online when WAL) and restores its final state", + (journalMode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-online-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + const observationsPath = path.join(root, "observations.json"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + // SQLite keeps -wal beside the symlink's target, so the backup must restore it there. + yield* fs.symlink("actual.sqlite", databasePath); + const seed = new NodeSqlite.DatabaseSync(databasePath); + seed.exec(` + PRAGMA journal_mode = ${journalMode}; + CREATE TABLE events (id INTEGER PRIMARY KEY, payload TEXT NOT NULL); + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 500) + INSERT INTO events (payload) SELECT 'before the update ' || i FROM n; + `); + seed.close(); + + const encoded = JSON.stringify({ root, databasePath, observationsPath, journalMode }); + const childSource = ` +import { readdirSync, writeFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +const paths = ${encoded}; +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + writeFileSync(paths.observationsPath + ".trial", JSON.stringify( + readdirSync(paths.root + "/runtime/db-backup/" + context.update.id).sort(), + )); + const trial = new DatabaseSync(context.update.dbPath); + trial.exec("PRAGMA journal_mode = " + paths.journalMode + "; INSERT INTO events (payload) VALUES ('trial');"); + trial.close(); + process.exit(1); +} else if (context.update === undefined) { + const db = new DatabaseSync(paths.databasePath); + db.exec("PRAGMA busy_timeout = 5000;"); + db.exec("PRAGMA journal_mode = " + paths.journalMode + ";"); + const insert = db.prepare("INSERT INTO events (payload) VALUES (?)"); + const writer = setInterval(() => insert.run("written while serving"), 1); + process.once("SIGTERM", () => { + clearInterval(writer); + insert.run("last write before stopping"); + const backupRoot = paths.root + "/runtime/db-backup"; + writeFileSync(paths.observationsPath, JSON.stringify({ + backupsAtStop: readdirSync(backupRoot).map((entry) => readdirSync(backupRoot + "/" + entry)), + rows: db.prepare("SELECT count(*) AS n FROM events").get().n, + })); + db.close(); + process.exit(0); + }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: paths.databasePath }); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { + backupSpaceProbe: { canClone: async () => false }, + }, + ); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.update?.status, "rolled-back"); + const observed = JSON.parse(yield* fs.readFileString(observationsPath)); + // A WAL database's main file is copied before the server is asked to stop; others fall back offline. + assert.deepEqual(observed.backupsAtStop, journalMode === "wal" ? [["database"]] : []); + assert.isAbove(observed.rows, 501); + // Only the online copy holds no -shm: the offline fallback copies the one the server left. + assert.deepEqual( + JSON.parse(yield* fs.readFileString(`${observationsPath}.trial`)), + journalMode === "wal" ? ["database", "database-wal"] : ["database"], + ); + const restored = new NodeSqlite.DatabaseSync(databasePath); + try { + assert.equal(restored.prepare("PRAGMA integrity_check").get()?.integrity_check, "ok"); + assert.equal( + restored.prepare("SELECT count(*) AS n FROM events").get()?.n, + observed.rows, + ); + assert.equal( + restored + .prepare( + "SELECT count(*) AS n FROM events WHERE payload = 'last write before stopping'", + ) + .get()?.n, + 1, + ); + } finally { + restored.close(); + } + }), + ); + const requestRejectedUpdate = (backupSpaceProbe: Partial) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -670,7 +1034,7 @@ setInterval(() => {}, 1_000); assert.include((yield* check(0.5 * 1024 ** 3, true)) ?? "", "1.0 GB needed, 0.5 GB free"); const blocker = yield* check(8 * 1024 ** 3, false); assert.include(blocker ?? "", "Not enough free disk space"); - assert.include(blocker ?? "", "10.5 GB needed, 8.0 GB free"); + assert.include(blocker ?? "", "10.8 GB needed, 8.0 GB free"); assert.include(blocker ?? "", "The server was not stopped"); }), ); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index ee05c63297843..e6d3431f39b3b 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -7,6 +7,7 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; +import { acquireServiceSelectionLock } from "./cloud/serviceSelectionLock.ts"; import type { PendingServiceUpdate, @@ -21,12 +22,14 @@ import { decodeServiceLauncherChildMessage, isExactServiceVersion, parseServiceState, + serviceUnitBaseDirOf, SERVICE_ENV_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, + SERVICE_UNIT_ENV, } from "./cloud/serviceProtocol.ts"; const HANDOFF_DELAY_MS = 2_000; @@ -64,6 +67,78 @@ const runtimeSpawnArguments = (paths: ReturnType) => ({ args: ["serve"], }); +/** + * The unit or launch agent that `t3 service install` wrote. Its program names + * the runtime that ran the install, and a remote update never replaces it, so + * after a commit the launcher points it at the committed runtime. Otherwise the + * next start lands on a runtime that nothing in the service state records. + */ +export interface ServiceUnit { + readonly path: string; + /** systemd keeps a loaded copy of the unit; launchd re-reads the plist at bootstrap. */ + readonly reload: () => Promise; +} + +function serviceUnitFromEnvironment(env: NodeJS.ProcessEnv = process.env): ServiceUnit | undefined { + const unitName = env[SERVICE_UNIT_ENV]?.trim(); + const home = env.HOME?.trim(); + if (!unitName || !home) return undefined; + if (unitName.endsWith(".plist")) { + return { + path: NodePath.join(home, "Library", "LaunchAgents", unitName), + reload: () => Promise.resolve(), + }; + } + // The manager's own environment, which decides where it reads user units. + const xdgConfigHome = env.XDG_CONFIG_HOME?.trim(); + const configHome = + xdgConfigHome && NodePath.isAbsolute(xdgConfigHome) + ? xdgConfigHome + : NodePath.join(home, ".config"); + return { + path: NodePath.join(configHome, "systemd", "user", unitName), + reload: () => + new Promise((resolve, reject) => { + NodeChildProcess.spawn("systemctl", ["--user", "daemon-reload"], { + stdio: "ignore", + timeout: 30_000, + }) + .once("error", reject) + .once("exit", (code, signal) => + code === 0 + ? resolve() + : reject(new Error(`systemctl --user daemon-reload exited with ${code ?? signal}`)), + ); + }), + }; +} + +// systemd's ExecStart value, or the plist string before the __service-launcher argument. +// Only the version segment changes, so quoted or escaped paths stay as rendered. +// The launch agent's PATH names a versions directory without the executable; leave it. +const SERVICE_UNIT_PROGRAMS = [ + /^(ExecStart=.*?[\\/]runtime[\\/]versions[\\/])([^\\/\s"]+)([\\/]t3)(?=["\s])/m, + /([^<]*?[\\/]runtime[\\/]versions[\\/])([^\\/<]+)([\\/]t3<\/string>\s*__service-launcher<\/string>)/, +] as const; + +/** + * The unit contents with their program pointed at `version`'s runtime. A unit + * already at that version or a newer one is returned unchanged: `t3 update` + * may have repointed it to a later version while this commit was in flight. + */ +function repointServiceUnit(contents: string, version: string): string { + for (const program of SERVICE_UNIT_PROGRAMS) { + const match = program.exec(contents); + if (match === null) continue; + const [, prefix = "", current = "", suffix = ""] = match; + if (isExactServiceVersion(current) && compareExactServiceVersions(current, version) >= 0) { + return contents; + } + return contents.replace(program, () => `${prefix}${version}${suffix}`); + } + return contents; +} + const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; const RESTORE_MARKER = ".restore-pending"; @@ -162,6 +237,8 @@ const GIB = 1024 ** 3; // Cloned backups still need headroom for trial writes and migrations to diverge. const CLONED_BACKUP_MIN_HEADROOM_BYTES = GIB; const CLONED_BACKUP_HEADROOM_FRACTION = 0.1; +// While the online backup holds its snapshot, the live WAL and its copy can each reach the server's 128 MiB gate. +const ONLINE_BACKUP_WAL_HEADROOM_BYTES = 2 * 128 * 1024 ** 2; async function statDatabaseFiles(dbPath: string): Promise> { const stats: Array = []; @@ -242,7 +319,7 @@ export async function databaseBackupBlocker( await removeAbandonedBackupCopies(backupRoot); const [files, free] = await Promise.all([statDatabaseFiles(dbPath), readFreeBytes(backupRoot)]); const size = files.reduce((total, file) => total + file.size, 0); - const fullCopyBytes = size * 1.05; + const fullCopyBytes = size * 1.05 + ONLINE_BACKUP_WAL_HEADROOM_BYTES; if (free >= fullCopyBytes) return undefined; const clonedBytes = Math.max( CLONED_BACKUP_MIN_HEADROOM_BYTES, @@ -254,6 +331,83 @@ export async function databaseBackupBlocker( return `Not enough free disk space to back up the database before updating: ${gb(needed)} GB needed, ${gb(free)} GB free. Free up disk space and try again. The server was not stopped.`; } +// A child-process copy: closing any descriptor this process opens on the database drops the snapshot's POSIX locks. +async function copyWithoutOpening(source: string, destination: string): Promise { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. + if (process.platform === "win32") return NodeFSP.copyFile(source, destination); + if (!(await runCp([source, destination]))) throw new Error(`cp ${source} failed.`); +} + +// Copies the main file while the server runs; an open read transaction stops SQLite resetting the WAL, +// which `finish` copies after the stop and which overrides any page the copy caught mid-checkpoint. +// `-shm` is left out: its backfill count would make SQLite trust those pages. Past the WAL size gate, +// a write-heavy server's new writes wait, uncommitted, until the server stops. +async function startOnlineDatabaseBackup( + baseDir: string, + pending: PendingServiceUpdate, + probe: Partial = {}, +): Promise<(() => Promise) | undefined> { + const backupDir = databaseBackupDir(baseDir, pending.id); + if (await pathExists(backupDir)) return undefined; + const { canClone } = { ...filesystemBackupSpaceProbe, ...probe }; + if (await canClone(pending.dbPath, NodePath.dirname(backupDir)).catch(() => true)) + return undefined; + const NodeSqlite = await import("node:sqlite").catch(() => undefined); + if (NodeSqlite === undefined) return undefined; + + const stagingDir = `${backupDir}.staging`; + const discardStaging = () => + NodeFSP.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + const fallBack = async (cause: unknown) => { + await discardStaging(); + process.stderr.write( + `[service-launcher] Copying the database while the server runs failed; backing it up offline: ${cause instanceof Error ? cause.message : String(cause)}\n`, + ); + return undefined; + }; + let snapshot: InstanceType | undefined; + // SQLite keeps -wal beside the resolved file, not beside a symlink to it. + let dbPath = pending.dbPath; + const release = () => { + try { + snapshot?.close(); + } catch {} + snapshot = undefined; + }; + try { + await discardStaging(); + await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); + dbPath = await NodeFSP.realpath(pending.dbPath); + snapshot = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + const mode = String(snapshot.prepare("PRAGMA journal_mode").get()?.journal_mode); + if (mode.toLowerCase() !== "wal") throw new Error(`journal_mode is ${mode}, not wal.`); + snapshot.exec("BEGIN"); + snapshot.prepare("SELECT count(*) FROM sqlite_schema").get(); + const mainCopy = databaseBackupFile(stagingDir, ""); + await copyWithoutOpening(dbPath, mainCopy); + await syncFile(mainCopy); + } catch (cause) { + release(); + return fallBack(cause); + } + return async () => { + try { + const wal = `${dbPath}-wal`; + if (!(await pathExists(wal))) + throw new Error("the WAL disappeared while the snapshot was open."); + const walCopy = databaseBackupFile(stagingDir, "-wal"); + await copyWithoutOpening(wal, walCopy); + await syncFile(walCopy); + release(); + await NodeFSP.rename(stagingDir, backupDir); + await syncDirectory(NodePath.dirname(backupDir)); + } catch (cause) { + release(); + await fallBack(cause); + } + }; +} + // Preserve completed backups; restarted trials may already have changed the live database. async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); @@ -330,8 +484,13 @@ async function restoreDatabaseBackup( await markDatabaseRestorePending(backupDir); const directories = new Set([NodePath.dirname(pending.dbPath)]); + // SQLite keeps -wal and -shm beside the resolved database file, not beside a symlink to it. + const mainTarget = await restoreTarget(pending.dbPath); for (const suffix of DB_FILE_SUFFIXES) { - const livePath = `${pending.dbPath}${suffix}`; + const livePath = suffix === "" ? pending.dbPath : `${mainTarget}${suffix}`; + if (livePath !== `${pending.dbPath}${suffix}`) { + await NodeFSP.rm(`${pending.dbPath}${suffix}`, { force: true }); + } const source = databaseBackupFile(backupDir, suffix); if (await pathExists(source)) { // Restore into the symlink target; cloning would replace the link. @@ -361,8 +520,12 @@ export async function readServiceState(filePath: string): Promise } export async function writeServiceState(filePath: string, state: ServiceState): Promise { + await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true, mode: 0o700 }); + await replaceFileDurably(filePath, `${JSON.stringify(state, null, 2)}\n`); +} + +async function replaceFileDurably(filePath: string, contents: string): Promise { const directory = NodePath.dirname(filePath); - await NodeFSP.mkdir(directory, { recursive: true, mode: 0o700 }); const tempPath = NodePath.join( directory, `.${NodePath.basename(filePath)}.${process.pid}.${NodeCrypto.randomUUID()}`, @@ -370,7 +533,7 @@ export async function writeServiceState(filePath: string, state: ServiceState): let handle: NodeFSP.FileHandle | undefined; try { handle = await NodeFSP.open(tempPath, "wx", 0o600); - await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.writeFile(contents, "utf8"); await handle.sync(); await handle.close(); handle = undefined; @@ -458,6 +621,7 @@ export class Launcher { #done = false; readonly #completion = Promise.withResolvers(); readonly #backupSpaceProbe: Partial | undefined; + readonly #serviceUnit: ServiceUnit | undefined; readonly #environment: Readonly; constructor( @@ -465,11 +629,13 @@ export class Launcher { state: ServiceState, options: { readonly backupSpaceProbe?: Partial; + readonly serviceUnit?: ServiceUnit; readonly environment?: Readonly; } = {}, ) { this.#baseDir = baseDir; this.#backupSpaceProbe = options.backupSpaceProbe; + this.#serviceUnit = options.serviceUnit; this.#environment = { ...(options.environment ?? process.env) }; this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); this.#state = state; @@ -536,20 +702,66 @@ export class Launcher { this.#timer = undefined; } + async #selectState(next: ServiceState): Promise { + const release = await acquireServiceSelectionLock(this.#statePath); + try { + const selected = await readServiceState(this.#statePath); + // Local installs replace the selection and clear its update record. An old + // launcher must not restore its own selection when asked to update again. + if ( + selected.activeVersion !== this.#state.activeVersion || + selected.update?.id !== this.#state.update?.id || + selected.update?.status !== this.#state.update?.status + ) { + throw new Error("Service selection changed. Restart the service before updating."); + } + await writeServiceState(this.#statePath, next); + this.#state = next; + } finally { + release(); + } + } + async #recover(): Promise { // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); - const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); - if (awaitedVersion?.trim() === this.#state.activeVersion) { - await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined); + try { + const release = await acquireServiceSelectionLock(this.#statePath); + try { + const selected = await readServiceState(this.#statePath); + const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch( + () => undefined, + ); + const awaited = awaitedVersion?.trim(); + if ( + selected.activeVersion === this.#state.activeVersion && + selected.update?.id === this.#state.update?.id && + selected.update?.status === this.#state.update?.status && + awaited !== undefined && + isExactServiceVersion(awaited) && + compareExactServiceVersions(awaited, this.#state.activeVersion) <= 0 + ) { + await NodeFSP.rm(restartPending, { force: true }); + } + } finally { + release(); + } + } catch (error) { + process.stderr.write( + `[service-launcher] Could not clear ${restartPending}: ${error instanceof Error ? error.message : String(error)}\n`, + ); } const update = this.#state.update; if (update?.status !== "pending") { if (update !== undefined) { await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined); } - await this.#startChild(this.#state.activeVersion, "active", update); + try { + await this.#startChild(this.#state.activeVersion, "active", update); + } finally { + if (update !== undefined) await this.#repointServiceUnit(this.#state.activeVersion); + } return; } if (await databaseRestorePending(this.#baseDir, update)) { @@ -696,8 +908,12 @@ export class Launcher { status: "pending", }; const next: ServiceState = { ...this.#state, update: pending }; - await writeServiceState(this.#statePath, next); - this.#state = next; + try { + await this.#selectState(next); + } catch (error) { + await reject(error instanceof Error ? error.message : String(error)); + return; + } await sendMessage(child.process, { type: "update-accepted", updateId: pending.id }); this.#timer = setTimeout(() => this.#enqueue(() => this.#beginTrial(child)), HANDOFF_DELAY_MS); } @@ -708,8 +924,17 @@ export class Launcher { return; } this.#timer = undefined; + // Anything thrown outside its own fallback still leaves the offline backup to run. + const finishOnlineBackup = await startOnlineDatabaseBackup( + this.#baseDir, + pending, + this.#backupSpaceProbe, + ).catch(() => undefined); this.#child = null; await terminateChild(child.process); + await finishOnlineBackup?.(); + // A stop during a minutes-long copy should not start a trial; restart resumes the pending update. + if (this.#stopRequested) return; await this.#startTrial(pending); } @@ -734,11 +959,53 @@ export class Launcher { activeVersion: pending.targetVersion, update: committed, }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); child.role = "active"; await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined); - await sendMessage(child.process, { type: "committed", updateId: committed.id }); + try { + await sendMessage(child.process, { type: "committed", updateId: committed.id }); + } finally { + await this.#repointServiceUnit(committed.targetVersion); + } + } + + // Selection is durable before this best-effort unit reconciliation. + async #repointServiceUnit(version: string): Promise { + const unit = this.#serviceUnit; + if (unit === undefined) return; + try { + const release = await acquireServiceSelectionLock(unit.path); + try { + // The installer writes state before the unit under this lease. Do not + // acquire its state lease here: the installer takes that lease first. + const selected = await readServiceState(this.#statePath); + if ( + selected.activeVersion !== this.#state.activeVersion || + selected.update?.id !== this.#state.update?.id || + selected.update?.status !== this.#state.update?.status + ) { + return; + } + const contents = await NodeFSP.readFile(unit.path, "utf8"); + const installedBaseDir = serviceUnitBaseDirOf(contents); + if ( + installedBaseDir === undefined || + NodePath.resolve(installedBaseDir) !== NodePath.resolve(this.#baseDir) + ) { + return; + } + const repointed = repointServiceUnit(contents, version); + if (repointed !== contents) await replaceFileDurably(unit.path, repointed); + // A previous rename may have succeeded before reload failed. + await unit.reload(); + } finally { + release(); + } + } catch (error) { + process.stderr.write( + `[service-launcher] Could not point ${unit.path} at t3@${version}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } } async #handlePreparedTimeout(child: ManagedChild): Promise { @@ -794,9 +1061,9 @@ export class Launcher { activeVersion: pending.fromVersion, update: terminalUpdate({ pending, status: "failed", reason: "state-dir-owned" }), }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); + await this.#repointServiceUnit(next.activeVersion); } // Signal listeners alone do not keep Node alive. this.#timer = setInterval(() => {}, 2_147_483_647); @@ -822,10 +1089,13 @@ export class Launcher { activeVersion: pending.fromVersion, update: outcome, }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); - await this.#startChild(next.activeVersion, "active", outcome); + try { + await this.#startChild(next.activeVersion, "active", outcome); + } finally { + await this.#repointServiceUnit(next.activeVersion); + } } } @@ -837,5 +1107,10 @@ export async function main(): Promise { const environment = await readServiceEnvironment(baseDir); const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); const state = await readServiceState(statePath); - await new Launcher(baseDir, state, { environment }).run(); + const serviceUnit = serviceUnitFromEnvironment(); + await new Launcher( + baseDir, + state, + serviceUnit === undefined ? { environment } : { environment, serviceUnit }, + ).run(); } diff --git a/apps/server/src/speech/SpeechModelStore.test.ts b/apps/server/src/speech/SpeechModelStore.test.ts index e08a4cf022f4a..be25e4f290b17 100644 --- a/apps/server/src/speech/SpeechModelStore.test.ts +++ b/apps/server/src/speech/SpeechModelStore.test.ts @@ -12,6 +12,8 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ServerConfig from "../config.ts"; import * as SpeechModelStore from "./SpeechModelStore.ts"; @@ -168,3 +170,53 @@ it.effect("cleans only download partials on cancellation or restart", () => ]); }).pipe(Effect.provide(dependencies)), ); + +it.effect("links a verified model file from another folder and never deletes it", () => + Effect.gen(function* () { + const h = yield* harness; + const path = yield* Path.Path; + const elsewhere = yield* h.fs.makeTempDirectoryScoped({ prefix: "speech-other-app-" }); + const nested = path.join(elsewhere, "hub", "snapshots"); + yield* h.fs.makeDirectory(nested, { recursive: true }); + const impostor = path.join(elsewhere, "same-size.gguf"); + const existing = path.join(nested, "renamed-by-the-other-app.gguf"); + yield* h.fs.writeFile(impostor, new Uint8Array(bytes.length)); + yield* h.fs.writeFile(existing, bytes); + + expect(yield* h.store.link([h.model], impostor)).toEqual([]); + expect(yield* h.store.linkedFrom(h.model)).toBeUndefined(); + + expect(yield* h.store.link([h.model], elsewhere)).toEqual([h.model.id]); + expect(yield* h.store.linkedFrom(h.model)).toBe(existing); + expect(yield* h.store.installedPath(h.model)).toBe(h.stale); + expect(h.requests).toHaveLength(0); + // Pointing at T3 Code's own folder must not turn the link into a link to itself. + expect(yield* h.store.link([h.model], h.directory)).toEqual([]); + expect(yield* h.store.installedPath(h.model)).toBe(h.stale); + + yield* h.store.remove(h.model); + expect(yield* h.store.isReady(h.model)).toBe(false); + expect([...(yield* h.fs.readFile(existing))]).toEqual([...bytes]); + }).pipe(Effect.scoped, Effect.provide(dependencies)), +); + +it.effect("installs from a Hugging Face cache copy instead of downloading", () => + Effect.gen(function* () { + const h = yield* harness; + const path = yield* Path.Path; + const home = yield* h.fs.makeTempDirectoryScoped({ prefix: "speech-home-" }); + const snapshot = path.join( + home, + ".cache/huggingface/hub/models--fixture--speech-model/snapshots", + h.model.revision, + ); + yield* h.fs.makeDirectory(snapshot, { recursive: true }); + const cached = path.join(snapshot, h.model.filename); + yield* h.fs.writeFile(cached, bytes); + const store = yield* h.openStore.pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + + expect(yield* store.download(h.model)).toBe(h.stale); + expect(yield* store.linkedFrom(h.model)).toBe(cached); + expect(h.requests).toHaveLength(0); + }).pipe(Effect.scoped, Effect.provide(dependencies)), +); diff --git a/apps/server/src/speech/SpeechModelStore.ts b/apps/server/src/speech/SpeechModelStore.ts index 99f3fe6ca5bbc..d12999194df21 100644 --- a/apps/server/src/speech/SpeechModelStore.ts +++ b/apps/server/src/speech/SpeechModelStore.ts @@ -14,7 +14,10 @@ import type { PlatformError } from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ServerConfig from "../config.ts"; +import { expandHomePath } from "../os-jank.ts"; import type { SpeechModel } from "./model.ts"; type ModelFile = Pick; @@ -44,6 +47,7 @@ export class SpeechModelStore extends Context.Service< readonly isReady: (model: ModelFile) => Effect.Effect; /** The installed file once its SHA-256 has been verified, hashing it on first use. */ readonly installedPath: (model: ModelFile) => Effect.Effect; + /** Links a copy the Hugging Face cache or Handy already holds; fetches only without one. */ readonly download: ( model: ModelFile, progress?: SpeechModelDownloadProgress, @@ -51,6 +55,17 @@ export class SpeechModelStore extends Context.Service< string, SpeechModelDownloadError | PlatformError | HttpClientError.HttpClientError >; + /** + * Installs catalog files already on this machine as symlinks, never copies. + * `source` is a file or a folder to search; returns the ids it installed. + */ + readonly link: ( + models: ReadonlyArray, + source: string, + ) => Effect.Effect, PlatformError>; + /** The outside file a linked model points at. */ + readonly linkedFrom: (model: ModelFile) => Effect.Effect; + /** Removes T3 Code's file or link; a linked file itself is left in place. */ readonly remove: (model: ModelFile) => Effect.Effect; } >()("t3/speech/SpeechModelStore") {} @@ -59,9 +74,14 @@ const DOWNLOAD_ATTEMPTS = 3; const PARTIAL_FILENAME = /\.gguf\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.part$/i; +const LINK_SEARCH_DEPTH = 5; +const LINK_SEARCH_ENTRIES = 5_000; + const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const platform = yield* HostProcess.Platform; const http = yield* HttpClient.HttpClient; const random = yield* SecureRandom.SecureRandom; const config = yield* ServerConfig.ServerConfig; @@ -73,6 +93,28 @@ const make = Effect.gen(function* () { } } const modelPath = (model: ModelFile) => path.join(directory, model.filename); + const appDataDirectories = + platform === "darwin" + ? [path.join(home, "Library", "Application Support")] + : platform === "win32" + ? [path.join(home, "AppData", "Roaming")] + : [path.join(home, ".local", "share"), path.join(home, ".config")]; + // Where the Hugging Face cache and Handy keep these same pinned files. + const knownCopies = (model: ModelFile) => [ + path.join( + home, + ".cache", + "huggingface", + "hub", + `models--${model.id.replace("/", "--")}`, + "snapshots", + model.revision, + model.filename, + ), + ...appDataDirectories.map((appData) => + path.join(appData, "com.pais.handy", "models", model.filename), + ), + ]; // Files whose digest failed, by identity, so status polls stop calling them ready. const corrupt = new Map(); @@ -179,12 +221,81 @@ const make = Effect.gen(function* () { return downloaded; }); + const filesUnder = Effect.fn("SpeechModelStore.filesUnder")(function* (source: string) { + const files: Array<{ readonly file: string; readonly size: number }> = []; + const pending = [{ entry: source, depth: LINK_SEARCH_DEPTH }]; + for (let visited = 0; pending.length > 0 && visited < LINK_SEARCH_ENTRIES; visited += 1) { + const { entry, depth } = pending.pop()!; + const info = yield* statFile(entry); + if (info?.type === "File") files.push({ file: entry, size: Number(info.size) }); + if (info?.type !== "Directory" || depth === 0) continue; + for (const name of yield* fs.readDirectory(entry).pipe(Effect.orElseSucceed(() => []))) + pending.push({ entry: path.join(entry, name), depth: depth - 1 }); + } + return files; + }); + + const realPath = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => undefined)); + + const link: SpeechModelStore["Service"]["link"] = Effect.fn("SpeechModelStore.link")( + function* (models, source) { + const root = path.resolve( + yield* expandHomePath(source.trim()).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcess.HomeDirectory, home), + ), + ); + yield* fs.makeDirectory(directory, { recursive: true }); + const linked: string[] = []; + for (const { file, size } of yield* filesUnder(root)) { + for (const model of models) { + if (model.size !== size || linked.includes(model.id)) continue; + const finalPath = modelPath(model); + // Pointing at T3 Code's own model folder must not replace a file with a link to itself. + if ((yield* realPath(file)) === (yield* realPath(finalPath))) continue; + if (yield* verifiedPath(model, finalPath)) continue; + const hashed = yield* expectedFile(model, file); + if (!hashed || !(yield* digestMatches(model, file))) continue; + // Staged then renamed, so a failed link never removes the file already installed. + const staged = `${finalPath}.${yield* random.uuidV4()}.part`; + // Windows grants symlinks only to privileged accounts; a hard link also shares the bytes. + const hardLinked = yield* fs.symlink(file, staged).pipe( + Effect.as(false), + Effect.catch(() => fs.link(file, staged).pipe(Effect.as(true))), + ); + const current = yield* expectedFile(model, staged); + const marker = identity(model, hashed); + if (!current || identity(model, current) !== marker) { + yield* fs.remove(staged, { force: true }); + continue; + } + yield* fs.rename(staged, finalPath); + // A hard link cannot be read back, so its origin is recorded beside it. + yield* ( + hardLinked + ? fs.writeFileString(`${finalPath}.source`, file, { mode: 0o600 }) + : fs.remove(`${finalPath}.source`, { force: true }) + ).pipe(Effect.ignore); + yield* fs + .writeFileString(`${finalPath}.verified`, marker, { mode: 0o600 }) + .pipe(Effect.ignore); + linked.push(model.id); + } + } + return linked; + }, + ); + const download: SpeechModelStore["Service"]["download"] = Effect.fn("SpeechModelStore.download")( function* (model, progress = {}) { yield* Effect.annotateCurrentSpan({ modelId: model.id }); const finalPath = modelPath(model); yield* fs.makeDirectory(directory, { recursive: true }); if (yield* verifiedPath(model, finalPath, progress.onVerificationStart)) return finalPath; + for (const copy of knownCopies(model)) { + const linked = yield* link([model], copy).pipe(Effect.orElseSucceed(() => [])); + if (linked.length > 0) return finalPath; + } const partialPath = `${finalPath}.${yield* random.uuidV4()}.part`; const url = `https://huggingface.co/${model.id}/resolve/${model.revision}/${model.filename}`; return yield* Effect.gen(function* () { @@ -211,6 +322,7 @@ const make = Effect.gen(function* () { if (!complete || !(yield* digestMatches(model, partialPath))) return yield* new SpeechModelDownloadError({ modelId: model.id, reason: "verification" }); yield* fs.rename(partialPath, finalPath); + yield* fs.remove(`${finalPath}.source`, { force: true }).pipe(Effect.ignore); // Rename changes file metadata, so record the verified file's final identity. const info = yield* fs.stat(finalPath); yield* fs @@ -228,6 +340,13 @@ const make = Effect.gen(function* () { ); return SpeechModelStore.of({ + link, + linkedFrom: Effect.fn("SpeechModelStore.linkedFrom")((model) => + fs.readLink(modelPath(model)).pipe( + Effect.catch(() => fs.readFileString(`${modelPath(model)}.source`)), + Effect.orElseSucceed(() => undefined), + ), + ), isReady: Effect.fn("SpeechModelStore.isReady")((model) => expectedFile(model, modelPath(model)).pipe( Effect.map( @@ -243,6 +362,7 @@ const make = Effect.gen(function* () { const file = modelPath(model); yield* fs.remove(file, { force: true }); yield* fs.remove(`${file}.verified`, { force: true }); + yield* fs.remove(`${file}.source`, { force: true }); }), }); }); diff --git a/apps/server/src/speech/SpeechService.test.ts b/apps/server/src/speech/SpeechService.test.ts index 41db47f0376a4..8d980f2846271 100644 --- a/apps/server/src/speech/SpeechService.test.ts +++ b/apps/server/src/speech/SpeechService.test.ts @@ -103,6 +103,8 @@ const store = Layer.succeed( installedPath: (model) => Effect.sync(() => (readyModels.has(model.id) ? "test.gguf" : undefined)), download, + link: () => Effect.succeed([]), + linkedFrom: () => Effect.undefined, remove: (model) => Effect.sync(() => { readyModels.delete(model.id); diff --git a/apps/server/src/speech/SpeechService.ts b/apps/server/src/speech/SpeechService.ts index c5f0faebe18f4..b4ae198b9447d 100644 --- a/apps/server/src/speech/SpeechService.ts +++ b/apps/server/src/speech/SpeechService.ts @@ -138,6 +138,8 @@ export class SpeechService extends Context.Service< readonly selectModel: (modelId: string) => Effect.Effect; readonly cancelDownload: (modelId: string) => Effect.Effect; readonly removeModel: (modelId: string) => Effect.Effect; + /** Uses catalog model files already on this machine, from a file or folder path. */ + readonly linkModels: (source: string) => Effect.Effect; readonly prepareModel: Effect.Effect; readonly transcribe: ( pcmBytes: Uint8Array, @@ -467,6 +469,7 @@ const make = Effect.gen(function* () { size: definition.size, supportsStreaming: definition.supportsStreaming, supportsBufferedStreaming: true, + supportsModelLinking: true, supportsTranslation: definition.supportsTranslation, languages: definition.languages, supportsLanguageDetection: definition.supportsLanguageDetection, @@ -489,8 +492,8 @@ const make = Effect.gen(function* () { const models = yield* Effect.forEach( SPEECH_MODELS, (definition) => - store.isReady(definition).pipe( - Effect.map((ready): EnvironmentSpeechModel => { + Effect.all([store.isReady(definition), store.linkedFrom(definition)]).pipe( + Effect.map(([ready, linkedFrom]): EnvironmentSpeechModel => { const operation = downloading?.modelId === definition.id ? downloading : undefined; return { id: definition.id, @@ -514,6 +517,7 @@ const make = Effect.gen(function* () { ? "installed" : "downloadable", ...(operation ? { downloaded: operation.downloaded } : {}), + ...(ready && linkedFrom ? { linkedFrom } : {}), }; }), ), @@ -951,6 +955,21 @@ const make = Effect.gen(function* () { ), ), ), + linkModels: Effect.fn("SpeechService.linkModels")(function* (source) { + yield* requireSupported; + const settings = yield* readSettings("model linking"); + const linked = yield* exclusive( + "model linking", + store + .link(SPEECH_MODELS, source) + .pipe(Effect.mapError((cause) => speechError("model linking", cause))), + ); + const first = linked[0]; + if (first === undefined) return yield* new SpeechModelNotFoundError({ modelId: source }); + if (!(yield* store.isReady(selectedModel(settings)))) + yield* writeSettings("model linking", { speechModelId: first }); + return yield* modelsEffect; + }), updateAcceleration: Effect.fn("SpeechService.updateAcceleration")((acceleration) => writeSettings("acceleration update", { speechAcceleration: acceleration }).pipe( Effect.andThen(freshStatus("acceleration update")), diff --git a/apps/server/src/speech/http.test.ts b/apps/server/src/speech/http.test.ts index e431fc84699d8..1ca24566b5a46 100644 --- a/apps/server/src/speech/http.test.ts +++ b/apps/server/src/speech/http.test.ts @@ -81,6 +81,7 @@ effectIt.live("rejects oversized chunked audio without invoking transcription", prepareModel: Effect.void, models: () => Effect.succeed({ models: [] }), downloadModel: () => Effect.succeed({ models: [] }), + linkModels: () => Effect.succeed({ models: [] }), selectModel: () => Effect.succeed({ models: [] }), cancelDownload: () => Effect.succeed({ models: [] }), updateAcceleration: () => Effect.succeed({ supported: false as const, reason: "test" }), @@ -165,7 +166,31 @@ effectIt.effect( HttpApiBuilder.layer(HttpApi.make("environment").add(EnvironmentVoiceHttpApi)).pipe( Layer.provide(speechHttpApiLayer), Layer.provide(principal(AuthOrchestrationOperateScope, AuthSettingsWriteScope)), - Layer.provide(Layer.mock(SpeechService.SpeechService)({})), + Layer.provide( + Layer.mock(SpeechService.SpeechService)({ + models: () => + Effect.succeed({ + models: [ + { + id: "fixture/model", + name: "Fixture", + description: "", + size: 1, + languages: ["en"], + accuracy: 0, + speed: 0, + recommended: false, + supportsStreaming: false, + supportsLanguageDetection: false, + supportsTranslation: false, + active: true, + state: "installed", + linkedFrom: "/home/someone/models/fixture.gguf", + }, + ], + }), + }), + ), Layer.provide(cloud), Layer.provide(dependencies), Layer.provide(HttpServer.layerServices), @@ -229,6 +254,22 @@ effectIt.effect( 422, ); expect(transcribe).toHaveBeenLastCalledWith("openrouter", "acme/transcribe-1"); + // Naming a path on the environment needs filesystem access, not only settings access. + const link = yield* Effect.promise(() => + handler( + new Request("http://localhost/api/voice/models/link", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: "/models" }), + }), + context, + ), + ); + expect(link.status).toBe(403); + const listed = yield* Effect.promise(() => + handler(new Request("http://localhost/api/voice/models"), context), + ); + expect(yield* Effect.promise(() => listed.text())).not.toContain("/home/someone"); }).pipe(Effect.scoped), ); @@ -269,6 +310,7 @@ effectIt.effect.each([ prepareModel: Effect.void, models: () => Effect.succeed({ models: [] }), downloadModel: () => Effect.fail(error), + linkModels: () => Effect.fail(error), selectModel: () => Effect.fail(error), cancelDownload: () => Effect.fail(error), updateAcceleration: () => Effect.fail(error), diff --git a/apps/server/src/speech/http.ts b/apps/server/src/speech/http.ts index eb4a7a01b88fb..bf91b4c1ef3a6 100644 --- a/apps/server/src/speech/http.ts +++ b/apps/server/src/speech/http.ts @@ -1,9 +1,12 @@ import { decodeSpeechPcmRequest } from "@t3tools/shared/speech"; import { + AuthFilesystemReadScope, AuthOrchestrationOperateScope, AuthSettingsWriteScope, type AuthEnvironmentScope, + EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi, + type EnvironmentSpeechModels, EnvironmentVoiceBodyLimit, SPEECH_MAX_OPTIONS_BYTES, } from "@t3tools/contracts"; @@ -53,6 +56,14 @@ const mapSpeechError = (effect: Effect.Effect model) }; +}); + export const speechHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "voice", @@ -133,35 +144,54 @@ export const speechHttpApiLayer = HttpApiBuilder.group( "models", Effect.fn("environment.voice.models")(function* (args) { yield* guard(args.endpoint.name); - return yield* speech.models(args.query.benchmarks === "1").pipe(mapSpeechError); + return yield* speech + .models(args.query.benchmarks === "1") + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "downloadModel", Effect.fn("environment.voice.downloadModel")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.downloadModel(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .downloadModel(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "selectModel", Effect.fn("environment.voice.selectModel")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.selectModel(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .selectModel(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "cancelModelDownload", Effect.fn("environment.voice.cancelModelDownload")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.cancelDownload(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .cancelDownload(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "removeModel", Effect.fn("environment.voice.removeModel")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.removeModel(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .removeModel(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); + }), + ) + .handle( + "linkModels", + Effect.fn("environment.voice.linkModels")(function* (args) { + yield* guard(args.endpoint.name, AuthSettingsWriteScope); + // The request names a path on this machine and the result reveals what is there. + yield* requireEnvironmentScope(AuthFilesystemReadScope); + return yield* speech.linkModels(args.payload.path).pipe(mapSpeechError); }), ) .handle( diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 910584d110b31..bcbfedff60893 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,7 +1,12 @@ import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; -import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; +import { + formatHostForUrl, + isPrivateNetworkHost, + isSharedAddressSpaceHost, + isWildcardHost, +} from "@t3tools/shared/hostClassification"; import { QrCode } from "@t3tools/shared/qrCode"; import { buildPairingAppLink } from "@t3tools/shared/remote"; import * as Effect from "effect/Effect"; @@ -33,12 +38,6 @@ export const isLoopbackHost = (host: string | undefined): boolean => { ); }; -export const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -export const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; - /** * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and * macOS's `bridge100`+) have private addresses only this machine reaches. A diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 0de8e89f07de4..3446015780e29 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProjectId, ProviderDriverKind, type ModelSelection, type ServerProvider, @@ -542,6 +543,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "pending", "exhausted", "not installed", + "not installed, fallback disabled for project", "signed out", "signed out, fallback failed", ] as const; @@ -550,6 +552,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima Effect.gen(function* () { const primary = ProviderInstanceId.make("codex"); const fallback = ProviderInstanceId.make("claude_work"); + const projectId = ProjectId.make("project-a"); const selection = { instanceId: primary, model: "chat-model", @@ -573,7 +576,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ...(primaryState === "pending" && instanceId === primary ? { installed: false, status: "warning", auth: { status: "unknown" } } : {}), - ...(primaryState === "not installed" && instanceId === primary + ...(primaryState.startsWith("not installed") && instanceId === primary ? { installed: false, status: "error", auth: { status: "unknown" } } : {}), ...(primaryState.startsWith("signed out") && instanceId === primary @@ -626,6 +629,13 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima providerInstances: { [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, }, + ...(primaryState === "not installed, fallback disabled for project" + ? { + projectSettingsOverrides: { + [projectId]: { providerInstances: { [fallback]: { enabled: false } } }, + }, + } + : {}), }), Layer.mock(TextGenerators.TextGenerators)({ runner: () => Effect.undefined }), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -640,11 +650,13 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima message: "hello", linkedContext: "", modelSelection: selection, + projectId, }), ).toEqual({ title: "Generated title" }); expect(calls).toEqual([ primaryState === "ready" || primaryState === "pending" || + primaryState === "not installed, fallback disabled for project" || primaryState === "signed out, fallback failed" ? selection : { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 7f09ed3b9046c..6fb2d443fba21 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { isProviderAvailable, isProviderTextGenerationCapable, @@ -84,6 +84,7 @@ export const make = Effect.gen(function* () { const generate = Effect.fn("TextGeneration.generate")(function* ( operation: TextGenerationOp, selection: ModelSelection, + projectId: ProjectId | null | undefined, invoke: ( service: ProviderTextGeneration, modelSelection: ModelSelection, @@ -132,7 +133,7 @@ export const make = Effect.gen(function* () { return ( instanceId !== selection.instanceId && provider?.enabled === true && - resolveProjectProviderInstanceEnabled(settings, null, instanceId) && + resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) && provider.installed && provider.auth.status !== "unauthenticated" && provider.status !== "error" && @@ -158,23 +159,35 @@ export const make = Effect.gen(function* () { }); return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { - return generate("generateCommitMessage", input.modelSelection, (service, modelSelection) => - service.generateCommitMessage({ ...input, modelSelection }), + return generate( + "generateCommitMessage", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generateCommitMessage({ ...input, modelSelection }), ); }), generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { - return generate("generatePrContent", input.modelSelection, (service, modelSelection) => - service.generatePrContent({ ...input, modelSelection }), + return generate( + "generatePrContent", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generatePrContent({ ...input, modelSelection }), ); }), generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { - return generate("generateBranchName", input.modelSelection, (service, modelSelection) => - service.generateBranchName({ ...input, modelSelection }), + return generate( + "generateBranchName", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generateBranchName({ ...input, modelSelection }), ); }), generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { - return generate("generateReviewGuide", input.modelSelection, (service, modelSelection) => - service.generateReviewGuide({ ...input, modelSelection }), + return generate( + "generateReviewGuide", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generateReviewGuide({ ...input, modelSelection }), ); }), generateTranscriptionPostProcessing: Effect.fn( @@ -183,6 +196,7 @@ export const make = Effect.gen(function* () { return generate( "generateTranscriptionPostProcessing", input.modelSelection, + null, (service, modelSelection) => service.generateTranscriptionPostProcessing({ ...input, modelSelection }), ); @@ -191,6 +205,7 @@ export const make = Effect.gen(function* () { return generate( "generateThreadTitle", input.modelSelection, + input.projectId, (textGeneration, modelSelection) => Effect.gen(function* () { const linkedContext = diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index d2ed0c58a5b62..e3855b45b852f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -95,6 +95,42 @@ function claudeLine( })}\n`; } +/** One content-block line of message `msg_1`; Claude Code writes several per message. */ +function claudeCopy(usage: Readonly>): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_1", + sessionId: "session-1", + message: { id: "msg_1", model: "claude-fable-5", usage }, + })}\n`; +} + +// A subagent message: its stream-start copy, then its final copy. +const PARTIAL_USAGE = { + input_tokens: 2, + cache_creation_input_tokens: 1097, + cache_read_input_tokens: 67934, + output_tokens: 3, +}; +const COMPLETE_USAGE = { ...PARTIAL_USAGE, output_tokens: 264 }; +const COPY_RATES = { + "claude-fable-5": { + input_cost_per_token: 1e-5, + output_cost_per_token: 5e-5, + cache_read_input_token_cost: 1e-6, + cache_creation_input_token_cost: 1.25e-5, + }, +}; +const COMPLETE_COST_USD = 2 * 1e-5 + 67934 * 1e-6 + 1097 * 1.25e-5 + 264 * 5e-5; +const COMPLETE_TOTALS = { + uncachedInputTokens: 2, + cachedInputTokens: 67934, + cacheCreationTokens: 1097, + outputTokens: 264, + reasoningTokens: 0, +}; + const WINDOW: UsageSummaryInput = { timeZone: "UTC", sinceDay: UsageDay.make("2026-07-31"), @@ -2712,6 +2748,65 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "merges partial Claude copies through tail replacement, newline completion and restart", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const partial = claudeCopy(PARTIAL_USAGE); + const complete = claudeCopy(COMPLETE_USAGE); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, partial)); + yield* Effect.gen(function* () { + const first = yield* UsageService.make; + assert.strictEqual(totalOutputTokens(yield* first.readSummary(WINDOW)), 3); + yield* first.awaitPersisted; + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, complete.trimEnd())); + const resumed = yield* UsageService.make; + const tail = yield* resumed.readSummary(WINDOW); + assert.deepStrictEqual(tail.buckets[0]?.totals, COMPLETE_TOTALS); + assert.strictEqual(tail.buckets[0]?.records, 1); + assert.closeTo(tail.buckets[0]?.costUsd ?? 0, COMPLETE_COST_USD, 1e-12); + yield* resumed.awaitPersisted; + const restarted = yield* UsageService.make; + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, tail.buckets); + + // Grow the file without changing its settled prefix: the old tail must be discarded. + yield* Effect.promise(() => + NodeFSP.writeFile(transcript, partial + claudeLine(2, 7) + claudeLine(3, 5)), + ); + const replaced = yield* restarted.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(replaced), 3 + 7 + 5); + assert.strictEqual(replaced.buckets[0]?.records, 3); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, complete.trimEnd())); + const grown = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(grown.buckets[0]?.totals, { + ...COMPLETE_TOTALS, + uncachedInputTokens: 22, + outputTokens: 264 + 7 + 5, + }); + assert.strictEqual(grown.buckets[0]?.records, 3); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, "\n")); + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, grown.buckets); + yield* restarted.awaitPersisted; + assert.deepStrictEqual( + (yield* (yield* UsageService.make).readSummary(WINDOW)).buckets, + grown.buckets, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-copy-lifecycle-test", + home, + settings, + ratesDocument: COPY_RATES, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live( "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", () => @@ -2768,7 +2863,7 @@ describe("UsageService", () => { ); it.live.each([4, 5, 6, 7, 8, 10])( - "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts and the old file", + "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts, removes the old file", (version) => Effect.gen(function* () { const { home, settings } = yield* setup; @@ -2817,7 +2912,7 @@ describe("UsageService", () => { yield* first.readSummary(WINDOW); yield* first.awaitPersisted; - const legacy = yield* Effect.promise(async () => { + yield* Effect.promise(async () => { const livePath = await NodeFSP.realpath(live); const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { files: Record }>; @@ -2855,7 +2950,9 @@ describe("UsageService", () => { } } await NodeFSP.rm(deleted); - return text; + await NodeFSP.mkdir(`${cachePath}.stale`); + await NodeFSP.utimes(`${cachePath}.stale`, 0, 0); + await NodeFSP.mkdir(`${cachePath}.active`); }); const upgraded = yield* UsageService.make; @@ -2866,10 +2963,12 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); - assert.strictEqual( - yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), - legacy, + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json"); + assert.deepStrictEqual( + (yield* Effect.promise(() => NodeFSP.readdir(stateDir))) + .filter((name) => name.startsWith("usage-scan-cache")) + .toSorted(), + [SCAN_CACHE_FILE_NAME, `${SCAN_CACHE_FILE_NAME}.active`], ); }).pipe( Effect.provide( diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index b9e3e7843ff88..1b445382ef832 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -81,6 +81,7 @@ const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; const CACHE_RETENTION_DAYS = 92; +const STALE_TEMP_DIRECTORY_MS = 60 * 60 * 1000; const TRANSCRIPT_READ_CONCURRENCY = 4; @@ -279,7 +280,7 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); - const writeCacheFile = (filePath: string, contents: string) => + const writeCacheFile = (filePath: string, contents: string | Iterable) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -661,6 +662,32 @@ export const make = Effect.gen(function* () { // Serialize writes so concurrently finished window scans cannot overwrite newer snapshots. const persistLock = yield* Semaphore.make(1); + // The saved cache supersedes older versions. Temp folders an aborted write left behind are + // removed once idle for an hour, so a write in progress from another server survives. + let supersededFilesRemoved = false; + const removeSupersededCacheFiles = Effect.gen(function* () { + for (const fileName of LEGACY_SCAN_CACHE_FILE_NAMES) { + yield* fileSystem.remove(path.join(config.stateDir, fileName)).pipe(Effect.ignore); + } + const tempPrefixes = [SCAN_CACHE_FILE_NAME, ...LEGACY_SCAN_CACHE_FILE_NAMES].map( + (fileName) => `${fileName}.`, + ); + const staleBeforeMs = (yield* Clock.currentTimeMillis) - STALE_TEMP_DIRECTORY_MS; + for (const name of yield* fileSystem.readDirectory(config.stateDir)) { + if (!tempPrefixes.some((prefix) => name.startsWith(prefix))) continue; + const tempPath = path.join(config.stateDir, name); + yield* fileSystem.stat(tempPath).pipe( + Effect.flatMap((info) => { + const mtimeMs = Option.getOrUndefined(info.mtime)?.getTime(); + return info.type === "Directory" && mtimeMs !== undefined && mtimeMs <= staleBeforeMs + ? fileSystem.remove(tempPath, { recursive: true }) + : Effect.void; + }), + Effect.ignore, + ); + } + }).pipe(Effect.ignore); + // Clear dirty before encoding to retain concurrent changes; restore it on failure for retry. const persistCaches = Effect.gen(function* () { if (cacheDirty) { @@ -669,6 +696,13 @@ export const make = Effect.gen(function* () { writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), ).pipe( Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)), + Effect.andThen( + Effect.suspend(() => { + if (supersededFilesRemoved) return Effect.void; + supersededFilesRemoved = true; + return removeSupersededCacheFiles; + }), + ), Effect.catchCause(() => Effect.sync(() => { cacheDirty = true; @@ -693,7 +727,7 @@ export const make = Effect.gen(function* () { /** * Reads run concurrently, and the caller stores updates in walk order rather * than completion order: saved records of deleted transcripts aggregate in - * cache order, where the first copy of a duplicate wins. Untraced: a scan + * cache order, where the first copy of a duplicate decides its bucket. Untraced: a scan * reads thousands of files, so scanTranscriptDir records the counts instead. */ const readFileRecords = Effect.fnUntraced(function* ( @@ -771,9 +805,8 @@ export const make = Effect.gen(function* () { } } const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = parsed.tailRecords; return { records: tailRecords.length === 0 ? records : [...records, ...tailRecords], @@ -907,7 +940,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); - // Scan independent sources together, directories sequentially to bound open files; preserve order for first-copy dedupe. + // Scan independent sources together, directories sequentially to bound open files; keep order, since the first copy decides a duplicate's bucket. const [transcripts, scanDirs] = yield* Effect.all( [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 7e24cabf961cd..d314d987eb71b 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -48,17 +49,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function fixture( options: { - accounts?: ReadonlyArray< - Omit<(typeof accounts)[number], "email" | "id_token"> & { - email?: string; - disabled?: boolean; - project_id?: string; - id_token?: (typeof accounts)[number]["id_token"] & { - plan_type?: string; - chatgpt_plan_type?: string; - }; - } - >; + accounts?: Array<{ + id: string; + auth_index: string; + provider: string; + email?: string; + disabled?: boolean; + project_id?: string; + id_token?: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; + }>; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; } = {}, @@ -440,6 +439,191 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + const antigravitySummary = { + buckets: [ + { + bucketId: "gemini-weekly", + window: "weekly", + remainingFraction: 0.75, + resetTime: "2099-01-01T00:00:00Z", + }, + ], + }; + + it.effect("reads an xAI account as Grok with Grok CLI headers", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ id: "xai.json", auth_index: "x", provider: "xai", email: "x@example.com" }], + upstream: () => ({ + status: 200, + body: { + config: { + creditUsagePercent: 40, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: "2099-01-01T00:00:00Z" }, + }, + }, + }), + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account).toMatchObject({ driver: "grok", email: "x@example.com" }); + expect(account?.usageLimits.windows).toMatchObject([ + { id: "subscription", kind: "weekly", usedPercent: 40 }, + ]); + const call = test.requests.find((request) => request.body?.url)?.body; + expect(call?.method).toBe("GET"); + expect(call?.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); + expect(call?.header).toMatchObject({ + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": expect.any(String), + "User-Agent": expect.stringMatching(/^grok-cli\//), + }); + }), + ); + + it.effect("reads an Antigravity summary and keys the account by its Google ID", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { + id: "antigravity-g@example.com.json", + auth_index: "g", + provider: "antigravity", + email: "g@example.com", + project_id: "project-7", + }, + ], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/userinfo") ? { id: "google-42" } : antigravitySummary, + }), + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account?.driver).toBe("antigravity"); + expect(account?.email).toBeUndefined(); + expect(account?.usageLimits.credentialFingerprint).toBe( + // sha256("google-account\0google-42"), as a local Antigravity probe reports it. + "136d4952771fd67a3600053f37492cbd05828712e0b1c89ebab03d4c22ab6554", + ); + expect(account?.usageLimits.windows).toMatchObject([ + { id: "gemini-weekly", kind: "weekly", usedPercent: 25 }, + ]); + const summary = test.requests.find((request) => request.body?.method === "POST")?.body; + expect(summary?.url).toBe( + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", + ); + expect(summary?.data).toBe(encodeJson({ project: "project-7" })); + expect(summary).toMatchObject({ + auth_index: "g", + method: "POST", + header: { + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": + "antigravity/hub/2.24.0 (aidev_client; os_type=linux; arch=x64; cl=963137146)", + }, + }); + const identityCalls = test.requests.filter((request) => + request.body?.url?.endsWith("/userinfo"), + ); + expect(identityCalls).toHaveLength(1); + expect(identityCalls[0]?.body).toMatchObject({ + method: "GET", + url: "https://www.googleapis.com/oauth2/v2/userinfo", + header: { Authorization: "Bearer $TOKEN$" }, + }); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Architecture, "x64"), + ), + ); + + it.effect("keeps Antigravity limits and listing email when its identity read fails", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { + id: "antigravity.json", + auth_index: "g", + provider: "antigravity", + email: "g@example.com", + project_id: "project-7", + }, + ], + upstream: (request) => + request.url?.endsWith("/userinfo") + ? { status: 403, body: { error: "denied" } } + : { + status: 200, + body: antigravitySummary, + }, + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result).toHaveLength(1); + const [account] = result; + expect(account).toMatchObject({ driver: "antigravity", email: "g@example.com" }); + expect(account?.usageLimits.credentialFingerprint).toBeUndefined(); + expect(account?.usageLimits.unavailable).toBeUndefined(); + expect(account?.usageLimits.windows).toMatchObject([ + { id: "gemini-weekly", kind: "weekly", usedPercent: 25 }, + ]); + }), + ); + + it.effect.each([ + { name: "a refused xAI call", provider: "xai", status: 403, body: { error: "denied" } }, + { name: "a malformed xAI answer", provider: "xai", status: 200, body: { config: "x" } }, + { name: "a refused Antigravity call", provider: "antigravity", status: 401, body: {} }, + { name: "a malformed Antigravity answer", provider: "antigravity", status: 200, body: [] }, + { name: "an empty Antigravity summary", provider: "antigravity", status: 200, body: {} }, + ])("reports $name as unreadable", ({ provider, status, body }) => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ id: `${provider}.json`, auth_index: "a", provider, project_id: "project-7" }], + upstream: () => ({ status, body }), + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed"); + }), + ); + + it.effect.each([undefined, "", " "])( + "rejects a missing or blank Antigravity project (%s) after discovery", + (project_id) => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { + id: "antigravity.json", + auth_index: "a", + provider: "antigravity", + ...(project_id === undefined ? {} : { project_id }), + }, + ], + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed"); + expect(test.requests.map((request) => request.path)).toEqual([ + "/v0/management/auth-files", + "/v0/management/api-call", + ]); + }), + ); + + it.effect("skips accounts of providers the hub table does not know", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { id: "kiro.json", auth_index: "k", provider: "kiro", email: "k@example.com" }, + { ...accounts[0]!, provider: "claude" }, + ], + upstream: () => ({ status: 200, body: { five_hour: { utilization: 1, resets_at: null } } }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result.map((account) => account.driver)).toEqual(["claudeAgent"]); + expect(test.requests.map((request) => request.body?.auth_index)).not.toContain("k"); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); @@ -609,6 +793,7 @@ describe("CLIProxyAPI built-in management API", () => { "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary", "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", + "https://www.googleapis.com/oauth2/v2/userinfo", ]); }), ); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 46b66e0348857..5478fb896a146 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -6,6 +6,7 @@ import { type UsageLimitSourceAccount, type UsageLimitSourceConfig, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; @@ -15,10 +16,19 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import { + GROK_USAGE_URL, + GrokUsageResponse, + grokUsageResponseToLimits, +} from "@t3tools/provider-grok/server"; import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; -import { GrokUsageResponse, grokUsageResponseToLimits } from "@t3tools/provider-grok/server"; +import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; +import { + antigravityAccountFingerprint, + antigravitySummaryWindows, + antigravityUserAgent, +} from "../provider/antigravityUsageLimits.ts"; import { clampPercent, makeUnavailableUsageLimits, @@ -31,19 +41,79 @@ export interface CliproxyHub { readonly managementKey: string; } +const AntigravityBucket = Schema.Struct({ + displayName: Schema.optional(Schema.String), + display_name: Schema.optional(Schema.String), + window: Schema.optional(Schema.String), + remainingFraction: Schema.optional(Schema.Number), + remaining_fraction: Schema.optional(Schema.Number), + resetTime: Schema.optional(Schema.NullOr(Schema.String)), + reset_time: Schema.optional(Schema.NullOr(Schema.String)), +}); +const AntigravityQuota = Schema.Struct({ + groups: Schema.optional( + Schema.Array( + Schema.Struct({ + displayName: Schema.optional(Schema.String), + display_name: Schema.optional(Schema.String), + buckets: Schema.optional(Schema.Array(AntigravityBucket)), + }), + ), + ), +}); +const CodeAssistProject = Schema.Union([ + Schema.String, + Schema.Struct({ id: Schema.optional(Schema.String) }), +]); +const CodeAssist = Schema.Struct({ + cloudaicompanionProject: Schema.optional(CodeAssistProject), +}); +const KimiDetail = Schema.Struct({ + remaining: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Number), + resetTime: Schema.optional(Schema.NullOr(Schema.String)), + reset_at: Schema.optional(Schema.NullOr(Schema.String)), + resetAt: Schema.optional(Schema.NullOr(Schema.String)), +}); +const KimiUsage = Schema.Struct({ + usage: Schema.optional(KimiDetail), + limits: Schema.optional( + Schema.Array( + Schema.Struct({ + window: Schema.optional( + Schema.Struct({ + duration: Schema.optional(Schema.Number), + timeUnit: Schema.optional(Schema.String), + }), + ), + detail: Schema.optional(KimiDetail), + }), + ), + ), +}); +const QuotaNumber = Schema.Union([Schema.Number, Schema.String]); +const DevinQuota = Schema.Struct({ + userStatus: Schema.optional( + Schema.Struct({ + planStatus: Schema.optional( + Schema.Struct({ + planInfo: Schema.optional(Schema.Struct({ planName: Schema.optional(Schema.String) })), + dailyQuotaRemainingPercent: Schema.optional(QuotaNumber), + weeklyQuotaRemainingPercent: Schema.optional(QuotaNumber), + dailyQuotaResetAtUnix: Schema.optional(QuotaNumber), + weeklyQuotaResetAtUnix: Schema.optional(QuotaNumber), + }), + ), + }), + ), +}); + const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, provider: Schema.String, email: Schema.optional(Schema.String), disabled: Schema.optional(Schema.Boolean), - id_token: Schema.optional( - Schema.Struct({ - chatgpt_account_id: Schema.optional(Schema.String), - chatgpt_plan_type: Schema.optional(Schema.String), - plan_type: Schema.optional(Schema.String), - }), - ), project_id: Schema.optional(Schema.String), projectId: Schema.optional(Schema.String), metadata: Schema.optional( @@ -52,6 +122,13 @@ const AuthFile = Schema.Struct({ projectId: Schema.optional(Schema.String), }), ), + id_token: Schema.optional( + Schema.Struct({ + chatgpt_account_id: Schema.optional(Schema.String), + chatgpt_plan_type: Schema.optional(Schema.String), + plan_type: Schema.optional(Schema.String), + }), + ), }); const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) }); const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String }); @@ -114,72 +191,6 @@ const CreditList = Schema.Struct({ }), ), }); -const AntigravityBucket = Schema.Struct({ - displayName: Schema.optional(Schema.String), - display_name: Schema.optional(Schema.String), - window: Schema.optional(Schema.String), - remainingFraction: Schema.optional(Schema.Number), - remaining_fraction: Schema.optional(Schema.Number), - resetTime: Schema.optional(Schema.NullOr(Schema.String)), - reset_time: Schema.optional(Schema.NullOr(Schema.String)), -}); -const AntigravityQuota = Schema.Struct({ - groups: Schema.optional( - Schema.Array( - Schema.Struct({ - displayName: Schema.optional(Schema.String), - display_name: Schema.optional(Schema.String), - buckets: Schema.optional(Schema.Array(AntigravityBucket)), - }), - ), - ), -}); -const CodeAssistProject = Schema.Union([ - Schema.String, - Schema.Struct({ id: Schema.optional(Schema.String) }), -]); -const CodeAssist = Schema.Struct({ - cloudaicompanionProject: Schema.optional(CodeAssistProject), -}); -const KimiDetail = Schema.Struct({ - remaining: Schema.optional(Schema.Number), - limit: Schema.optional(Schema.Number), - resetTime: Schema.optional(Schema.NullOr(Schema.String)), - reset_at: Schema.optional(Schema.NullOr(Schema.String)), - resetAt: Schema.optional(Schema.NullOr(Schema.String)), -}); -const KimiUsage = Schema.Struct({ - usage: Schema.optional(KimiDetail), - limits: Schema.optional( - Schema.Array( - Schema.Struct({ - window: Schema.optional( - Schema.Struct({ - duration: Schema.optional(Schema.Number), - timeUnit: Schema.optional(Schema.String), - }), - ), - detail: Schema.optional(KimiDetail), - }), - ), - ), -}); -const QuotaNumber = Schema.Union([Schema.Number, Schema.String]); -const DevinQuota = Schema.Struct({ - userStatus: Schema.optional( - Schema.Struct({ - planStatus: Schema.optional( - Schema.Struct({ - planInfo: Schema.optional(Schema.Struct({ planName: Schema.optional(Schema.String) })), - dailyQuotaRemainingPercent: Schema.optional(QuotaNumber), - weeklyQuotaRemainingPercent: Schema.optional(QuotaNumber), - dailyQuotaResetAtUnix: Schema.optional(QuotaNumber), - weeklyQuotaResetAtUnix: Schema.optional(QuotaNumber), - }), - ), - }), - ), -}); const decodeAuthFiles = Schema.decodeUnknownEffect(AuthFiles); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -192,6 +203,7 @@ const decodeAntigravityQuota = Schema.decodeUnknownEffect(Schema.fromJsonString( const decodeCodeAssist = Schema.decodeUnknownEffect(Schema.fromJsonString(CodeAssist)); const decodeKimiUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(KimiUsage)); const decodeDevinQuota = Schema.decodeUnknownEffect(Schema.fromJsonString(DevinQuota)); +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); // Extra CAs can fix untrusted chains; expiry/hostname failures report codes only. @@ -260,99 +272,111 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }); -/** - * Providers whose subscription status the hub can read. Names follow the - * auth-file `provider` values CLIProxyAPI writes, including aliases. - */ -type HubProvider = "codex" | "claude" | "grok" | "antigravity" | "devin" | "kimi"; - -function hubProvider(provider: string): HubProvider | undefined { - switch (provider.trim().toLowerCase().replaceAll("_", "-")) { - case "codex": - return "codex"; - case "claude": - case "anthropic": - return "claude"; - case "grok": - case "xai": - case "x-ai": - return "grok"; - case "antigravity": - case "anti-gravity": - case "gemini": - return "antigravity"; - case "devin": - case "cognition": - return "devin"; - case "kimi": - return "kimi"; - default: - return undefined; - } +type AuthFile = typeof AuthFile.Type; +/** Sends one upstream request with the account's token, as GET or as a JSON POST of `data`. */ +type HubCall = ( + url: string, + data?: unknown, +) => Effect.Effect; +type HubReading = Omit; +interface HubProvider { + readonly driver: ProviderDriverKind; + readonly header: ( + account: AuthFile, + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + ) => Record; + readonly read: (input: { + readonly account: AuthFile; + readonly call: HubCall; + readonly checkedAt: string; + }) => Effect.Effect; } -function driverFor(provider: HubProvider): ProviderDriverKind { - switch (provider) { - case "codex": - return ProviderDriverKind.make("codex"); - case "claude": - return ProviderDriverKind.make("claudeAgent"); - case "grok": - return ProviderDriverKind.make("grok"); - case "antigravity": - return ProviderDriverKind.make("antigravity"); - case "devin": - return ProviderDriverKind.make("devin"); - case "kimi": - return ProviderDriverKind.make("kimi"); - default: { - const exhaustive: never = provider; - return exhaustive; - } - } -} +const listedIdentity = (account: AuthFile) => ({ + ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), +}); -const BEARER = "Bearer $TOKEN$"; +const credits = Effect.fn("CliproxyApi.credits")(function* (call: HubCall) { + const response = yield* decodeCreditList(yield* call(CREDIT_URL)); + const now = DateTime.toEpochMillis(yield* DateTime.now); + return response.credits + .filter( + (credit) => + credit.reset_type === "codex_rate_limits" && + credit.status === "available" && + Date.parse(credit.expires_at) > now, + ) + .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); +}); -function codexHeaders(account: typeof AuthFile.Type): Record { - return { - Authorization: BEARER, +const CODEX_HUB: HubProvider = { + driver: ProviderDriverKind.make("codex"), + header: (account) => ({ + Authorization: "Bearer $TOKEN$", "Content-Type": "application/json", "OpenAI-Beta": "codex-1", Originator: "Codex Desktop", ...(account.id_token?.chatgpt_account_id ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id } : {}), - }; -} - -const CLAUDE_HEADERS = { - Authorization: BEARER, - "anthropic-beta": "oauth-2025-04-20", - // The OAuth usage endpoint can return 429 without a Claude Code user agent. - "User-Agent": "claude-code/2.1.273", -}; -const GROK_USAGE_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; -const GROK_HEADERS = { - Authorization: BEARER, - "x-xai-token-auth": "xai-grok-cli", - "x-grok-client-version": "0.2.91", - accept: "*/*", - "user-agent": "grok-pager/0.2.91 grok-shell/0.2.91 (macos; aarch64)", + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeCodexUsage(yield* call(`${CODEX_BASE}/usage`)); + const toWindow = (window: typeof CodexWindow.Type | null | undefined) => + window + ? { + usedPercent: window.used_percent, + resetsAt: window.reset_at ?? null, + ...(window.limit_window_seconds === undefined + ? {} + : { windowDurationMins: window.limit_window_seconds / 60 }), + } + : null; + const available = yield* credits(call).pipe(Effect.orElseSucceed(() => undefined)); + const next = available?.[0]; + const planType = + usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; + return { + ...listedIdentity(account), + plan: codexPlanLabel(planType), + usageLimits: { + ...codexRateLimitsToLimits({ + checkedAt, + snapshot: { + planType: planType ?? null, + primary: toWindow(usage.rate_limit?.primary_window), + secondary: toWindow(usage.rate_limit?.secondary_window), + }, + }), + ...(available + ? { + resetCredits: { + availableCount: available.length, + ...(next + ? { + nextCreditId: next.id, + nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)), + } + : {}), + }, + } + : {}), + }, + }; + }), }; -// Antigravity traffic is served by the daily host; later hosts are fallbacks. + const ANTIGRAVITY_HOSTS = [ "https://daily-cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com", "https://cloudcode-pa.googleapis.com", ] as const; -const ANTIGRAVITY_HEADERS = { - Authorization: BEARER, - "Content-Type": "application/json", - "User-Agent": "antigravity/cli/1.0.13 (aidev_client; os_type=darwin; arch=arm64)", -}; const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; -const KIMI_HEADERS = { Authorization: BEARER }; +const KIMI_HEADERS = { Authorization: "Bearer $TOKEN$" }; const DEVIN_QUOTA_URL = "https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus"; const DEVIN_HEADERS = { @@ -430,7 +454,7 @@ function usedFromQuota( return clampPercent(Math.round((1 - remaining / limit) * 1000) / 10); } -function antigravityProjectId(account: typeof AuthFile.Type): string | undefined { +function antigravityProjectId(account: AuthFile): string | undefined { return [ account.project_id, account.projectId, @@ -590,9 +614,176 @@ function devinAccount(quota: typeof DevinQuota.Type): { return { plan, windows }; } +const GROK_CLI_VERSION = "1.0.77"; + +// Keyed by the auth-files `provider`; accounts of any other provider are skipped. +const HUB_PROVIDERS = new Map([ + ["codex", CODEX_HUB], + [ + "claude", + { + driver: ProviderDriverKind.make("claudeAgent"), + header: () => ({ + Authorization: "Bearer $TOKEN$", + "anthropic-beta": "oauth-2025-04-20", + // The OAuth usage endpoint can return 429 without a Claude Code user agent. + "User-Agent": "claude-code/2.1.273", + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeClaudeUsage( + yield* call("https://api.anthropic.com/api/oauth/usage"), + ); + const model_scoped = (usage.limits ?? []).flatMap((limit) => + limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" + ? [ + { + display_name: limit.scope.model.display_name, + utilization: limit.percent, + resets_at: limit.resets_at ?? null, + }, + ] + : [], + ); + const rateLimits = { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + spend: usage.spend, + extra_usage: usage.extra_usage ?? null, + }; + return { + ...listedIdentity(account), + plan: "Claude Subscription", + usageLimits: claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: rateLimits }, + }).limits, + }; + }), + }, + ], + [ + "xai", + { + driver: ProviderDriverKind.make("grok"), + header: () => ({ + Authorization: "Bearer $TOKEN$", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": GROK_CLI_VERSION, + "User-Agent": `grok-cli/${GROK_CLI_VERSION}`, + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeGrokUsage(yield* call(GROK_USAGE_URL)); + return { + ...listedIdentity(account), + usageLimits: grokUsageResponseToLimits(usage, checkedAt), + }; + }), + }, + ], + [ + "antigravity", + { + driver: ProviderDriverKind.make("antigravity"), + header: (_account, platform, architecture) => ({ + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(platform, architecture), + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + let project = antigravityProjectId(account); + if (!project) { + const discovered = yield* call(`${ANTIGRAVITY_HOSTS[0]}/v1internal:loadCodeAssist`, { + metadata: { ideType: "ANTIGRAVITY" }, + }).pipe( + Effect.flatMap(decodeCodeAssist), + Effect.map((value) => codeAssistProject(value.cloudaicompanionProject)), + Effect.option, + ); + if (Option.isSome(discovered)) project = discovered.value; + } + if (!project) + return yield* new UsageLimitSourceError({ + detail: "Antigravity account has no project.", + }); + let windows: ReadonlyArray = []; + for (const host of ANTIGRAVITY_HOSTS) { + const requestedAt = DateTime.toEpochMillis(yield* DateTime.now); + const summary = yield* call(`${host}/v1internal:retrieveUserQuotaSummary`, { + project, + }).pipe(Effect.result); + if (summary._tag === "Failure") continue; + const parsed = yield* decodeJson(summary.success).pipe(Effect.option); + if (Option.isNone(parsed)) continue; + windows = yield* antigravitySummaryWindows(parsed.value, requestedAt).pipe( + Effect.orElseSucceed(() => []), + ); + if (windows.length === 0) { + const legacy = yield* decodeAntigravityQuota(summary.success).pipe(Effect.option); + if (Option.isSome(legacy)) windows = antigravityWindows(legacy.value); + } + if (windows.length > 0) break; + } + if (windows.length === 0) + return yield* new UsageLimitSourceError({ + detail: "Antigravity returned no quota windows.", + }); + // Local Antigravity reports no e-mail, so the Google account fingerprint is what pools the two. + const fingerprint = yield* call("https://www.googleapis.com/oauth2/v2/userinfo").pipe( + Effect.flatMap(decodeJson), + Effect.flatMap(antigravityAccountFingerprint), + Effect.option, + ); + const usageLimits = makeUsageLimits({ checkedAt, windows }); + return Option.match(fingerprint, { + onNone: () => ({ ...listedIdentity(account), usageLimits }), + onSome: (credentialFingerprint) => ({ + usageLimits: { ...usageLimits, credentialFingerprint }, + }), + }); + }), + }, + ], + [ + "kimi", + { + driver: ProviderDriverKind.make("kimi"), + header: () => KIMI_HEADERS, + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + return { + ...listedIdentity(account), + usageLimits: makeUsageLimits({ + checkedAt, + windows: kimiWindows(yield* decodeKimiUsage(yield* call(KIMI_USAGE_URL))), + }), + }; + }), + }, + ], + [ + "devin", + { + driver: ProviderDriverKind.make("devin"), + header: () => DEVIN_HEADERS, + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const parsed = devinAccount( + yield* decodeDevinQuota(yield* call(DEVIN_QUOTA_URL, DEVIN_QUOTA_BODY)), + ); + return { + ...listedIdentity(account), + ...(parsed.plan ? { plan: parsed.plan } : {}), + usageLimits: makeUsageLimits({ checkedAt, windows: parsed.windows }), + }; + }), + }, + ], +]); + export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const digest = yield* Digest.Digest; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; const management = Effect.fn("CliproxyApi.management")(function* ( hub: CliproxyHub, @@ -631,16 +822,17 @@ export const makeCliproxyApi = Effect.gen(function* () { const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( hub: CliproxyHub, - account: typeof AuthFile.Type, + account: AuthFile, + header: Record, url: string, - options: { readonly header: Record; readonly data?: unknown }, + data?: unknown, ) { const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, - method: options.data === undefined ? "GET" : "POST", + method: data === undefined ? "GET" : "POST", url, - header: options.header, - ...(options.data === undefined ? {} : { data: yield* encodeJson(options.data) }), + header, + ...(data === undefined ? {} : { data: yield* encodeJson(data) }), }); const response = yield* decodeApiResponse(raw); if (response.status_code < 200 || response.status_code >= 300) { @@ -651,216 +843,18 @@ export const makeCliproxyApi = Effect.gen(function* () { return response.body; }); - const credits = Effect.fn("CliproxyApi.credits")(function* ( - hub: CliproxyHub, - account: typeof AuthFile.Type, - ) { - const body = yield* apiCall(hub, account, CREDIT_URL, { header: codexHeaders(account) }); - const response = yield* decodeCreditList(body); - const now = DateTime.toEpochMillis(yield* DateTime.now); - return response.credits - .filter( - (credit) => - credit.reset_type === "codex_rate_limits" && - credit.status === "available" && - Date.parse(credit.expires_at) > now, - ) - .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); - }); - - const readAntigravityLimits = Effect.fn("CliproxyApi.readAntigravityLimits")(function* ( - hub: CliproxyHub, - account: typeof AuthFile.Type, - checkedAt: string, - ) { - let project = antigravityProjectId(account); - if (!project) { - const discovered = yield* Effect.gen(function* () { - const body = yield* apiCall( - hub, - account, - `${ANTIGRAVITY_HOSTS[0]}/v1internal:loadCodeAssist`, - { - header: ANTIGRAVITY_HEADERS, - data: { metadata: { ideType: "ANTIGRAVITY" } }, - }, - ); - const resolved = codeAssistProject((yield* decodeCodeAssist(body)).cloudaicompanionProject); - if (!resolved) { - return yield* new UsageLimitSourceError({ - detail: "Antigravity account has no project.", - }); - } - return resolved; - }).pipe(Effect.option); - if (Option.isSome(discovered)) project = discovered.value; - } - if (!project) { - return yield* new UsageLimitSourceError({ detail: "Antigravity account has no project." }); - } - for (const host of ANTIGRAVITY_HOSTS) { - const body = yield* apiCall(hub, account, `${host}/v1internal:retrieveUserQuotaSummary`, { - header: ANTIGRAVITY_HEADERS, - data: { project }, - }).pipe(Effect.result); - if (body._tag === "Failure") continue; - const quota = yield* decodeAntigravityQuota(body.success).pipe(Effect.option); - if (Option.isNone(quota)) continue; - const windows = antigravityWindows(quota.value); - if (windows.length === 0) continue; - return makeUsageLimits({ checkedAt, windows }); - } - return yield* new UsageLimitSourceError({ - detail: "Antigravity returned no quota windows.", - }); - }); - const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, - account: typeof AuthFile.Type, + account: AuthFile, provider: HubProvider, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const base = { - id: account.id, - driver: driverFor(provider), - ...(account.email ? { email: account.email } : {}), - ...(account.id_token?.chatgpt_account_id?.trim() - ? { workspaceId: account.id_token.chatgpt_account_id.trim() } - : {}), - }; - const read = Effect.gen(function* () { - switch (provider) { - case "claude": { - const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage", { - header: CLAUDE_HEADERS, - }); - const usage = yield* decodeClaudeUsage(body); - const model_scoped = (usage.limits ?? []).flatMap((limit) => - limit.kind === "weekly_scoped" && - limit.scope?.model && - typeof limit.percent === "number" - ? [ - { - display_name: limit.scope.model.display_name, - utilization: limit.percent, - resets_at: limit.resets_at ?? null, - }, - ] - : [], - ); - const rateLimits = { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, - spend: usage.spend, - extra_usage: usage.extra_usage ?? null, - }; - return { - ...base, - plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: rateLimits, - }, - }).limits, - }; - } - case "grok": { - const body = yield* apiCall(hub, account, GROK_USAGE_URL, { header: GROK_HEADERS }); - return { - ...base, - usageLimits: grokUsageResponseToLimits(yield* decodeGrokUsage(body), checkedAt), - }; - } - case "antigravity": - return { - ...base, - usageLimits: yield* readAntigravityLimits(hub, account, checkedAt), - }; - case "devin": { - const body = yield* apiCall(hub, account, DEVIN_QUOTA_URL, { - header: DEVIN_HEADERS, - data: DEVIN_QUOTA_BODY, - }); - const parsed = devinAccount(yield* decodeDevinQuota(body)); - return { - ...base, - ...(parsed.plan ? { plan: parsed.plan } : {}), - usageLimits: makeUsageLimits({ checkedAt, windows: parsed.windows }), - }; - } - case "kimi": { - const body = yield* apiCall(hub, account, KIMI_USAGE_URL, { header: KIMI_HEADERS }); - return { - ...base, - usageLimits: makeUsageLimits({ - checkedAt, - windows: kimiWindows(yield* decodeKimiUsage(body)), - }), - }; - } - case "codex": { - const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`, { - header: codexHeaders(account), - }); - const usage = yield* decodeCodexUsage(body); - const toWindow = (window: typeof CodexWindow.Type | null | undefined) => - window - ? { - usedPercent: window.used_percent, - resetsAt: window.reset_at ?? null, - ...(window.limit_window_seconds === undefined - ? {} - : { windowDurationMins: window.limit_window_seconds / 60 }), - } - : null; - // A credits outage must not hide successfully fetched quota windows. - const available = yield* credits(hub, account).pipe( - Effect.orElseSucceed(() => undefined), - ); - const next = available?.[0]; - const planType = - usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; - return { - ...base, - plan: codexPlanLabel(planType), - usageLimits: { - ...codexRateLimitsToLimits({ - checkedAt, - snapshot: { - planType: planType ?? null, - primary: toWindow(usage.rate_limit?.primary_window), - secondary: toWindow(usage.rate_limit?.secondary_window), - }, - }), - ...(available - ? { - resetCredits: { - availableCount: available.length, - ...(next - ? { - nextCreditId: next.id, - nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)), - } - : {}), - }, - } - : {}), - }, - }; - } - default: { - const exhaustive: never = provider; - return exhaustive; - } - } - }); - return yield* read.pipe( + const call: HubCall = (url, data) => + apiCall(hub, account, provider.header(account, platform, architecture), url, data); + const reading = yield* provider.read({ account, call, checkedAt }).pipe( + Effect.provideService(Digest.Digest, digest), Effect.orElseSucceed(() => ({ - ...base, + ...listedIdentity(account), usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed", @@ -868,6 +862,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }), })), ); + return { id: account.id, driver: provider.driver, ...reading }; }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( @@ -882,9 +877,20 @@ export const makeCliproxyApi = Effect.gen(function* () { ); return yield* Effect.forEach( accounts.flatMap((account) => { - if (account.disabled) return []; - const provider = hubProvider(account.provider); - return provider ? [{ account, provider }] : []; + const provider = HUB_PROVIDERS.get( + ( + { + anthropic: "claude", + grok: "xai", + "x-ai": "xai", + "anti-gravity": "antigravity", + gemini: "antigravity", + cognition: "devin", + } as Record + )[account.provider.trim().toLowerCase().replaceAll("_", "-")] ?? + account.provider.trim().toLowerCase().replaceAll("_", "-"), + ); + return provider && !account.disabled ? [{ account, provider }] : []; }), ({ account, provider }) => readAccount(hub, account, provider), { concurrency: 4 }, @@ -903,16 +909,19 @@ export const makeCliproxyApi = Effect.gen(function* () { detail: "The Codex hub account is missing or disabled.", }); } - const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { - header: codexHeaders(account), - data: { + const body = yield* apiCall( + hub, + account, + CODEX_HUB.header(account, platform, architecture), + `${CREDIT_URL}/consume`, + { redeem_request_id: yield* creditRedeemRequestId( account.id_token?.chatgpt_account_id ?? account.id, creditId, ).pipe(Effect.provideService(Digest.Digest, digest)), credit_id: creditId, }, - }); + ); const response = yield* decodeConsumeResponse(body); const outcome = ( { diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 254502811e19a..14a6be8c295b4 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -125,17 +125,68 @@ describe("UsageAggregator", () => { expect(bucket).not.toHaveProperty("ultrafastCostUsd"); }); - it("keeps only the first record for a repeated dedupe key", () => { + it("merges partial copies of a key by channel maximum in the first copy's bucket", () => { + const zeroTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + const partial = record({ + dedupeKey: "msg_1:", + speed: "fast", + reportedCostUsd: 1, + totals: { ...record().totals, cachedInputTokens: 1200, outputTokens: 3, reasoningTokens: 5 }, + }); + const complete = record({ + dedupeKey: "msg_1:", + model: "claude-fable-5[1m]", + timestampMs: Date.parse("2026-08-08T12:00:00Z"), + sessionId: "session-fork", + reportedCostUsd: 2, + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 40, + outputTokens: 264, + reasoningTokens: 135, + }, + }); + const zero = { ...partial, totals: zeroTotals, reportedCostUsd: null }; + const merged = { + reportedCostUsd: 2, + totals: { ...complete.totals, uncachedInputTokens: 100, cachedInputTokens: 1200 }, + }; + + for (const copies of [ + [zero, partial, complete, complete], + [complete, partial, zero, zero], + ]) { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + copies.forEach((copy, index) => aggregator.add(copy, `${index}.jsonl`)); + const result = aggregator.finish(); + const expected = aggregate([{ ...copies[0]!, ...merged }]).buckets[0]; + + expect(result.duplicatesDropped).toBe(3); + expect(result.buckets).toEqual([{ ...expected, sourcePath: "0.jsonl" }]); + } + }); + + it("does not count a key whose first copy fell outside the window", () => { const result = aggregate([ - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-07-01T12:00:00Z") }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 500 } }), ]); - expect(result.duplicatesDropped).toBe(2); - expect(result.buckets).toHaveLength(1); - expect(result.buckets[0]?.records).toBe(1); - expect(result.buckets[0]?.totals.outputTokens).toBe(50); + expect(result.duplicatesDropped).toBe(1); + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); }); it("buckets by the day in the requested time zone", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index f0d9420605103..a004b7f83d902 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -130,10 +130,50 @@ export interface AggregateResult { readonly outOfWindow: number; } +/** + * Partial copies share a billed message's key: keep each token channel's maximum and the larger + * reported cost, and the first copy's time, model, session and speed so it stays in its bucket. + */ +export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRecord { + const a = held.totals; + const b = copy.totals; + const reportedCostUsd = + held.reportedCostUsd === null || copy.reportedCostUsd === null + ? (held.reportedCostUsd ?? copy.reportedCostUsd) + : Math.max(held.reportedCostUsd, copy.reportedCostUsd); + if ( + b.uncachedInputTokens <= a.uncachedInputTokens && + b.cachedInputTokens <= a.cachedInputTokens && + b.cacheCreationTokens <= a.cacheCreationTokens && + b.outputTokens <= a.outputTokens && + b.reasoningTokens <= a.reasoningTokens && + reportedCostUsd === held.reportedCostUsd + ) { + return held; + } + return { + ...held, + totals: { + uncachedInputTokens: Math.max(a.uncachedInputTokens, b.uncachedInputTokens), + cachedInputTokens: Math.max(a.cachedInputTokens, b.cachedInputTokens), + cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens), + outputTokens: Math.max(a.outputTokens, b.outputTokens), + reasoningTokens: Math.max(a.reasoningTokens, b.reasoningTokens), + }, + reportedCostUsd, + }; +} + +interface Contribution { + record: UsageRecord; + /** Null when the first copy fell outside the window. */ + readonly bucket: MutableBucket | null; +} + /** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */ export class UsageAggregator { readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #seen = new Map(); readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; @@ -168,20 +208,33 @@ export class UsageAggregator { if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { + const held = this.#seen.get(record.dedupeKey); + if (held !== undefined) { this.#duplicatesDropped += 1; + if (held.bucket !== null) held.record = mergeUsageCopies(held.record, record); return false; } - this.#seen.add(record.dedupeKey); } + const bucket = this.#placeInWindow(record, sourcePath ?? ""); + if (record.dedupeKey !== null) this.#seen.set(record.dedupeKey, { record, bucket }); + if (bucket === null) { + this.#outOfWindow += 1; + return false; + } + // Keyed usage is counted in finish(), once all its copies are merged. + if (record.dedupeKey === null) this.#accumulate(bucket, record); + if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); + return true; + } + + #placeInWindow(record: UsageRecord, source: string): MutableBucket | null { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; - return false; + return null; } const day = this.#toDay(record.timestampMs); @@ -189,16 +242,17 @@ export class UsageAggregator { this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; - return false; + return null; } const hourIndex = this.#hourlyWindow === null ? -1 : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); - const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); + return this.#bucketFor(day, hourIndex, record.provider, record.model, source); + } + #accumulate(bucket: MutableBucket, record: UsageRecord): void { const rates = this.#options.providerRates?.[record.provider] ?? this.#options.rates; const priced = priceUsage(rates, record, this.#options.priceOverrides); @@ -229,8 +283,6 @@ export class UsageAggregator { bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; - if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } // Aliases use the target's rate, replacing provider-specific rateModel. @@ -286,6 +338,10 @@ export class UsageAggregator { } finish(): AggregateResult { + for (const { record, bucket } of this.#seen.values()) { + if (bucket !== null) this.#accumulate(bucket, record); + } + this.#seen.clear(); const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index db1d7ae083a1f..e62bdb0b82e57 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -278,6 +278,37 @@ describe("usage pricing", () => { } }); + it("prices a bare name listed only under Bedrock IDs at the base anthropic rate", () => { + const bedrock = (input: number, output: number, cacheRead: number, cacheCreation: number) => ({ + input_cost_per_token: input, + output_cost_per_token: output, + cache_read_input_token_cost: cacheRead, + cache_creation_input_token_cost: cacheCreation, + }); + const base = bedrock(8e-6, 4e-5, 2e-7, 1e-5); + const regional = bedrock(8.8e-6, 4.4e-5, 2.2e-7, 1.1e-5); + const govCloud = bedrock(9.6e-6, 4.8e-5, 2.4e-7, 1.2e-5); + const rows = [ + ["anthropic.claude-mythos-5-2", base], + ["global.anthropic.claude-mythos-5-2", base], + ["us.anthropic.claude-mythos-5-2", regional], + ["eu.anthropic.claude-mythos-5-2", regional], + ["us-gov.anthropic.claude-mythos-5-2", govCloud], + ["bedrock/us-gov-east-1/anthropic.claude-mythos-5-2", govCloud], + ["bedrock/us-gov-west-1/anthropic.claude-mythos-5-2", govCloud], + ] as const; + + for (const entries of [rows, rows.toReversed()]) { + const table = parseRateTable(Object.fromEntries(entries)); + expect(lookupRate(table, "claude-mythos-5-2")).toMatchObject({ + inputCostPerToken: 8e-6, + outputCostPerToken: 4e-5, + cacheReadCostPerToken: 2e-7, + cacheCreationCostPerToken: 1e-5, + }); + } + }); + it("leaves a bare name unpriced when model makers' own entries disagree", () => { const table = parseRateTable({ "openai/example-model": rate(1), diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index fdc00b4dcafdb..72aa4773862b3 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -183,9 +183,10 @@ export function parseRateTable(document: unknown): RateTable { const aliasCandidates = new Map(); for (const [key, rate] of table) { - const alias = bareModelName(key); + const slash = key.indexOf("/"); + const alias = slash === -1 ? bedrockBaseModelName(key) : bareModelName(key); if (alias.length === 0 || alias === key || table.has(alias)) continue; - const firstParty = FIRST_PARTY_PROVIDERS.has(key.slice(0, key.indexOf("/"))); + const firstParty = slash !== -1 && FIRST_PARTY_PROVIDERS.has(key.slice(0, slash)); const held = aliasCandidates.get(alias); if (held === undefined || (firstParty && !held.firstParty)) { aliasCandidates.set(alias, { rate, firstParty }); @@ -247,11 +248,12 @@ function normalizeModelName(model: string): string { return bareModelName(normalizeRateKey(model)); } -/** - * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which - * Claude Code writes for the 1M context tier. The rate table only knows the - * base name, and we price at the base tier anyway. - */ +// Only Bedrock's base ID gives a bare alias; its regional (us., eu.) and GovCloud IDs carry markups. +function bedrockBaseModelName(key: string): string { + return key.startsWith("anthropic.") ? key.slice("anthropic.".length) : key; +} + +// Price Claude's bracketed context variants at the base tier, the only name the rate table knows. function stripVariantSuffix(key: string): string { const bracket = key.indexOf("["); return bracket === -1 ? key : key.slice(0, bracket); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fa659c4a443b3..2866a295da8fe 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -11,6 +11,8 @@ import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import { TEST_FORMAT_MAP } from "./usageTestFormats.ts"; import { initialCodexScanState, parseCodexLine } from "../provider/Drivers/codexUsage.ts"; +const parse = (parts: Iterable) => JSON.parse([...parts].join("")); + function record(overrides: Partial = {}): UsageRecord { return { provider: "claude", @@ -152,10 +154,7 @@ describe("scan cache round trip", () => { record({ model: "", dedupeKey: "msg_tail:" }), ], }); - const restored = decodeScanCache( - JSON.parse(makeScanCacheWriter()(cached, {})), - TEST_FORMAT_MAP, - ); + const restored = decodeScanCache(parse(makeScanCacheWriter()(cached, {})), TEST_FORMAT_MAP); expect(restored.size).toBe(4); expect(restored).toEqual(original); @@ -196,7 +195,7 @@ describe("scan cache round trip", () => { malformedRecords: 0, }; original.set("/clean-codex.jsonl", cleanCodex); - const encoded = JSON.parse(makeScanCacheWriter()(original, {})); + const encoded = parse(makeScanCacheWriter()(original, {})); const entry = encoded.files["/gone.jsonl"]!; const legacy = { ...encoded, @@ -218,15 +217,11 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual( - version >= 10 - ? original.get("/gone.jsonl") - : { - ...original.get("/gone.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }, - ); + expect(restored.get("/gone.jsonl")).toEqual({ + ...original.get("/gone.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-codex.jsonl")).toEqual({ ...original.get("/gone-codex.jsonl"), size: -1, @@ -249,11 +244,11 @@ describe("scan cache round trip", () => { }, ); - it("re-parses a Claude transcript cached before advisor usage was counted", () => { + it("re-parses a Claude transcript cached before partial copies were merged", () => { const original = cacheWith([["/claude.jsonl", 100, [record()]]]); - const v9 = { ...JSON.parse(makeScanCacheWriter()(original, {})), version: 9 }; + const v11 = { ...parse(makeScanCacheWriter()(original, {})), version: 11 }; - const restored = decodeScanCache(JSON.parse(JSON.stringify(v9)), TEST_FORMAT_MAP); + const restored = decodeScanCache(JSON.parse(JSON.stringify(v11)), TEST_FORMAT_MAP); expect(restored.get("/claude.jsonl")).toEqual({ ...original.get("/claude.jsonl"), size: -1, @@ -269,7 +264,7 @@ describe("scan cache round trip", () => { ]); cache.set("/b.jsonl", { ...cache.get("/b.jsonl")!, malformedRecords: 2 }); const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; - expect(decodeScanCache(JSON.parse(write(cache, { sources })), TEST_FORMAT_MAP)).toEqual(cache); + expect(decodeScanCache(parse(write(cache, { sources })), TEST_FORMAT_MAP)).toEqual(cache); cache.set("/b.jsonl", { ...cache.get("/b.jsonl")!, @@ -277,7 +272,7 @@ describe("scan cache round trip", () => { malformedRecords: 4, records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], }); - const document = JSON.parse(write(cache, { sources })); + const document = parse(write(cache, { sources })); expect(decodeScanCache(document, TEST_FORMAT_MAP)).toEqual(cache); expect(document.sources).toEqual(sources); }); @@ -291,9 +286,7 @@ describe("scan cache round trip", () => { }); it("skips malformed file entries but keeps good ones", () => { - const encoded = JSON.parse( - makeScanCacheWriter()(cacheWith([["/good.jsonl", 100, [record()]]]), {}), - ); + const encoded = parse(makeScanCacheWriter()(cacheWith([["/good.jsonl", 100, [record()]]]), {})); const withJunk = { ...encoded, files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, @@ -304,7 +297,7 @@ describe("scan cache round trip", () => { }); it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { - const encoded = JSON.parse( + const encoded = parse( makeScanCacheWriter()( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), {}, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c9032d64c05bd..4cd959c75aa25 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -7,18 +7,20 @@ import type { import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; +import { mergeUsageCopies } from "./usageAggregation.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 11 as const; +const USAGE_SCAN_CACHE_VERSION = 12 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** * An older server sharing that directory cannot read a newer cache and would * replace it, dropping saved usage for deleted transcripts. Separate files keep - * both. + * the newer one; older files are removed once it is saved. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v11.json", "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", @@ -134,32 +136,28 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile }; } -/** Memoizes immutable entries by identity; growing intern tables preserve indexes, retaining unused strings until restart. */ +/** Yields the document in pieces so large caches never become one string; growing intern tables preserve indexes, retaining unused strings until restart. */ export function makeScanCacheWriter(): ( cache: ScanCache, extra: Readonly>, -) => string { +) => Iterable { const tables = makeInternTables(); - const fragments = new WeakMap(); - return (cache, extra) => { - const files: string[] = []; - for (const [path, entry] of cache) { - let fragment = fragments.get(entry); - if (fragment === undefined) { - fragment = JSON.stringify(serializeFile(entry, tables)); - fragments.set(entry, fragment); - } - files.push(`${JSON.stringify(path)}:${fragment}`); + function* encode(entries: readonly (readonly [string, CachedFile])[], extra: object) { + yield '{"files":{'; + for (const [index, [path, entry]] of entries.entries()) { + yield `${index === 0 ? "" : ","}${JSON.stringify(path)}:${JSON.stringify(serializeFile(entry, tables))}`; } // Encoded after the files, which may have added to the intern tables. - const head = JSON.stringify({ + const tail = JSON.stringify({ ...extra, version: USAGE_SCAN_CACHE_VERSION, models: tables.models, sessions: tables.sessions, }); - return `${head.slice(0, -1)},"files":{${files.join(",")}}}`; - }; + yield `},${tail.slice(1)}`; + } + // Snapshot now: the encoding runs later, while the file is written. + return (cache, extra) => encode([...cache], extra); } function isRecordArray(value: unknown): value is readonly unknown[] { @@ -293,9 +291,9 @@ export function decodeScanCache( } // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. - // v10 adds Claude advisor calls, which no older Claude entry holds. + // v10 adds Claude advisor calls; before v12 a repeated Claude key kept its first, possibly partial, copy. const legacy = - (entry.p === "claude" && version < 10) || + (entry.p === "claude" && version < 12) || (format.state !== undefined && (version < 6 || (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); @@ -357,16 +355,18 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Pass only newline-terminated records: an unterminated tail can still change, so the aggregator merges it per scan. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { const kept: UsageRecord[] = []; + const firstIndex = new Map(); for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const index = firstIndex.get(record.dedupeKey); + if (index !== undefined) { + kept[index] = mergeUsageCopies(kept[index]!, record); + continue; + } + firstIndex.set(record.dedupeKey, kept.length); } kept.push(record); } diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index ec8809d895f6d..d33c2b10ed514 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -406,6 +406,33 @@ it.effect("checkpoint capture skips an ignored project while keeping its allowed }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect.each([ + { workspace: "the repository root", subdirectory: "" }, + { workspace: "a subdirectory", subdirectory: "sub" }, +])( + "checkpoint capture keeps new files in $workspace when the root .gitignore has a blank CRLF line", + ({ subdirectory }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-crlf-ignore-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, parent); + yield* fs.writeFileString(path.join(parent, ".gitignore"), "a.txt\r\n\r\nb.txt\r\n"); + const cwd = path.join(parent, subdirectory); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString(path.join(cwd, "notes.md"), "notes\n"); + const notesPath = subdirectory === "" ? "notes.md" : `${subdirectory}/notes.md`; + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.include( + (yield* git(["ls-tree", "-r", "--name-only", checkpointRef])).stdout.split("\n"), + notesPath, + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect.each([ { tracking: "committed-only", committed: ["committed.txt"], staged: [] }, { tracking: "staged-only", committed: [], staged: ["staged.txt"] }, @@ -1007,7 +1034,6 @@ it.effect.each(["committed", "unborn"] as const)( "rev-parse", "config", ...(head === "committed" ? ["read-tree", "ls-files"] : []), - "check-ignore", "add", "write-tree", "commit-tree", @@ -1060,13 +1086,13 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const commands = yield* captureRecordingGitCommands(cwd, checkpointRef); assert.deepStrictEqual(commands, [ + "rev-parse", "rev-parse", "rev-parse", "config", "rev-parse", "read-tree", "ls-files", - "check-ignore", "add", "write-tree", "commit-tree", diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 78f428901a48b..ff129690b7b24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -872,6 +872,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--git-common-dir", "--git-path", "index", + "--show-prefix", "--verify", "--quiet", "HEAD", @@ -881,19 +882,27 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const headExists = result.exitCode === 0; const headChecked = headExists || result.exitCode === 1; const lines = result.stdout.split("\n"); - const [gitCommonDir = "", indexPath = ""] = lines; - if (headChecked && lines.length === (headExists ? 4 : 3) && lines.at(-1) === "") { + const [gitCommonDir = "", indexPath = "", prefix = ""] = lines; + if (headChecked && lines.length === (headExists ? 5 : 4) && lines.at(-1) === "") { return { gitCommonDir: gitCommonDir.trim(), indexPath: indexPath.trim(), + prefix, headExists, }; } // Git before 2.31 echoes --path-format, and a path containing a newline spans lines. // Separate commands read the paths as before and report a failure the same way. + const fallbackCommonDir = yield* resolveGitCommonDir(cwd); + const prefixResult = yield* execute({ + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + cwd, + args: ["rev-parse", "--show-prefix"], + }); return { - gitCommonDir: yield* resolveGitCommonDir(cwd), + gitCommonDir: fallbackCommonDir, indexPath: undefined, + prefix: prefixResult.stdout.replace(/\n$/, ""), headExists: headChecked ? headExists : yield* hasHeadCommit(cwd), }; }); @@ -930,7 +939,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "splitIndex.sharedIndexExpire=never", ]; - const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd); + const { gitCommonDir, indexPath, prefix, headExists } = yield* resolveCaptureRepository( + input.cwd, + ); const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const hostEnv = yield* HostProcess.Environment; @@ -1123,7 +1134,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } } - const workspaceIgnored = (yield* filterIgnoredPaths(input.cwd, ["."])).length === 0; + // The working-tree root is never ignored. Probe a subdirectory without the trailing slash + // Git adds to ".", which a blank or whitespace-only ignore line matches as an empty pattern. + const workspaceIgnored = + prefix !== "" && + (yield* filterIgnoredPaths(input.cwd, [`../${prefix.slice(0, -1).split("/").at(-1)}`])) + .length === 0; if (workspaceIgnored) { // HEAD omits staged-only paths. Seed just this workspace's additions, keeping // unrelated staged changes and the scratch index's sparse flags untouched. diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 27526568a3ade..760c96a5b658f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -13,6 +13,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as PlatformError from "effect/PlatformError"; @@ -23,6 +24,7 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { ChildProcess, ChildProcessSpawner, CommandLine } from "effect/process"; import { @@ -4005,6 +4007,44 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("records no failed span reading status of a repository without a remote", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "feature"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const traced = (effect: Effect.Effect) => + Effect.gen(function* () { + spans.length = 0; + const exit = yield* effect.pipe(Effect.withTracer(tracer), Effect.exit); + const failedSpans = spans.flatMap((span) => + span.status._tag === "Ended" && Exit.isFailure(span.status.exit) ? [span.name] : [], + ); + return { exit, failedSpans }; + }); + + const status = yield* traced(driver.statusDetailsRemote(cwd)); + assert.isTrue(Exit.isSuccess(status.exit)); + assert.deepStrictEqual(status.failedSpans, []); + + // A caller that needs a remote still fails, in its own span. + const fetch = yield* traced(driver.fetchRefCommit({ cwd, ref: "refs/pull/1/head" })); + assert.include( + Exit.isFailure(fetch.exit) ? Cause.pretty(fetch.exit.cause) : "", + "No git remote is configured for this repository.", + ); + assert.deepStrictEqual(fetch.failedSpans, ["GitVcsDriver.fetchRefCommit"]); + }), + ); + it.effect("can read cached remote divergence without fetching upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4643,6 +4683,79 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isTrue(Result.isSuccess(result), "worktree creation failed in the hook"); }), ); + it.effect("refreshes a new worktree's index once its checkout second is over", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + for (let file = 0; file < 50; file++) { + yield* writeTextFile(cwd, `src/file-${file}.txt`, `contents ${file}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "more files"]); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "fresh"); + + // The refresh waits on the clock that stamps the files. + yield* TestClock.withLive( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/fresh", + }), + ); + + // Git re-reads every entry stamped in the index's own second or later. + const indexPath = path.resolve( + worktreePath, + yield* git(worktreePath, ["rev-parse", "--git-path", "index"]), + ); + const indexMtime = Option.getOrThrow((yield* fs.stat(indexPath)).mtime); + const indexSecond = Math.floor(indexMtime.getTime() / 1000); + const entrySeconds = Array.from( + (yield* git(worktreePath, ["ls-files", "--debug"])).matchAll(/^ {2}mtime: (\d+):/gm), + ([, seconds]) => Number(seconds), + ); + assert.equal(entrySeconds.length, 51); + assert.deepEqual( + entrySeconds.filter((seconds) => seconds >= indexSecond), + [], + ); + }), + ); + it.effect("returns the worktree when its index refresh fails", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + // Something else takes the new worktree's index lock as soon as its checkout ends. + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + '#!/bin/sh\ntouch "$(git rev-parse --git-path index).lock"\n', + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "locked"); + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/locked", + }); + + assert.equal(created.worktree.path, worktreePath); + const indexPath = path.resolve( + worktreePath, + yield* git(worktreePath, ["rev-parse", "--git-path", "index"]), + ); + assert.isTrue(yield* fs.exists(`${indexPath}.lock`)); + }), + ); it.effect.each(["commit", "short-commit", "tag", "revision"] as const)( "creates a worktree from a %s without recording it as a PR base", (kind) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ef91660a0e9a7..3d8122f197971 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -65,6 +65,7 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const backgroundGitProcesses = Semaphore.makeUnsafe(2); +const WORKTREE_INDEX_REFRESH_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; @@ -1744,25 +1745,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return parsedRemoteRef?.branchName ?? branchName; }); - const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { + // Local-only repositories are normal and status polls them, so the span succeeds with null. + // Callers that need a remote fail in their own span. + const findPrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { if (yield* originRemoteExists(cwd)) { return "origin"; } - const remotes = yield* listRemoteNames(cwd); - const [firstRemote] = remotes; - if (firstRemote) { - return firstRemote; - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.resolvePrimaryRemoteName", - cwd, - args: ["remote"], - }), - detail: "No git remote is configured for this repository.", - }); + const [firstRemote] = yield* listRemoteNames(cwd); + return firstRemote ?? null; }); + const resolvePrimaryRemoteName = (cwd: string) => + findPrimaryRemoteName(cwd).pipe( + Effect.filterOrFail( + (remoteName): remoteName is string => remoteName !== null, + () => + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolvePrimaryRemoteName", + cwd, + args: ["remote"], + }), + detail: "No git remote is configured for this repository.", + }), + ), + ); + const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( cwd: string, refName: string, @@ -4240,6 +4248,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + // A checkout stamps most files in the second it writes the index, and Git re-reads every file + // stamped in the index's own second or later ("racily clean") on each status, diff and add. + // T3's own git calls never rewrite a worktree's index, so refresh it once. + const refreshNewWorktreeIndex = Effect.fn("refreshNewWorktreeIndex")(function* ( + worktreePath: string, + ) { + const indexPath = path.resolve( + worktreePath, + (yield* runGitStdout("GitVcsDriver.createWorktree.indexPath", worktreePath, [ + "rev-parse", + "--git-path", + "index", + ])).trim(), + ); + const indexSecond = fileSystem.stat(indexPath).pipe( + Effect.map(({ mtime }) => Option.map(mtime, (date) => Math.floor(date.getTime() / 1000))), + Effect.orElseSucceed(() => Option.none()), + ); + // Older Git (such as 2.30) skips the write when racy entries merely check out clean. + // A refresh re-reads every checked-out file, so allow five minutes outside shared permits. + const refresh = runGit( + "GitVcsDriver.createWorktree.refreshIndex", + worktreePath, + ["update-index", "-q", "--refresh", "--force-write-index"], + { timeoutMs: WORKTREE_INDEX_REFRESH_TIMEOUT_MS }, + ); + const checkoutSecond = yield* indexSecond; + yield* refresh; + const refreshedSecond = yield* indexSecond; + if ( + Option.isNone(checkoutSecond) || + Option.isNone(refreshedSecond) || + refreshedSecond.value > checkoutSecond.value + ) { + return; + } + // The refresh finished within the checkout's second, so its index is just as racy. Wait that + // second out (file timestamps can trail the clock by a kernel tick) and refresh again. Two + // seconds or more means this clock does not match the file timestamps, as on a share stamped + // by another machine, so it cannot say when that second ends. + const delay = (checkoutSecond.value + 1) * 1000 + 50 - (yield* Clock.currentTimeMillis); + if (delay >= 2_000) return; + if (delay > 0) yield* Effect.sleep(Duration.millis(delay)); + yield* refresh; + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input, options) { @@ -4437,6 +4491,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } + // Before the worktree is returned: a refresh holds index.lock, so running it in the + // background would race the setup script's and the agent's own git commands. + yield* refreshNewWorktreeIndex(worktreePath).pipe( + Effect.catch((cause) => + Effect.logWarning( + "worktree index refresh failed; Git keeps re-reading its files until the index is rewritten", + { worktreePath, cause }, + ), + ), + ); + if (newRefName && base) { yield* Effect.gen(function* () { yield* writeConfigValue({ diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 8516ef727a7e1..8e8e7b340f17e 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -32,6 +32,8 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, AuthSessionId, AuthSourceControlWriteScope, EnvironmentId, @@ -42,11 +44,14 @@ import { TurnItemId, ProviderDriverKind, ProviderInstanceId, + RunId, SourceControlRepositoryError, WS_METHODS, WsRpcGroup, WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadShell, type OrchestrationProjectShell, @@ -55,18 +60,22 @@ import { type ServerProvider, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Cause from "effect/Cause"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; +import * as RpcTest from "effect/rpc/RpcTest"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; import { vi } from "vite-plus/test"; @@ -110,6 +119,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; +import * as AcpRegistrySessions from "./provider/AcpRegistrySessions.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -159,8 +169,14 @@ import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import * as ServerSettings from "./serverSettings.ts"; import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; +import * as EventStore from "./orchestration-v2/EventStore.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +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 * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -836,6 +852,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}), Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({}), Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({}), + Layer.mock(AcpRegistrySessions.AcpRegistrySessions)({}), Layer.mock(AgentSessionImporter.AgentSessionImporter)({}), Layer.mock(AgentSessionScanner.AgentSessionScanner)({}), Layer.mock(AnalyticsService.AnalyticsService)({}), @@ -1227,6 +1244,9 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), refreshWorkspaceSnapshot: () => Effect.succeed([provider]), }), + Layer.mock(ProjectService.ProjectService)({ + workspaceScanDirectory: (cwd) => Effect.succeed(cwd), + }), Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ listInstances: Effect.sync(() => [ (scansCwd @@ -1411,6 +1431,178 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), ); +it.effect("a missing thread worktree gets its project's skills in the cwd snapshot", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfigModule.ServerConfig; + const instanceId = ProviderInstanceId.make("claudeAgent"); + const projectId = ProjectId.make("project:skills"); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-missing-worktree-project-", + }); + const skillPath = path.join(workspaceRoot, ".claude", "skills", "finalize", "SKILL.md"); + yield* fileSystem.makeDirectory(path.dirname(skillPath), { recursive: true }); + yield* fileSystem.writeFileString(skillPath, "---\nname: finalize\n---\n"); + // Removed with `git worktree remove`; only the next turn start recreates it. + const worktreePath = path.join(serverConfig.worktreesDir, "proj", "t3-6f94fb95"); + const thread = { + id: ThreadId.make("thread:worktree"), + projectId, + archivedAt: null, + worktreePath, + branch: "t3/reply-ok", + } as unknown as OrchestrationV2ThreadShell; + const project = { + id: projectId, + title: "proj", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-11-07T00:00:00.000Z", + updatedAt: "2026-11-07T00:00:00.000Z", + } satisfies OrchestrationProjectShell; + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-11-07T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + // Like Claude's probe: project skills come from the scanned cwd, and a missing cwd is final. + const scan = (cwd: string) => + fileSystem.exists(path.join(cwd, ".claude", "skills", "finalize", "SKILL.md")).pipe( + Effect.orDie, + Effect.map((found) => ({ + cwd, + checkedAt: "2026-11-07T00:00:00.000Z", + slashCommands: [], + slashCommandsPending: false, + skills: found + ? [{ name: "finalize", path: path.join(cwd, ".claude/skills/finalize"), enabled: true }] + : [], + })), + ); + const responses = yield* Queue.unbounded(); + const services = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + refreshWorkspaceSnapshot: (input) => + Effect.map(scan(input.scanCwd ?? input.cwd), (snapshot) => [ + { ...provider, workspaceSnapshots: [{ ...snapshot, cwd: input.cwd }] }, + ]), + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed([]), + getInstance: () => Effect.succeed({ instanceId, snapshotForCwd: scan } as never), + }), + Layer.effect(ProjectService.ProjectService, ProjectService.make).pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + readShellSnapshot: (options) => + Effect.succeed( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [thread].filter( + (candidate) => + options?.worktreePath === undefined || + candidate.worktreePath === options.worktreePath, + ), + archivedThreads: [], + } as never), + ), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + get: () => Effect.succeedSome(project as never), + }), + Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}), + Layer.mock(WorkspacePaths.WorkspacePaths)({}), + Layer.mock(EventSink.EventSinkV2)({}), + IdAllocator.layer, + Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({}), + Layer.mock(ThreadCommandExecutor.ThreadCommandExecutor)({}), + ), + ), + ), + Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]) }), + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsServerRefreshProvidersRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Exit" && response.exit._tag === "Success" + ? Queue.offer(responses, response.exit.value) + : response._tag === "Exit" + ? Queue.offer(responses, response.exit) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("worktree-skills-test"), + subject: "worktree-skills-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + { secretReferences: true }, + ).pipe(Layer.provide(services)), + ), + ); + + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, cwd: worktreePath, workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const refreshed = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + const snapshot = refreshed.providers[0]?.workspaceSnapshots?.find( + (candidate) => candidate.cwd === worktreePath, + ); + assert.deepEqual( + snapshot?.skills?.map((skill) => skill.name), + ["finalize"], + "the thread's removed worktree should list the project's skills", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + it.effect("a publish whose push fails still refreshes the new remote", () => Effect.gen(function* () { const refreshes = yield* Queue.unbounded(); @@ -1687,3 +1879,236 @@ it.effect("thread resume at its own high-water below the pruned boundary skips t assert.isTrue(Exit.isSuccess(exit)); }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); + +const acpDriver = ProviderDriverKind.make("acpRegistry"); +const acpInstanceId = ProviderInstanceId.make("acpRegistry_test"); +const acpProjectId = ProjectId.make("project:acp"); +const acpThreadId = (sessionId: string) => + IdAllocator.deriveThreadFromProviderThread({ + driver: acpDriver, + providerInstanceId: acpInstanceId, + nativeThreadId: sessionId, + }); +const acpSessionTags = [ + WS_METHODS.serverListAcpRegistrySessions, + WS_METHODS.serverImportAcpRegistrySession, +] as const; +const acpSessionRpcs = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + keyof typeof RpcAuthorization.RPC_REQUIRED_SCOPES, + (typeof acpSessionTags)[number] + > => !(acpSessionTags as ReadonlyArray).includes(tag), + ), +); +const acpStores = Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), +); + +const seedAcpThread = Effect.fn(function* ( + sessionId: string, + state: { readonly imported: boolean; readonly ran: boolean }, +) { + const sink = yield* EventSink.EventSinkV2; + const now = DateTime.makeUnsafe("2026-10-23T00:00:00.000Z"); + const threadId = acpThreadId(sessionId); + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: acpProjectId, + title: sessionId, + providerInstanceId: acpInstanceId, + modelSelection: { instanceId: acpInstanceId, model: "default" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + ...(state.imported ? { historyOrigin: "v1_import" as const } : {}), + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + }; + const events: Array = [ + { + id: EventId.make(`${threadId}:created`), + type: "thread.created", + threadId, + occurredAt: now, + payload: thread, + }, + ]; + if (state.ran) { + events.push({ + id: EventId.make(`${threadId}:run`), + type: "run.updated", + threadId, + occurredAt: now, + payload: { + id: RunId.make(`${threadId}:run`), + threadId, + ordinal: 1, + providerInstanceId: acpInstanceId, + modelSelection: thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`${threadId}:prompt`), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + } + yield* sink.write({ events }); + return threadId; +}); + +it.effect("ACP session RPCs list a thread missing its history and repair it on import", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const pending = yield* seedAcpThread("pending", { imported: false, ran: false }); + const complete = yield* seedAcpThread("complete", { imported: true, ran: false }); + const ran = yield* seedAcpThread("ran", { imported: false, ran: true }); + const repaired: Array = []; + const session = (sessionId: string) => ({ + sessionId, + cwd: "/workspace/acp", + additionalDirectories: [], + title: null, + updatedAt: null, + importedThreadId: null, + }); + const instance = { + driverKind: acpDriver, + snapshot: { getSnapshot: Effect.succeed({ nativeSessions: { canLoad: true } }) }, + acpSessionManagement: { + listSessions: () => + Effect.succeed({ + sessions: ["new", "pending", "complete", "ran"].map(session), + nextCursor: null, + canLoad: true, + canResume: true, + canDelete: true, + }), + }, + } as unknown as ProviderInstance; + const dependencies = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.fresh(layerConfigSources), + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(UsageLimitSources.UsageLimitSources)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ + id: acpProjectId, + title: "ACP", + workspaceRoot: "/workspace/acp", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-23T00:00:00.000Z", + updatedAt: "2026-10-23T00:00:00.000Z", + deletedAt: null, + }), + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: () => Effect.succeed(instance), + }), + Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({ + withSessionMutation: (effect) => effect, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + projections + .getThreadShell(threadId) + .pipe( + Effect.mapError( + (cause) => new Orchestrator.OrchestratorProjectionError({ threadId, cause }), + ), + ), + }), + // Repairing an existing thread must not launch another one. + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(AgentSessionImporter.AgentSessionImporter)({ + importNativeHistory: ({ threadId }) => + Effect.sync(() => repaired.push(threadId)).pipe(Effect.as(1)), + }), + ); + const services = AcpRegistrySessions.layer.pipe(Layer.provideMerge(dependencies)); + const client = yield* RpcTest.makeClient(acpSessionRpcs).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("acp-test"), + subject: "acp-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + { secretReferences: true }, + ).pipe( + Layer.provide(services), + Layer.merge( + RpcAuthorization.layer([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), + ), + ), + ), + ); + + const listed = yield* client[WS_METHODS.serverListAcpRegistrySessions]({ + instanceId: acpInstanceId, + projectId: acpProjectId, + }); + assert.deepEqual( + listed.sessions.map(({ sessionId, importedThreadId, nativeHistoryPending }) => [ + sessionId, + importedThreadId, + nativeHistoryPending, + ]), + [ + ["new", null, undefined], + ["pending", pending, true], + ["complete", complete, undefined], + ["ran", ran, undefined], + ], + ); + + const imported = yield* client[WS_METHODS.serverImportAcpRegistrySession]({ + instanceId: acpInstanceId, + projectId: acpProjectId, + sessionId: "pending", + }); + assert.deepEqual(imported, { threadId: pending, imported: true }); + assert.deepEqual(repaired, [pending]); + }).pipe( + Effect.provide( + Layer.mergeAll( + EventSink.layer.pipe(Layer.provideMerge(acpStores)), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4b564d828a747..1c3c6d1c43311 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -17,7 +17,6 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import { rpcInitialItems } from "./rpcInitialItems.ts"; import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; @@ -43,7 +42,6 @@ import { type GitActionProgressEvent, type GitManagerServiceError, type MessageId, - type AcpRegistryImportSessionInput, type AcpRegistryDeleteSessionInput, type AcpRegistryDisableProviderInput, type AcpRegistryListProvidersInput, @@ -94,9 +92,6 @@ import { PersistChatAttachmentsError, RpcClientId, EnvironmentAuthorizationError, - type ProjectId, - type ProviderDriverKind, - type ProviderInstanceId, ThreadId, type TerminalMetadataStreamEvent, type PullRequestRef, @@ -124,7 +119,6 @@ import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManag import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; import * as DocumentText from "./provider/DocumentText.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; @@ -174,6 +168,7 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; +import * as AcpRegistrySessions from "./provider/AcpRegistrySessions.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; @@ -1517,6 +1512,7 @@ export const layerWsRpc = ( const acpRegistryCatalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const acpRegistryRuntimeCoordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; + const acpRegistrySessions = yield* AcpRegistrySessions.AcpRegistrySessions; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const providerAuth = yield* ProviderAuthService.ProviderAuthService; const messageDrafts = yield* MessageDrafts.MessageDrafts; @@ -1575,6 +1571,7 @@ export const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { + ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( @@ -1585,64 +1582,11 @@ export const layerWsRpc = ( ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); - const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* ( - projectId: ProjectId, - ) { - const project = yield* projectService.getById(projectId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "project_not_found", - message: `Project ${projectId} is unavailable.`, - cause, - }), - ), - ); - return yield* Effect.fromOption( - project, - () => - new AcpRegistryOperationError({ - reason: "project_not_found", - message: `Project ${projectId} was not found.`, - }), - ); - }); - - const acpSessionManager = Effect.fn("ws.acpRegistry.sessionManager")(function* ( - instanceId: ProviderInstanceId, - ) { - const instance = yield* providerInstances.getInstance(instanceId); - if (instance === undefined) { - return yield* new AcpRegistryOperationError({ - reason: "instance_not_found", - message: `Provider instance ${instanceId} was not found.`, - }); - } - if (instance.acpSessionManagement === undefined) { - return yield* new AcpRegistryOperationError({ - reason: "session_list_unsupported", - message: `Provider instance ${instanceId} does not expose ACP session management.`, - }); - } - return { instance, manager: instance.acpSessionManagement }; - }); - - const importedAcpThreadId = (input: { - readonly driver: ProviderDriverKind; - readonly instanceId: ProviderInstanceId; - readonly sessionId: string; - }) => - IdAllocator.deriveThreadFromProviderThread({ - driver: input.driver, - providerInstanceId: input.instanceId, - nativeThreadId: input.sessionId, - }); - const listAcpRegistrySessions = Effect.fn("ws.acpRegistry.listSessions")(function* ( input: AcpRegistryListSessionsInput, ) { - const project = yield* acpRegistryProject(input.projectId); - const { instance, manager } = yield* acpSessionManager(input.instanceId); + const project = yield* acpRegistrySessions.project(input.projectId); + const { instance, manager } = yield* acpRegistrySessions.sessionManager(input.instanceId); const listed = yield* manager.listSessions({ cwd: project.workspaceRoot, ...(input.cursor === undefined ? {} : { cursor: input.cursor }), @@ -1650,7 +1594,7 @@ export const layerWsRpc = ( const sessions = yield* Effect.forEach( listed.sessions, (session) => { - const threadId = importedAcpThreadId({ + const threadId = AcpRegistrySessions.importedThreadId({ driver: instance.driverKind, instanceId: input.instanceId, sessionId: session.sessionId, @@ -1659,6 +1603,13 @@ export const layerWsRpc = ( Effect.map((thread) => ({ ...session, importedThreadId: thread === null ? null : threadId, + ...(thread !== null && + AgentSessionImporter.isNativeHistoryPending({ + historyOrigin: thread.historyOrigin, + hasRun: thread.latestRunId !== null, + }) + ? { nativeHistoryPending: true } + : {}), })), Effect.mapError( (cause) => @@ -1675,106 +1626,15 @@ export const layerWsRpc = ( return { ...listed, sessions }; }); - const importAcpRegistrySession = Effect.fn("ws.acpRegistry.importSession")(function* ( - input: AcpRegistryImportSessionInput, - ) { - return yield* acpRegistryRuntimeCoordinator.withSessionMutation( - Effect.gen(function* () { - yield* acpRegistryProject(input.projectId); - const { instance } = yield* acpSessionManager(input.instanceId); - const providerSnapshot = yield* instance.snapshot.getSnapshot; - if ( - providerSnapshot.nativeSessions?.canLoad !== true && - providerSnapshot.nativeSessions?.canResume !== true - ) { - return yield* new AcpRegistryOperationError({ - reason: "session_resume_unsupported", - message: "The ACP agent cannot load or resume native sessions.", - }); - } - const threadId = importedAcpThreadId({ - driver: instance.driverKind, - instanceId: input.instanceId, - sessionId: input.sessionId, - }); - const existing = yield* threadManagement.getThreadShell(threadId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "session_import_failed", - message: "Could not inspect the imported ACP session mapping.", - cause, - }), - ), - ); - if (existing !== null) return { threadId, imported: false } as const; - - const provider = (yield* providerRegistry.getProviders).find( - (candidate) => candidate.instanceId === input.instanceId, - ); - const model = - provider?.models.find((candidate) => candidate.isDefault)?.slug ?? - provider?.models[0]?.slug ?? - "default"; - const commandId = CommandId.make(yield* random.uuidV4()); - const launched = yield* Effect.result( - startup.enqueueCommand( - threadLaunch.launch({ - commandId, - threadId, - projectId: input.projectId, - title: input.title ?? "Imported ACP session", - modelSelection: { instanceId: input.instanceId, model }, - runtimeMode: "approval-required", - interactionMode: "default", - workspaceStrategy: { type: "root" }, - importedNativeThread: { - ref: { - driver: instance.driverKind, - nativeId: input.sessionId, - strength: "strong", - }, - metadata: { - itemIdentityVersion: 2, - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.updatedAt === undefined ? {} : { updatedAt: input.updatedAt }), - }, - }, - createdBy: "user", - creationSource: "web", - }), - ), - ); - if (Result.isFailure(launched)) { - const racedImport = yield* threadManagement.getThreadShell(threadId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "session_import_failed", - message: "Could not inspect the imported ACP session after launch failed.", - cause, - }), - ), - ); - if (racedImport !== null) return { threadId, imported: false } as const; - return yield* new AcpRegistryOperationError({ - reason: "session_import_failed", - message: "Could not create a T3 thread for the ACP session.", - cause: launched.failure, - }); - } - return { threadId, imported: true } as const; - }), - ); - }); - const deleteAcpRegistrySession = Effect.fn("ws.acpRegistry.deleteSession")(function* ( input: AcpRegistryDeleteSessionInput, ) { return yield* acpRegistryRuntimeCoordinator.withSessionMutation( Effect.gen(function* () { - const project = yield* acpRegistryProject(input.projectId); - const { instance, manager } = yield* acpSessionManager(input.instanceId); + const project = yield* acpRegistrySessions.project(input.projectId); + const { instance, manager } = yield* acpRegistrySessions.sessionManager( + input.instanceId, + ); const snapshot = yield* instance.snapshot.getSnapshot; if (snapshot.nativeSessions?.canDelete !== true) { return yield* new AcpRegistryOperationError({ @@ -1782,7 +1642,7 @@ export const layerWsRpc = ( message: "The ACP agent does not advertise session deletion.", }); } - const threadId = importedAcpThreadId({ + const threadId = AcpRegistrySessions.importedThreadId({ driver: instance.driverKind, instanceId: input.instanceId, sessionId: input.sessionId, @@ -1815,8 +1675,8 @@ export const layerWsRpc = ( const listAcpRegistryProviders = Effect.fn("ws.acpRegistry.listProviders")(function* ( input: AcpRegistryListProvidersInput, ) { - const project = yield* acpRegistryProject(input.projectId); - const { instance, manager } = yield* acpSessionManager(input.instanceId); + const project = yield* acpRegistrySessions.project(input.projectId); + const { instance, manager } = yield* acpRegistrySessions.sessionManager(input.instanceId); const snapshot = yield* instance.snapshot.getSnapshot; if (snapshot.configurableProviders !== true) { return yield* new AcpRegistryOperationError({ @@ -1830,8 +1690,8 @@ export const layerWsRpc = ( const setAcpRegistryProvider = Effect.fn("ws.acpRegistry.setProvider")(function* ( input: AcpRegistrySetProviderInput, ) { - const project = yield* acpRegistryProject(input.projectId); - const { manager } = yield* acpSessionManager(input.instanceId); + const project = yield* acpRegistrySessions.project(input.projectId); + const { manager } = yield* acpRegistrySessions.sessionManager(input.instanceId); if (input.headers !== undefined && Object.keys(input.headers).length > 32) { return yield* new AcpRegistryOperationError({ reason: "provider_configuration_failed", @@ -1872,8 +1732,8 @@ export const layerWsRpc = ( const disableAcpRegistryProvider = Effect.fn("ws.acpRegistry.disableProvider")(function* ( input: AcpRegistryDisableProviderInput, ) { - const project = yield* acpRegistryProject(input.projectId); - const { manager } = yield* acpSessionManager(input.instanceId); + const project = yield* acpRegistrySessions.project(input.projectId); + const { manager } = yield* acpRegistrySessions.sessionManager(input.instanceId); const listed = yield* manager.listProviders(project.workspaceRoot); const provider = listed.providers.find( (candidate) => candidate.providerId === input.providerId, @@ -2464,7 +2324,7 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId, "project.id": input.projectId, - }).pipe(Effect.andThen(importAcpRegistrySession(input))), + }).pipe(Effect.andThen(acpRegistrySessions.importSession(input))), [WS_METHODS.serverDeleteAcpRegistrySession]: (input) => Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId, @@ -2489,7 +2349,9 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId }).pipe( Effect.andThen( Effect.gen(function* () { - const { instance, manager } = yield* acpSessionManager(input.instanceId); + const { instance, manager } = yield* acpRegistrySessions.sessionManager( + input.instanceId, + ); const snapshot = yield* instance.snapshot.getSnapshot; if (snapshot.auth.canLogout !== true) { return yield* new AcpRegistryOperationError({ @@ -2551,6 +2413,7 @@ export const layerWsRpc = ( ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, cwd: input.cwd, + scanCwd: yield* projectService.workspaceScanDirectory(input.cwd), fresh: input.fresh === true, }) : input.reloadSecrets === true @@ -2683,7 +2546,7 @@ export const layerWsRpc = ( detail: "This provider does not bank reset credits.", }); } - const outcome = yield* instance.consumeResetCredit().pipe( + const outcome = yield* instance.consumeResetCredit(input.creditId).pipe( Effect.mapError( (error) => new ProviderSetupError({ @@ -2966,12 +2829,13 @@ export const layerWsRpc = ( }) .pipe( Effect.asVoid, - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ - message: "Failed to create clone project.", - cause, - }), + Effect.mapError((cause) => + cause._tag === "ProjectConflictError" + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to create clone project.", + cause, + }), ), ), onCloned: (project) => @@ -3715,6 +3579,7 @@ export const layer = Layer.unwrap( Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), + Layer.provide(AcpRegistrySessions.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(NpmRelease.NpmRelease, npmRelease)), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/package.json b/apps/web/package.json index b2e03e8717a2e..82eada4a4f689 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": { @@ -69,11 +69,9 @@ "jsonc-parser": "3.3.1", "jszip": "3.10.1", "katex": "^0.18.2", - "lucide": "^0.564.0", "lucide-react": "^0.564.0", "mdast-util-from-markdown": "^2.0.3", "micromark-util-combine-extensions": "^2.0.1", - "morphicons": "^1.7.1", "pdfjs-dist": "6.4.299", "pele": "catalog:", "react": "catalog:", @@ -87,7 +85,6 @@ "sql.js": "1.15.0", "tailwind-merge": "^3.4.0", "tiff": "7.1.3", - "unbash": "^5.0.0", "unified": "catalog:", "use-sync-external-store": "^1.6.0", "vfile": "^6.0.3", @@ -113,7 +110,7 @@ "compression": "^1.8.2", "micromark-util-types": "catalog:", "oxc-transform-react": "~0.152.0", - "playwright": "1.60.0", + "playwright": "1.64.1", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index d14d750894ebe..c32193ffeb5b6 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -106,6 +106,7 @@ function isFileResource(resource: AssetResource): boolean { return ( resource._tag === "workspace-file" || resource._tag === "media-file" || - resource._tag === "draft-workspace-file" + resource._tag === "draft-workspace-file" || + (resource._tag === "native-app-icon" && resource.app._tag === "app-path") ); } diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 3a5752fcae108..3d9c064cfcb23 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -186,16 +186,15 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe } setSubmitting(true); setError(null); - const projectId = newProjectId(); try { - await addProjectToMachine({ + const projectId = await addProjectToMachine({ source: { environmentId: project.environmentId, cwd: project.workspaceRoot }, subdirectory: projectSubdirectory( project.repositoryIdentity?.rootPath, project.workspaceRoot, ), target: { environmentId: target.environmentId, label: target.label }, - projectId, + projectId: newProjectId(), title: project.title, remoteUrl, destinationPath: destinationPath.trim(), diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 8b2222c4f74a6..c80d3cbe05f7a 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,6 +8,7 @@ import { resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, resolveLocalCheckoutBranchMismatch, + resolveLockedWorkspaceLabel, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -92,6 +93,16 @@ describe("resolveEffectiveEnvMode", () => { }); }); +describe("resolveLockedWorkspaceLabel", () => { + it.each([ + ["/home/me/.t3/worktrees/app/t3-ff466c42", "t3-ff466c42"], + ["/home/me/.t3/worktrees/app/t3-ff466c42/", "t3-ff466c42"], + ["C:\\Users\\me\\worktrees\\feature-x", "feature-x"], + ])("names the worktree at %s", (path, label) => { + expect(resolveLockedWorkspaceLabel(path, "worktree")).toBe(label); + }); +}); + describe("deriveLocalBranchNameFromRemoteRef", () => { it("supports remote names that contain slashes", () => { expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index a967c3171fbd1..018422f1fdab6 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -107,7 +107,7 @@ export function resolveLockedWorkspaceLabel( activeWorktreePath: string | null, effectiveEnvMode: EnvMode, ): string { - if (activeWorktreePath) return "Worktree"; + if (activeWorktreePath) return activeWorktreePath.split(/[\\/]/).findLast(Boolean) ?? "Worktree"; return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout"; } diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index ca511de43642c..e9ca7d3f7c94b 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -97,7 +97,8 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {forceNewWorktree ? "Each model starts in its own worktree." - : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} + : (activeWorktreePath ?? + resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} ); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 3e60ea76c6c9f..fb80100c4430a 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -10,6 +10,7 @@ import "../index.css"; import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; +import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline"; import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; import { HostImageUrlContext } from "./chat/hostImageUrl"; @@ -163,6 +164,38 @@ function buttonsLabelled(container: HTMLElement, label: string) { ); } +const SOURCE_NUMBER_PLUGINS = [remarkEnableOrderedListNumbers]; + +it("keeps decimal markers in nested user lists while documents keep their counter styles", async () => { + setup(); + const text = "1. Outer\n 1. Nested\n 7. Skipped\n 1. Deeper\n 4. Fourth"; + await using screen = await render( +
+
+

User message

+ +
+
+

Markdown preview

+ +
+
, + ); + await expect.element(screen.getByText("User message", { exact: true })).toBeVisible(); + const [message, preview] = screen.container.querySelectorAll(".chat-markdown"); + expect( + [...message!.querySelectorAll("ol")].map((list) => getComputedStyle(list).listStyleType), + ).toEqual(["decimal", "decimal", "decimal"]); + expect( + [...preview!.querySelectorAll("ol")].map((list) => getComputedStyle(list).listStyleType), + ).toEqual(["decimal", "lower-alpha", "lower-roman"]); +}); + it("shows unavailable audio when a workspace embed has no thread", async () => { setup(); await using screen = await renderText("![recording](clip.wav)"); @@ -509,6 +542,40 @@ describe("ChatMarkdown skill chips", () => { }); describe("ChatMarkdown headings", () => { + it("keeps insight streaming, reference links, and heading anchors in one message", async () => { + setup(); + const rule = "─".repeat(20); + const text = `## Setup\n\nBefore [source][src].\n\n★ Insight ${rule}\nA **streaming** note.`; + const message = (text: string, isStreaming: boolean) => ( + + ); + await using screen = await render(message(text, true)); + await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note."); + await screen.rerender( + message( + `${text}\n${rule}\n\n## Setup\n\nAfter [source][src].\n\n[src]:\n https://example.com`, + false, + ), + ); + await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note."); + expect([...screen.container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([ + "user-content-setup", + "user-content-setup-1", + ]); + expect( + [...screen.container.querySelectorAll("a")].filter( + (link) => link.href === "https://example.com/", + ), + ).toHaveLength(2); + await expect.element(screen.getByRole("note")).not.toHaveTextContent("After"); + }); + it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); await using screen = await renderText( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 71d3a2e5096db..0c1ecef2b1e8d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,5 @@ import type { MessageSkill } from "./chat/SkillInlineText"; +import { clientHostEnvironmentAtom } from "../state/clientHostEnvironment"; import { buildFileLinkParentSuffixByPath, fileLinkLabel } from "@t3tools/shared/fileLinks"; import { isWindowsDrivePathHref, @@ -20,6 +21,7 @@ import { CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeResolvePictureSources, + remarkAssistantInsights, } from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { @@ -38,7 +40,7 @@ import { MessageSquareWarningIcon, OctagonAlertIcon, PresentationIcon, - SparklesIcon, + StarIcon, TriangleAlertIcon, type LucideIcon, } from "lucide-react"; @@ -157,6 +159,8 @@ import { import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { hostEnvironmentIdAtom } from "../state/primaryEnvironment"; import { cn } from "../lib/utils"; import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; @@ -189,10 +193,9 @@ import { filesystemEnvironment } from "../state/filesystem"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, - pickStrippedWorkspaceMatch, - pickWorkspaceBasenameMatch, - stripRepeatedWorkspacePrefix, + pickWorkspaceLinkMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, + workspaceLinkLookupQuery, } from "@t3tools/client-runtime/workspace-basename-lookup"; import { resolvePullRequestPreviewTarget } from "~/lib/openPullRequestLink"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -259,6 +262,7 @@ import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); interface ChatMarkdownProps { + renderInsights?: boolean; /** Compact markdown transforms remove blocks before rendering inside a label. */ inline?: boolean; text: string; @@ -336,11 +340,8 @@ function CodexArtifactTemplateCard(props: { data-skill-name={props.template.skillName} >
- + - - -
@@ -368,13 +369,18 @@ function CodexArtifactTemplateCard(props: { const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; function orderedListGutterStyle( - itemCount: number, + itemValues: ReadonlyArray, start: unknown, ): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); - const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; - const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + let number = Number.isNaN(parsedStart) ? 1 : parsedStart; + let markerWidth = String(number).length; + for (const value of itemValues) { + const written = Number.parseInt(String(value ?? ""), 10); + if (!Number.isNaN(written)) number = written; + markerWidth = Math.max(markerWidth, String(number).length); + number += 1; + } if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 2}ch` }; } @@ -383,6 +389,12 @@ const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } > = { + insight: { + label: "Insight", + Icon: StarIcon, + borderClassName: "border-info", + titleClassName: "text-info", + }, note: { label: "Note", Icon: InfoIcon, @@ -473,6 +485,7 @@ function markdownServerConfigFields(config: ServerConfig | null) { ), ...settings.customEditors, ].map(({ id, label }) => ({ id, label })), + isMacOS: config?.environment.platform.os === "darwin", revealInFileManagerLabel: config?.shellRevealInFileManager === true && config.availableEditors.includes("file-manager") ? config.shellRevealInFileManagerKind === undefined @@ -489,6 +502,7 @@ function markdownServerConfigFieldsEqual( return ( previous.pullRequestsEnabled === next.pullRequestsEnabled && previous.threadPullRequestsEnabled === next.threadPullRequestsEnabled && + previous.isMacOS === next.isMacOS && previous.revealInFileManagerLabel === next.revealInFileManagerLabel && previous.availableEditors.length === next.availableEditors.length && previous.availableEditors.every((editor, index) => editor === next.availableEditors[index]) && @@ -629,6 +643,7 @@ function useChatMarkdownState({ const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); + const isClientHost = useAtomValue(clientHostEnvironmentAtom(environmentId)); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canOperateHost && @@ -683,6 +698,7 @@ function useChatMarkdownState({ sshTarget: null, remoteOpenTargets: undefined, isDesktopRenderer: window.desktopBridge !== undefined, + hostEnvironmentId: appAtomRegistry.get(hostEnvironmentIdAtom), }).mode !== "local-exec" ) { description = `The file was not found on ${target.label} at ${error.checkedPath ?? error.resource.path}.`; @@ -699,6 +715,7 @@ function useChatMarkdownState({ ); }; const { + isMacOS, pullRequestsEnabled, threadPullRequestsEnabled, availableEditors, @@ -848,33 +865,24 @@ function useChatMarkdownState({ ) { return null; } - const isBasename = needsWorkspaceBasenameLookup(workspaceRelativePath); - const strippedPath = isBasename - ? null - : stripRepeatedWorkspacePrefix(workspaceRelativePath, cwd); - if (!isBasename && strippedPath === null) return null; - const result = await searchProjectEntries({ - environmentId, - input: { - cwd, - query: strippedPath ?? workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - if (result._tag !== "Success") return null; - if (strippedPath === null) { - return pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries); + const query = workspaceLinkLookupQuery(workspaceRelativePath, cwd); + if (query === null) return null; + // Search can be truncated or still scanning, so it cannot rule out the literal + // path. A path with a folder opens as written whenever it exists. + if (!needsWorkspaceBasenameLookup(workspaceRelativePath)) { + const metadata = await getFilesystemMetadata({ + environmentId, + input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + }); + if (metadata._tag !== "Success" || metadata.value.entries[0] !== null) return null; } - const match = pickStrippedWorkspaceMatch(strippedPath, cwd, result.value.entries); - if (match === null) return null; - // Search can be truncated or still scanning, so it cannot rule out the - // literal path. Check the host before redirecting to the stripped match. - const metadata = await getFilesystemMetadata({ + const result = await searchProjectEntries({ environmentId, - input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + input: { cwd, query, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file" }, }); - return metadata._tag === "Success" && metadata.value.entries[0] === null ? match : null; + return result._tag === "Success" + ? pickWorkspaceLinkMatch(workspaceRelativePath, cwd, result.value.entries) + : null; }; const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { if (!threadRef) return; @@ -885,8 +893,7 @@ function useChatMarkdownState({ if ( fileLinkMeta.workspaceLookupPath === null || !cwd || - (!needsWorkspaceBasenameLookup(panelPath) && - stripRepeatedWorkspacePrefix(panelPath, cwd) === null) + workspaceLinkLookupQuery(panelPath, cwd) === null ) { openAt(panelPath); return; @@ -908,6 +915,13 @@ function useChatMarkdownState({ cwd, findWorkspaceMatch: () => findWorkspaceFileMatch(fileLinkMeta), }); + const isAppBundle = isMacOS && fileLinkMeta.filePath.endsWith(".app"); + const canOpenApp = + environmentId !== null && + isAppBundle && + isClientHost && + canOperateHost && + availableEditors.includes("file-manager"); const mediaPath = mediaSource ?? fileLinkMeta.filePath; const canPreviewMedia = mediaMimeTypeFromExtension( @@ -923,15 +937,21 @@ function useChatMarkdownState({ environmentId={environmentId} href={fileLinkMeta.targetPath} targetPath={fileLinkMeta.targetPath} - iconPath={fileLinkMeta.filePath} + iconPath={ + isAppBundle && cwd + ? resolveWorkspaceFilePath(fileLinkMeta.filePath, cwd) + : fileLinkMeta.filePath + } + isAppBundle={isAppBundle} workspaceRelativePath={fileLinkMeta.workspaceRelativePath} - panelPath={panelPath} + panelPath={isAppBundle ? null : panelPath} line={fileLinkMeta.line} label={fileLinkLabel( { path: fileLinkMeta.filePath, ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), + ...(fileLinkMeta.endLine !== undefined ? { endLine: fileLinkMeta.endLine } : {}), }, fileLinkParentSuffixByPath, )} @@ -967,7 +987,7 @@ function useChatMarkdownState({ ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) : undefined } - openInEditorMenuLabel={preferredEditorMenuLabel} + openInEditorMenuLabel={canOpenApp ? "Open app" : preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined ? async () => revealFileInFileManager((await resolveHostPath()).absolutePath) @@ -1285,10 +1305,11 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, ol: function MarkdownOrderedList({ node, start, style, ...props }) { - const itemCount = - node?.children?.filter((child) => child.type === "element" && child.tagName === "li") - .length ?? 0; - const gutterStyle = orderedListGutterStyle(itemCount, start); + const itemValues = + node?.children?.flatMap((child) => + child.type === "element" && child.tagName === "li" ? [child.properties.value] : [], + ) ?? []; + const gutterStyle = orderedListGutterStyle(itemValues, start); return (
    ); @@ -2018,6 +2039,8 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { const combinedPlugins = new WeakMap>(); // One combined list per pair, so a stable extra list shares a renderer and its parse cache. +const INSIGHT_REMARK_PLUGINS: PluggableList = [remarkAssistantInsights]; + function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { if (extra.length === 0) return shared; let byShared = combinedPlugins.get(extra); @@ -2085,10 +2108,12 @@ function ChatMarkdown({ parseRawHtml = true, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, linkBareGitHubReferences = false, + renderInsights = false, ...props }: ChatMarkdownProps) { const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } = useChatMarkdownState({ text, inline, ...props }); + const hasInsights = renderInsights && text.includes("★ Insight"); // Streaming re-renders only the last block; offsets for task edits need the whole text. const streamingBlocks = props.isStreaming === true && @@ -2099,6 +2124,7 @@ function ChatMarkdown({ : null; const incrementalParsing = props.isStreaming === true && + !hasInsights && streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); @@ -2147,14 +2173,27 @@ function ChatMarkdown({ const uncachedPlugins = incrementalParsing || referenceRemarkPlugins.length > 0; const remarkPlugins = incrementalParsing ? [ - ...sharedRemarkPlugins, + ...(hasInsights + ? withExtraPlugins(INSIGHT_REMARK_PLUGINS, sharedRemarkPlugins) + : sharedRemarkPlugins), ...extraRemarkPlugins, ...referenceRemarkPlugins, createIncrementalMarkdownPlugin(), ] : referenceRemarkPlugins.length > 0 - ? [...sharedRemarkPlugins, ...extraRemarkPlugins, ...referenceRemarkPlugins] - : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); + ? [ + ...(hasInsights + ? withExtraPlugins(INSIGHT_REMARK_PLUGINS, sharedRemarkPlugins) + : sharedRemarkPlugins), + ...extraRemarkPlugins, + ...referenceRemarkPlugins, + ] + : withExtraPlugins( + hasInsights + ? withExtraPlugins(INSIGHT_REMARK_PLUGINS, sharedRemarkPlugins) + : sharedRemarkPlugins, + extraRemarkPlugins, + ); const blocks = streamingBlocks ?? [text]; return ( diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 96eb837db4d69..32ccf9465ba3e 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,5 +1,5 @@ import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -11,15 +11,18 @@ import { AppRoot } from "~/AppRoot"; // takes 6-11s; importing it here keeps that out of the test's timeout. import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, installFakeEnvironment, + threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { @@ -65,3 +68,92 @@ it("a new draft does not load its reserved thread before the server lists it", a // A 404 here marks the thread deleted, and the view never loads it once the send creates it. expect(threadRequests).toEqual([]); }); + +it("opens the thread actions menu for an older settled thread the shell leaves out", async () => { + const thread = { ...threadWithHistory("older-settled", 1), olderSettled: true as const }; + using _environment = installFakeEnvironment([thread]); + __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("older-settled: question 0")).toBeVisible(); + + await page.getByRole("button", { name: `Thread actions for ${thread.title}` }).click(); + // Without a bound the poll runs to the test deadline and reports a bare timeout. + await expect + .element(page.getByRole("menuitem", { name: "Rename thread" }), { timeout: 5_000 }) + .toBeVisible(); +}); + +it("retains each thread's diff selection when returning from another thread or Settings", async () => { + const a = threadWithHistory("diff-selection-a", 1); + const b = threadWithHistory("diff-selection-b", 1); + using _environment = installFakeEnvironment([a, b]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id); + const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); + useRightPanelStore.getState().open(aRef, "diff"); + useRightPanelStore.getState().open(bRef, "diff"); + useDiffPanelStore.getState().selectBranchBaseRef(bRef, "origin/release"); + const router = getRouter(createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + await act(() => useDiffPanelStore.getState().selectGitScope(aRef, "unstaged")); + + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: b.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${b.id}.`)).toBeVisible(); + expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, bRef)).toEqual({ + kind: "branch", + baseRef: "origin/release", + }); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: a.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef)).toEqual({ + kind: "unstaged", + }); + + await act(() => useDiffPanelStore.getState().selectTurn(aRef, a.runs[0]!.id)); + await act(() => router.navigate({ to: "/settings/general" })); + await expect + .element(page.getByRole("heading", { name: "Time format", exact: true })) + .toBeVisible(); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: a.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef), + ).toMatchObject({ + kind: "turn", + turnId: a.runs[0]!.id, + }); +}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2e772cc0998c0..76e9488323f42 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,3 +1,4 @@ +import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { boundAttachmentIds, buildExpiredTerminalContextToastCopy, @@ -1272,3 +1273,25 @@ describe("deriveAvailableRevertTurnCounts", () => { expect(deriveAvailableRevertTurnCounts(null).size).toBe(0); }); }); + +describe("Open in for the host environment", () => { + it("shows both local environments when the primary has a separate WSL home", () => { + const primaryEnvironmentId = EnvironmentId.make("wsl"); + const hostEnvironmentId = EnvironmentId.make("host"); + for (const [activeThreadEnvironmentId, visible] of [ + [primaryEnvironmentId, true], + [hostEnvironmentId, true], + [EnvironmentId.make("other"), false], + ] as const) { + expect( + shouldShowOpenInPicker({ + activeProjectName: "Project", + activeThreadEnvironmentId, + primaryEnvironmentId, + hostEnvironmentId, + remoteOpenMode: "local-exec", + }), + ).toBe(visible); + } + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b5fd40fa25458..6ca00bb6a4492 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -236,16 +236,6 @@ export function resolveProactiveTurnDiffAction(input: { return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } -/** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for - * the next open. */ -export function resolveExplicitDiffOpenOnActivate(input: { - readonly surfaceKind: RightPanelSurface["kind"]; - readonly diffOpen: boolean; - readonly activeThreadRef: ScopedThreadRef; -}): ScopedThreadRef | null { - return input.surfaceKind === "diff" && !input.diffOpen ? input.activeThreadRef : null; -} - export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eedd595100bdc..9e0701faff0fb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -131,8 +131,10 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { getUserQueuedThreadRuns, + threadSteersDuringCompaction, threadSupportsProviderHandoff, } from "@t3tools/client-runtime/state/thread-workflows"; +import { compactionBlocksSteering } from "@t3tools/client-runtime/state/composer-dispatch"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { HandoffRow } from "./chat/V2LifecycleRow"; import { @@ -158,8 +160,11 @@ import { type SetStateAction, use, useEffect, useLayoutEffect, useRef, useState import { BackgroundThreadContext } from "./chat/backgroundThread"; import { WorkspaceMutationIdContext } from "./chat/ChatMarkdownMedia"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; -import { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { + assistantCitationFromLocation, + useSideChatCitationReveal, +} from "../lib/assistantCitationNavigation"; +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useStore } from "zustand"; import { shallow } from "zustand/vanilla/shallow"; import { useShallow } from "zustand/react/shallow"; @@ -171,7 +176,6 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { composerRequiresModifier } from "../composer-logic"; import { @@ -270,11 +274,8 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQueryRefresh } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; -import { - primaryServerAvailableEditorsAtom, - primaryServerKeybindingsAtom, - serverEnvironment, -} from "../state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; +import { hostEnvironmentIdAtom } from "../state/primaryEnvironment"; import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { @@ -433,6 +434,7 @@ import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplat const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = []; +const EMPTY_RUNS: ThreadStructure["runs"] = []; const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = []; const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = []; @@ -623,12 +625,16 @@ export default function ChatView(props: ChatViewProps) { select: (location) => location.state.assistantCitationActivation ?? location.state.__TSR_key, }); const citationLocation = { href: citationHref, key: citationKey }; - const citationRequest = (() => { - const citation = assistantCitationFromLocation(citationLocation.href); - return citation && citation.environmentId === environmentId && citation.threadId === threadId - ? { citation, key: citationLocation.key ?? citationLocation.href } - : null; - })() as AssistantCitationRequest | null; + const locationCitation = assistantCitationFromLocation(citationLocation.href); + const citationActivation = citationLocation.key ?? citationLocation.href; + const citationRequest = ( + locationCitation && + locationCitation.environmentId === environmentId && + locationCitation.threadId === threadId + ? { citation: locationCitation, key: citationActivation } + : null + ) as AssistantCitationRequest | null; + useSideChatCitationReveal(embedded ? null : routeThreadRef, locationCitation, citationActivation); const citationHistory = useCitedHistory(citationRequest); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( @@ -1133,20 +1139,6 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; - const explicitDiffOpenRef = useRef(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the diff panel store to branch scope when the diff opens without an explicit request - useLayoutEffect(() => { - const explicitThreadRef = explicitDiffOpenRef.current; - explicitDiffOpenRef.current = null; - if ( - diffOpen && - activeThreadRef && - explicitThreadRef !== activeThreadRef && - !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) - ) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - } - }, [activeThreadRef, diffOpen]); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -1274,7 +1266,6 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentUnavailable = @@ -1562,9 +1553,17 @@ export default function ChatView(props: ChatViewProps) { item.runId === activeRuntime?.activeRunId && item.status === "running", ); + const compactCommandIsActive = compactRequestIsActive && !compactionSettled; const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && - (compactionRunning || (compactRequestIsActive && !compactionSettled)); + (compactionRunning || compactCommandIsActive); + const compactionBlocksSteer = compactionBlocksSteering({ + compactCommandIsActive, + compactionRunning, + steersDuringCompaction: threadSteersDuringCompaction(serverProjection), + run: serverProjection?.runs.find((run) => run.id === activeRuntime?.activeRunId), + messages: serverProjection?.messages, + }); const isWorking = phase === "running" || isSendBusy || isCompacting || runlessWorkStartedAt !== null; const activeContextWindow = @@ -1796,17 +1795,21 @@ export default function ChatView(props: ChatViewProps) { const scriptKeybindings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); + const openInEnvironmentId = activeThread?.environmentId ?? environmentId; + const availableEditors = + useAtomValue(serverEnvironment.configValueAtom(openInEnvironmentId))?.availableEditors ?? []; + const hostEnvironmentId = useAtomValue(hostEnvironmentIdAtom); + const remoteOpenState = useRemoteOpenState(openInEnvironmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName: activeProject?.title, - activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId, - primaryEnvironmentId, + activeThreadEnvironmentId: openInEnvironmentId, + primaryEnvironmentId: primaryEnvironment?.environmentId ?? null, + hostEnvironmentId, remoteOpenMode: remoteOpenState.mode, }); useOpenFavoriteEditorShortcut({ enabled: showOpenInPicker && !backgroundThread, - environmentId: activeThread?.environmentId ?? environmentId, + environmentId: openInEnvironmentId, keybindings, availableEditors, openInCwd: gitCwd, @@ -2633,7 +2636,6 @@ export default function ChatView(props: ChatViewProps) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, onDiffPanelOpen, closeRightPanelSurface, }); @@ -3414,7 +3416,7 @@ export default function ChatView(props: ChatViewProps) { @@ -4078,6 +4080,8 @@ export default function ChatView(props: ChatViewProps) { : {})} phase={phase} isCompacting={isCompacting} + canSteer={!compactionBlocksSteer} + activeThreadRuns={serverProjection?.runs ?? EMPTY_RUNS} canInterrupt={canInterruptRunningThread} checkoutKindPending={checkoutKindPending} isSendBusy={ @@ -4370,6 +4374,7 @@ export default function ChatView(props: ChatViewProps) { ) } gitCwd={gitCwd} + workspaceMutationId={workspaceMutationId} isGitRepo={isGitRepo} envLocked={envLocked} availableEnvironments={logicalProjectEnvironments} diff --git a/apps/web/src/components/CommandBlock.tsx b/apps/web/src/components/CommandBlock.tsx index 2f4454932278f..69f68529c839d 100644 --- a/apps/web/src/components/CommandBlock.tsx +++ b/apps/web/src/components/CommandBlock.tsx @@ -1,6 +1,5 @@ -import { Check, Copy } from "lucide"; +import { CheckIcon, CopyIcon } from "lucide-react"; -import { MorphIcon } from "~/components/MorphIcon"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; @@ -37,7 +36,7 @@ export function CommandBlock({ aria-label="Copy command" onClick={() => copyToClipboard(command, undefined)} > - + {isCopied ? : }
); diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index f09c4ff88db7b..da292371623d2 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -2,7 +2,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { makeThreadFixture } from "../test-fixtures"; @@ -26,6 +26,56 @@ function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArr } describe("CommandPalette open intents", () => { + it("drags a searched thread into the current composer without navigating", async () => { + const environmentId = EnvironmentId.make("remote"); + const threadId = ThreadId.make("dragged-thread"); + setup({ + threads: [makeThreadFixture({ environmentId, id: threadId, title: "Drag this thread" })], + }); + const onDrop = vi.fn(); + await using screen = await render( + +
{ + node?.addEventListener("t3-thread-context-drop", onDrop); + }} + style={{ position: "fixed", bottom: 0, width: 300, height: 100 }} + > + Composer +
+
, + ); + const pathname = screen.router?.state.location.pathname; + openCommandPalette({ query: "Drag this thread" }); + const row = page.getByRole("option", { name: /Drag this thread/ }); + await expect.element(row).toBeVisible(); + const bounds = row.element().getBoundingClientRect(); + const pointer = (type: string, x: number, y: number) => + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + isPrimary: true, + button: 0, + buttons: type === "pointerup" ? 0 : 1, + clientX: x, + clientY: y, + }); + await act(() => { + row.element().dispatchEvent(pointer("pointerdown", bounds.x + 20, bounds.y + 10)); + document.dispatchEvent(pointer("pointermove", bounds.x + 30, bounds.y + 20)); + }); + expect(useCommandPaletteStore.getState().open).toBe(false); + await act(() => { + document.dispatchEvent(pointer("pointermove", 50, window.innerHeight - 30)); + document.dispatchEvent(pointer("pointerup", 50, window.innerHeight - 30)); + }); + expect(onDrop).toHaveBeenCalledOnce(); + expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([{ environmentId, threadId }]); + expect(screen.router?.state.location.pathname).toBe(pathname); + }); + it("opens New thread in... with focus in the search input, not the Back button", async () => { setup(); await using _screen = await render({null}); diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index aa1dbd8c80264..dc266b669f593 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -2,11 +2,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import { act, render } from "../../test/render"; import { CommandPalette } from "./CommandPalette"; import { openCommandPalette } from "../commandPaletteBus"; import { primaryServerKeybindingsAtom } from "../state/server"; +const scopes = vi.hoisted(() => ({ denied: undefined as string | undefined })); +const setup = (deniedScope?: string) => { + scopes.denied = deniedScope; +}; + vi.mock("@effect/atom-react", async (original) => ({ ...(await original()), useAtomValue: (atom: unknown) => @@ -41,7 +47,8 @@ vi.mock("../hooks/useHandleNewThread", () => ({ vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: () => async () => ({ _tag: "Success", value: undefined }), + // The add-project destination step starts a clone and follows its project id. + useAtomCommand: () => async () => ({ _tag: "Success", value: { projectId: "project-cloned" } }), })); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => async () => ({ _tag: "Success", value: undefined }), @@ -58,6 +65,7 @@ vi.mock("../state/environments", async (original) => ({ serverConfig: { environment: { platform: { os: "linux" }, capabilities: { projectCloneTracking: true } }, settings: { addProjectBaseDirectory: "/workspace/" }, + newProjectsRoot: "/workspace/", }, }, ], @@ -86,7 +94,7 @@ vi.mock("../state/queries", async (original) => ({ })); vi.mock("../state/session", async (original) => ({ ...(await original()), - useEnvironmentScope: () => true, + useEnvironmentScope: (_environmentId: unknown, scope: string) => scope !== scopes.denied, readEnvironmentScope: () => true, })); vi.mock("../state/filesystem", async (original) => ({ @@ -107,6 +115,7 @@ describe.each(["repository", "folder", "destination"] as const)("add-project %s" { isComposing: true, keyCode: 13 }, { isComposing: false, keyCode: 229 }, ])("keeps Enter in the input during composition: %j", async (composition) => { + setup(); await using _screen = await render({null}); await act(() => openCommandPalette({ open: "add-project" })); await page.getByText(step === "folder" ? "Local folder" : "Git URL", { exact: true }).click(); @@ -136,7 +145,44 @@ describe.each(["repository", "folder", "destination"] as const)("add-project %s" }); }); +it.each([ + { step: "folder", source: "Local folder", keys: "", description: /^(?!.*(Ctrl|⌘)).*Add.*Enter/ }, + { + step: "highlighted folder", + source: "Local folder", + keys: "{ArrowDown}", + description: /Add.*(Ctrl|⌘) Enter/, + }, + { step: "repository", source: "Git URL", keys: "", description: /Continue.*Enter/ }, + { step: "new project", source: "New project", keys: "", description: /Create.*Enter/ }, + { + step: "permission-blocked folder", + source: "Local folder", + keys: "", + deniedScope: AuthOrchestrationOperateScope, + description: "This connection cannot add projects.", + }, + { + step: "permission-blocked destination", + source: "Git URL", + keys: "https://example.com/team/project.git{Enter}", + deniedScope: AuthSourceControlWriteScope, + description: "This connection needs permission to write source control and add projects.", + }, +])( + "tells screen readers how to submit the $step step", + async ({ source, keys, deniedScope, description }) => { + setup(deniedScope); + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText(source, { exact: true }).click(); + if (keys) await userEvent.keyboard(keys); + await expect.element(page.getByRole("combobox")).toHaveAccessibleDescription(description); + }, +); + it("keeps the caret in place when an edit briefly makes the folder path not a path", async () => { + setup(); await using _screen = await render({null}); await act(() => openCommandPalette({ open: "add-project" })); await page.getByText("Local folder", { exact: true }).click(); @@ -149,3 +195,18 @@ it("keeps the caret in place when an edit briefly makes the folder path not a pa await userEvent.keyboard("w"); await expect.element(input()).toHaveValue("/wworkspace/project"); }); + +it.each([ + { flow: "Git URL", value: "https://github.com/pingdotgg/t3code.git", submit: "Continue (Enter)" }, + { flow: "New project", value: "demo-tab-order", submit: /^Create \(/ }, +])("moves focus from the $flow input to its enabled submit button on Tab", async (step) => { + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText(step.flow, { exact: true }).click(); + const input = page.getByRole("combobox"); + await input.fill(step.value); + const submit = page.getByRole("button", { name: step.submit }); + await expect.element(submit).toBeEnabled(); + await userEvent.keyboard("{Tab}"); + await expect.element(submit).toHaveFocus(); +}); diff --git a/apps/web/src/components/CommandPalette.drag.ts b/apps/web/src/components/CommandPalette.drag.ts new file mode 100644 index 0000000000000..3dec40e02b0f1 --- /dev/null +++ b/apps/web/src/components/CommandPalette.drag.ts @@ -0,0 +1,36 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { flushSync } from "react-dom"; +import { dispatchCommandPalette } from "../commandPaletteStore"; +import { SidebarPointerSensor } from "./Sidebar.pointer"; +import { + dropThreadContext, + endThreadContextDrag, + moveThreadContextDrag, +} from "./chat/threadContextDrag"; + +export function startPaletteThreadDrag( + event: PointerEvent, + thread: ScopedThreadRef, + title: string, +) { + if (!event.isPrimary || event.button !== 0 || event.pointerType === "touch") return; + // The document-owned sensor must survive closing and unmounting the palette on activation. + return new SidebarPointerSensor({ + event, + active: `${thread.environmentId}:${thread.threadId}`, + options: { + distance: 6, + onAttach: () => {}, + onFinish: endThreadContextDrag, + onDrop: (point) => dropThreadContext(point, [thread]), + }, + onStart: () => { + flushSync(() => dispatchCommandPalette({ _tag: "SetOpen", open: false })); + }, + onMove: (point) => moveThreadContextDrag(point, { title, count: 1 }), + onPending: () => {}, + onAbort: () => {}, + onCancel: () => {}, + onEnd: () => {}, + }); +} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index bf2f56bf2b8c2..03f47ad060a1e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -19,6 +19,7 @@ import { type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { + type ScopedThreadRef, type EnvironmentId, type ProjectId, type EnvironmentMachineKind, @@ -48,6 +49,7 @@ export function buildLinkedThreadActionItems( return input.threads.map((thread) => ({ kind: "action", value: `thread:${input.environmentId}:${thread.id}`, + threadRef: { environmentId: input.environmentId, threadId: thread.id }, title: thread.title || "Untitled thread", description: thread.archivedAt === null ? "Linked thread" : "Archived thread", searchTerms: [input.query, thread.title], @@ -223,6 +225,7 @@ export interface CommandPaletteItem { readonly timestamp?: string; /** Thread rows rank by this, with use history, instead of by `searchTerms`. */ readonly threadSearch?: ThreadSearchSubject; + readonly threadRef?: ScopedThreadRef; /** Shown in place of the description when the match is not in the title. */ readonly matchHint?: SearchMatchHint; readonly icon: ReactNode; @@ -495,6 +498,7 @@ export function buildThreadActionItems + { @@ -766,6 +775,14 @@ function OpenCommandPaletteDialog(props: { const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) : null; + const previewPanelThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : activeDraftThread + ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) + : null; + const activeThreadPreviewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, previewPanelThreadRef) === "preview", + ); const supportsThreadFind = environments.find((environment) => environment.environmentId === activeThread?.environmentId) ?.serverConfig?.threadFind === true; @@ -1522,6 +1539,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThreadPreviewOpen && previewBridge) { + actionItems.push({ + kind: "action", + value: "find-in-browser-page", + title: "Find on page in browser", + searchTerms: ["find", "search", "browser", "preview", "page"], + icon: , + shortcutCommand: "preview.find", + run: async () => { + dispatchPreviewAction("find"); + }, + }); + } + const promptLinkTarget = paletteRouteTarget ? paletteRouteTarget.kind === "server" ? paletteRouteTarget.threadRef @@ -2395,7 +2426,7 @@ function OpenCommandPaletteDialog(props: { function getDefaultCloneDestination( environmentId: EnvironmentId, remoteUrl: string, - repository: string, + repository?: string, ): string { const environment = environments.find((candidate) => candidate.environmentId === environmentId); return getCloneDestinationPath( @@ -2450,7 +2481,6 @@ function OpenCommandPaletteDialog(props: { const destinationPath = getDefaultCloneDestination( addProjectCloneFlow.environmentId, remoteUrl, - rawRepository, ); setAddProjectCloneFlow({ step: "confirm", @@ -2556,12 +2586,11 @@ function OpenCommandPaletteDialog(props: { return; } - const projectId = newProjectId(); setIsRemoteProjectCloning(true); const startResult = await startProjectClone({ environmentId: addProjectCloneFlow.environmentId, input: { - projectId, + projectId: newProjectId(), title: inferProjectTitleFromPath(destinationPath), // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) createdAt: new Date().toISOString(), @@ -2587,7 +2616,10 @@ function OpenCommandPaletteDialog(props: { addProjectCloneFlow.environmentId, addProjectCloneFlow.remoteUrl, ); - const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); + const projectRef = scopeProjectRef( + addProjectCloneFlow.environmentId, + startResult.value.projectId, + ); await waitForProject(projectRef, 3_000).catch(() => null); const navigationResult = await settlePromise(() => handleNewThread(projectRef)); if (navigationResult._tag === "Failure") { @@ -3039,6 +3071,7 @@ function OpenCommandPaletteDialog(props: { scrollCommandPaletteRowIntoView( resultListRef.current, resultRows.rowIndexByItemIndex[0] ?? 0, + true, ); return; } @@ -3176,9 +3209,17 @@ function OpenCommandPaletteDialog(props: { await handleAddProject(pickedPath); }; + const submitButtonId = useId(); + const browseSubmitLabel = + isCloneDestinationStep && !canCloneProject + ? "This connection needs permission to write source control and add projects." + : canCreateProject + ? `${submitActionLabel} (${addShortcutLabel})` + : "This connection cannot add projects."; const inputAccessory = newProjectFlow !== null ? ( ) : addProjectCloneFlow?.step === "repository" ? ( ) : isBrowsing ? ( handleQueryChange(nextQuery, event.target)} diff --git a/apps/web/src/components/CommandPaletteAddProject.tsx b/apps/web/src/components/CommandPaletteAddProject.tsx index 808536403a4bb..ce3797848e317 100644 --- a/apps/web/src/components/CommandPaletteAddProject.tsx +++ b/apps/web/src/components/CommandPaletteAddProject.tsx @@ -34,6 +34,7 @@ export function sourceControlHostIcon( /** The input's trailing submit button for the add-project, clone, and new-project flows. */ export function CommandPaletteSubmitButton(props: { + readonly id: string; readonly label: ReactNode; readonly shortcutLabel: string; readonly ariaLabel: string; @@ -46,9 +47,9 @@ export function CommandPaletteSubmitButton(props: { { + if (props.item.threadRef) { + startPaletteThreadDrag( + event.nativeEvent, + props.item.threadRef, + props.item.searchTitle ?? + (typeof props.item.title === "string" ? props.item.title : "Thread"), + ); + } + }} onMouseDown={(event) => { event.preventDefault(); }} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4a5e7c8fc8262..77d328b7acd84 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -28,6 +28,7 @@ import { Plugin, PluginKey, TextSelection, + type Transaction, } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import { ComposerColorCodesExtension, useComposerColorCodeHover } from "./composerColorCodes"; @@ -582,10 +583,15 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { const onRestoreFocus = () => { if (!editor.isEditable) return; const pos = nodePos(); - if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); - if (!current || current.type.name !== "composer-citation") return; - editor.commands.focus(pos + current.nodeSize); + const current = pos === null ? null : editor.state.doc.nodeAt(pos); + if (pos === null || current?.type.name !== "composer-citation") { + editor.view.focus(); + return; + } + editor + .chain() + .focus(pos + current.nodeSize) + .run(); }; return ( @@ -1045,6 +1051,8 @@ function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSe type TiptapEditor = NonNullable>; +const SILENT_SELECTION_META = "composerSilentSelection"; + function replaceSkillChips( editor: TiptapEditor, replacements: ReturnType, @@ -1341,7 +1349,7 @@ function ComposerPromptEditorTiptapInner( ), }; - const handleEditorChange = (updated: TiptapEditor) => { + const handleEditorChange = (updated: TiptapEditor, transaction: Transaction) => { const map = serializeEditorDoc(updated.state.doc); const contextIds = literalText ? [] : [...map.contextIds]; const { from, to } = updated.state.selection; @@ -1368,7 +1376,7 @@ function ComposerPromptEditorTiptapInner( previousSnapshot.contextIds.length === contextIds.length && previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) ); - if (isApplyingControlledUpdateRef.current) return; + if (isApplyingControlledUpdateRef.current || transaction.getMeta(SILENT_SELECTION_META)) return; if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { readCallbacks().onVisibleSelectionChange?.(); @@ -1815,10 +1823,10 @@ function ComposerPromptEditorTiptapInner( return; } } - handleEditorChange(updated); + handleEditorChange(updated, transaction); }, - onSelectionUpdate: ({ editor: updated }) => { - handleEditorChange(updated); + onSelectionUpdate: ({ editor: updated, transaction }) => { + handleEditorChange(updated, transaction); }, }, [], diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 8992dc43d7c1c..514c719dba73d 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -9,6 +9,7 @@ import { import { AlertDialog, AlertDialogClose, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, @@ -59,6 +60,7 @@ export function ConfirmDialogHost() { const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); const confirmVariant = state.status === "idle" ? "default" : state.variant; + const focusConfirm = state.status !== "idle" && state.initialFocus === "confirm"; const onCancel = () => respondToConfirmDialog(false); const onConfirm = () => respondToConfirmDialog(true); @@ -83,9 +85,13 @@ export function ConfirmDialogHost() { }>Cancel - + {focusConfirm && confirmVariant === "default" ? ( + Confirm + ) : ( + + )} diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx index ab42f126189ec..5e78949336cff 100644 --- a/apps/web/src/components/DiffFilePathCopyButton.tsx +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -1,14 +1,12 @@ -import { Check, Copy } from "lucide"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { useRef } from "react"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { cn } from "../lib/utils"; import { ANCHORED_COPY_TOAST_TIMEOUT_MS, showAnchoredCopyErrorToast, showAnchoredCopySuccessToast, } from "./ui/anchoredCopyToast"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { @@ -32,10 +30,7 @@ export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { /> } > - + {isCopied ? : }

{isCopied ? "Copied" : "Copy path"}

diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 874118daf306f..5f789469af763 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -121,7 +121,6 @@ function setup(failedBaseRef: string | null) { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, }); useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e7e6d614ef77f..9767972d73d05 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -17,8 +17,10 @@ import { PilcrowIcon, Rows3Icon, TextWrapIcon, + ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, } from "lucide-react"; -import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import { type MouseEvent, useEffect, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; @@ -70,7 +72,6 @@ import { DiffFileTree } from "./diffs/DiffFileTree"; import { FileExplorerPane } from "./files/FileExplorerPane"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -172,10 +173,11 @@ function DiffFileCollapseToggle({ /> } > - + {collapsed ? ( + + ) : ( + + )} {collapsed ? "Expand diff" : "Collapse diff"} @@ -230,7 +232,8 @@ export default function DiffPanel({ })); const diffLayout = settings.diffLayout; const [wordWrap, setWordWrap] = useState(settings.wordWrap); - const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const diffIgnoreWhitespace = + useDiffPanelSessionStore((state) => state.ignoreWhitespace) ?? settings.diffIgnoreWhitespace; const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( DIFF_FILE_TREE_STORAGE_KEY, false, @@ -1159,10 +1162,11 @@ export default function DiffPanel({ /> } > - + {allDiffFilesCollapsed ? ( + + ) : ( + + )} {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} @@ -1219,7 +1223,7 @@ export default function DiffPanel({ size="sm" pressed={diffIgnoreWhitespace} onPressedChange={(pressed) => { - setDiffIgnoreWhitespace(Boolean(pressed)); + useDiffPanelSessionStore.getState().setIgnoreWhitespace(Boolean(pressed)); }} /> } diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 43e10293741b0..d0e243cda25c4 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -4,6 +4,7 @@ import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; import { assert, describe, it } from "vite-plus/test"; import { buildMenuItems, + hasUsableGitMenuContent, requiresDefaultBranchConfirmation, resolveChangesTotals, resolveDefaultBranchActionDialogCopy, @@ -77,6 +78,29 @@ describe("when: ref is clean, up to date, and has no open PR", () => { false, ); }); + + it("offers the actions menu only when something in it is usable", () => { + const hasContent = ( + gitStatus: VcsStatusResult, + overrides: Partial[0]> = {}, + ) => + hasUsableGitMenuContent({ + menuItems: buildMenuItems(gitStatus, false, gitStatus.hasPrimaryRemote), + canWriteSourceControl: true, + canPublishRepository: false, + hasNotice: false, + ...overrides, + }); + const clean = status(); + const dirty = status({ hasWorkingTreeChanges: true }); + + assert.isFalse(hasContent(clean)); + assert.isTrue(hasContent(dirty)); + assert.isTrue(hasContent(status({ aheadCount: 1 }))); + assert.isFalse(hasContent(dirty, { canWriteSourceControl: false })); + assert.isTrue(hasContent(status({ hasPrimaryRemote: false }), { canPublishRepository: true })); + assert.isTrue(hasContent(clean, { hasNotice: true })); + }); }); describe("when: on default ref without open PR", () => { @@ -283,6 +307,37 @@ describe("resolveChangesTotals", () => { ); }); + it("uses the panel's branch comparison when it has file stats", () => { + const dirty = status({ + branchChanges: { baseRef: "main", insertions: 20, deletions: 3 }, + }); + const source = { + id: "branch-range", + kind: "branch-range", + title: "Changes vs release", + baseRef: "release", + headRef: "feature/test", + diff: "", + diffHash: "hash", + truncated: false, + } as const; + assert.deepEqual( + resolveChangesTotals(dirty, { + ...source, + files: [ + { path: "a.ts", previousPath: null, additions: 4, deletions: 1 }, + { path: "b.ts", previousPath: null, additions: 2, deletions: 0 }, + ], + }), + { insertions: 6, deletions: 1, baseRef: "release" }, + ); + assert.deepEqual(resolveChangesTotals(dirty, source), { + insertions: 20, + deletions: 3, + baseRef: "main", + }); + }); + it("falls back to the working tree when branchChanges is omitted", () => { assert.deepInclude( resolveChangesTotals( diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 5effb44c79a87..399e013068364 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,6 +1,7 @@ import type { GitRunStackedActionResult, GitStackedAction, + ReviewDiffPreviewSource, VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; @@ -174,6 +175,19 @@ export function buildMenuItems( ]; } +export function hasUsableGitMenuContent(input: { + readonly menuItems: readonly GitActionMenuItem[]; + readonly canWriteSourceControl: boolean; + readonly canPublishRepository: boolean; + readonly hasNotice: boolean; +}): boolean { + return ( + input.canPublishRepository || + input.hasNotice || + (input.canWriteSourceControl && input.menuItems.some((item) => !item.disabled)) + ); +} + export function resolveQuickAction( gitStatus: VcsStatusResult | null, isBusy: boolean, @@ -435,9 +449,21 @@ export type ChangesLineTotals = { // `branchChanges ?? workingTree` treats `{0,0}` as present, so a zero branch total hid a dirty // working tree and the Changes row read +0 while Diff still showed the work. +// The Diff panel's branch comparison wins when it has file stats, so a remembered base or +// hidden whitespace reads the same in the row as in the panel. export function resolveChangesTotals( status: VcsStatusResult | null | undefined, + branchPreview?: ReviewDiffPreviewSource | null, ): ChangesLineTotals | null { + if (branchPreview?.files) { + let insertions = 0; + let deletions = 0; + for (const file of branchPreview.files) { + insertions += file.additions; + deletions += file.deletions; + } + return { insertions, deletions, baseRef: branchPreview.baseRef }; + } if (!status) return null; const branch = status.branchChanges; const tree = { ...status.workingTree, baseRef: null }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2be7142ee9954..bbb009280cccd 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -47,6 +47,7 @@ import { buildMenuItems, formatGitActionElapsed, GIT_ACTION_SUCCESS_VISIBLE_MS, + hasUsableGitMenuContent, type GitActionProgressPresentation, type GitActionIconName, type GitActionMenuItem, @@ -94,6 +95,15 @@ import { import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; +import { useFilesystemReadAccess } from "~/state/filesystem"; +import { reviewEnvironment } from "~/state/review"; +import { revisionedQuery } from "~/state/revisionedQuery"; +import { + selectThreadBranchBaseRef, + useDiffPanelSessionStore, + useDiffPanelStore, +} from "~/diffPanelStore"; +import { useClientSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; import { threadEnvironment } from "~/state/threads"; @@ -122,6 +132,7 @@ interface GitActionsControlProps { showActions?: boolean; showChanges?: boolean; onOpenChanges?: () => void; + workspaceMutationId?: string | null; } interface PendingDefaultBranchAction { @@ -933,6 +944,7 @@ export default function GitActionsControl({ showActions = true, showChanges = true, onOpenChanges, + workspaceMutationId = null, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); const updateThreadMetadata = useAtomCommand( @@ -1051,7 +1063,53 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; - const resolvedChangesTotals = resolveChangesTotals(gitStatusForActions); + const changesBaseRef = useDiffPanelStore((state) => + selectThreadBranchBaseRef(state, activeThreadRef), + ); + const ignoreWhitespaceSetting = useClientSettings((settings) => settings.diffIgnoreWhitespace); + const ignoreWhitespace = + useDiffPanelSessionStore((state) => state.ignoreWhitespace) ?? ignoreWhitespaceSetting; + const { canReadFiles } = useFilesystemReadAccess(activeEnvironmentId); + // Same input as the Diff panel's branch query, so both read one cached result. Status + // totals join the revision because edits, pulls and resets outside a turn leave the mutation + // id as is. + const changesRevision = gitStatusForActions + ? JSON.stringify([ + workspaceMutationId, + gitStatusForActions.refName, + gitStatusForActions.workingTree.insertions, + gitStatusForActions.workingTree.deletions, + gitStatusForActions.branchChanges?.baseRef, + gitStatusForActions.branchChanges?.insertions, + gitStatusForActions.branchChanges?.deletions, + ]) + : null; + const changesPreview = useEnvironmentQuery( + showChanges && + isRepo && + canReadFiles && + changesRevision !== null && + activeEnvironmentId !== null && + gitCwd !== null + ? revisionedQuery( + reviewEnvironment.diffPreview({ + environmentId: activeEnvironmentId, + input: { + cwd: gitCwd, + ...(changesBaseRef ? { baseRef: changesBaseRef } : {}), + ignoreWhitespace, + metadataOnly: true, + sourceKind: "branch-range", + }, + }), + changesRevision, + ) + : null, + ); + const resolvedChangesTotals = resolveChangesTotals( + gitStatusForActions, + changesPreview.data?.sources.find((source) => source.kind === "branch-range"), + ); const changesTotals = resolvedChangesTotals ?? { insertions: 0, deletions: 0 }; const changesBaseLabel = resolvedChangesTotals ? resolvedChangesTotals.baseRef === null @@ -1414,6 +1472,23 @@ export default function GitActionsControl({ const canPublishRepository = canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote; + const isDetachedHead = gitStatusForActions?.refName === null; + const isCleanAndBehind = + gitStatusForActions !== null && + gitStatusForActions.refName !== null && + !gitStatusForActions.hasWorkingTreeChanges && + gitStatusForActions.behindCount > 0 && + gitStatusForActions.aheadCount === 0; + const hasGitMenuContent = hasUsableGitMenuContent({ + menuItems: gitActionMenuItems, + canWriteSourceControl, + canPublishRepository, + hasNotice: isDetachedHead || isCleanAndBehind || Boolean(gitStatusError), + }); + + // A disabled menu cannot refresh on open, and terminal edits or commits publish no status. + const refreshStatusForEmptyMenu = () => + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); const initializeGit = () => { void (async () => { @@ -1491,19 +1566,15 @@ export default function GitActionsControl({ Publish repository... ) : null} - {gitStatusForActions?.refName === null && ( + {isDetachedHead && (

Detached HEAD: create and check out a branch to enable push and{" "} {changeRequestTerminology.singular} actions.

)} - {gitStatusForActions && - gitStatusForActions.refName !== null && - !gitStatusForActions.hasWorkingTreeChanges && - gitStatusForActions.behindCount > 0 && - gitStatusForActions.aheadCount === 0 && ( -

Behind upstream. Pull/rebase first.

- )} + {isCleanAndBehind && ( +

Behind upstream. Pull/rebase first.

+ )} {gitStatusError &&

{gitStatusError}

} ); @@ -1531,6 +1602,8 @@ export default function GitActionsControl({ role="group" aria-label="Git actions" ref={panelAnchorRef} + onPointerEnter={hasGitMenuContent ? undefined : refreshStatusForEmptyMenu} + onFocus={hasGitMenuContent ? undefined : refreshStatusForEmptyMenu} className={cn( "shrink-0", THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, @@ -1638,7 +1711,7 @@ export default function GitActionsControl({ panel /> } - disabled={isGitActionRunning} + disabled={isGitActionRunning || !hasGitMenuContent} >
+ ); + if (normalizedTerminalIds.length === 0) { return (