diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 260be6436c3e8..5343dbb754ff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,10 +140,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 & @@ -287,10 +293,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/.gitignore b/.gitignore index b6b793e020ef1..85b7c5b4bf02f 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 2effa629d25e0..87f21a8a0a2f6 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..ab4e1ed382dab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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/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/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 0000000000000..45f63b951f366 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,67 @@ +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", () => ({})); + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopIpc from "./DesktopIpc.ts"; + +it.effect("answers only the main window's top frame", () => + Effect.gen(function* () { + const mainFrame = {}; + 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 }, + 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"], + 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 354acc5a81552..69d4f70c15a0e 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,17 +1,23 @@ 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 DesktopIpcInvokeEvent { returnValue: unknown; } +export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect; + export type DesktopIpcHandleListener = ( event: DesktopIpcInvokeEvent, raw: unknown, @@ -52,6 +58,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, @@ -80,7 +95,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, @@ -98,6 +117,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")), ), @@ -131,6 +153,11 @@ 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 (!(yield* isTrustedSender(event))) { + yield* Effect.logWarning("Rejected sync IPC from an untrusted sender."); + return undefined; + } return yield* handler(); }).pipe( Effect.annotateLogs({ channel }), @@ -152,7 +179,28 @@ 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) => + Option.isSome(main) && + main.value.webContents.id === event.sender.id && + main.value.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/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/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index eebabd429025d..376f85d2baf9d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -202,7 +202,7 @@ 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 }; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d109518be15e4..e3971d7c74ea5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -139,7 +139,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/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/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 03b9a2e6ad64a..91d034cbd772b 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -233,7 +233,7 @@ function layerTest(input: { readonly destroyAll?: Effect.Effect; readonly onWindowCreate?: Effect.Effect; readonly openedExternalUrls?: unknown[]; - readonly copiedTexts?: string[]; + readonly onCopyText?: (text: string) => Effect.Effect; readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; @@ -325,10 +325,7 @@ function layerTest(input: { return true; }), openSystemSettings: () => Effect.succeed(true), - copyText: (text) => - Effect.sync(() => { - input.copiedTexts?.push(text); - }), + copyText: input.onCopyText ?? (() => Effect.void), } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, @@ -700,6 +697,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. @@ -1186,6 +1232,56 @@ describe("DesktopWindow", () => { }), ); + it.effect("offers to copy the bare address when right-clicking a mailto link", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const popup = yield* Deferred.make(); + const copied = yield* Deferred.make(); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + onPopupTemplate: (input) => Deferred.succeed(popup, input), + onCopyText: (text) => Deferred.succeed(copied, text), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const contextMenu = fakeWindow.webContentsListeners.get("context-menu"); + if (!contextMenu) { + return yield* Effect.die("context-menu listener was not registered"); + } + contextMenu( + { preventDefault: vi.fn() }, + { + linkURL: "mailto:restaurant%40example.com?subject=Hi", + misspelledWord: "", + dictionarySuggestions: [], + mediaType: "none", + editFlags: { canCut: false, canCopy: false, canPaste: false, canSelectAll: true }, + }, + ); + + const { template } = yield* Deferred.await(popup); + const copyEmail = template.find((item) => item.label === "Copy Email Address"); + assert.isDefined( + copyEmail, + `no "Copy Email Address" item in ${JSON.stringify(template.map((item) => item.label ?? item.role ?? item.type))}`, + ); + copyEmail?.click?.( + {} as Electron.MenuItem, + fakeWindow.window, + {} as Electron.KeyboardEvent, + ); + assert.equal(yield* Deferred.await(copied), "restaurant@example.com"); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect( "retries opening the real main on activate when a failed post-readiness open left only the splash", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 17dba6742f795..73f5b41810dae 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -53,6 +53,21 @@ class ContextMenuImageCopyError extends Schema.TaggedError { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return Option.none(); + } + if (url.protocol !== "mailto:" || url.pathname === "") return Option.none(); + try { + return Option.some(decodeURIComponent(url.pathname)); + } catch { + return Option.some(url.pathname); + } +} + function syncMacosWindowButtons(window: Electron.BrowserWindow): void { if (window.isDestroyed() || window.isFullScreen()) return; window.setWindowButtonPosition({ @@ -590,6 +605,19 @@ export const make = Effect.gen(function* () { ); } + const mailtoAddress = parseMailtoAddress(params.linkURL); + if (Option.isSome(mailtoAddress)) { + menuTemplate.push( + { + label: "Copy Email Address", + click: () => { + void runPromise(electronShell.copyText(mailtoAddress.value)); + }, + }, + { type: "separator" }, + ); + } + if (params.mediaType === "image") { menuTemplate.push({ label: "Copy Image", @@ -677,6 +705,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) && @@ -692,6 +721,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") { @@ -877,6 +907,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" }); @@ -895,7 +927,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/AppSymbol.android.tsx b/apps/mobile/src/components/ui/AppSymbol.android.tsx index 866388195855a..7aaeac554417a 100644 --- a/apps/mobile/src/components/ui/AppSymbol.android.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.android.tsx @@ -64,6 +64,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"; @@ -90,6 +91,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"; @@ -106,11 +108,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"; @@ -209,6 +211,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, @@ -275,8 +278,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, @@ -286,23 +290,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/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/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 ? ( [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" @@ -192,7 +219,25 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss This discussion is no longer available. ) : null} {comments.map((comment) => ( - + { + if ( + anchor === null || + !pullRequestAnchorMatchesUrl(anchor, comment.url) || + positionedComment.current === comment.id + ) + return; + positionedComment.current = comment.id; + pendingTop.current = event.nativeEvent.layout.y; + scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false }); + }} + className={ + anchor !== null && pullRequestAnchorMatchesUrl(anchor, comment.url) + ? "-mx-3 gap-3 rounded-2xl border border-primary bg-primary/10 px-3 py-4" + : "gap-3 border-b border-border-subtle py-4" + } + > diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f78cd9caa58cd..a58de3b87a781 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -1,3 +1,10 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, + pullRequestDiffPathMatches, + pullRequestLinkCommit, + resolvePullRequestReviewAnchor, +} from "@t3tools/shared/pullRequestAnchor"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { Chip } from "../../components/ui/Chip"; import { pullRequestTabOffset } from "./pullRequestTabScroll"; @@ -154,8 +161,34 @@ function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityF }); return rows; })(); + const location = parsePullRequestAnchor(params.navigationUrl); + const anchor = + location?.kind !== "timeline" + ? null + : result.data + ? resolvePullRequestReviewAnchor( + location.anchor, + result.data.comments, + result.data.reviewThreads, + ) + : location.anchor; + const targetComment = anchor + ? result.data?.comments.find((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url)) + : null; + const targetThread = anchor + ? result.data?.reviewThreads.find((thread) => + thread.comments.some((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url)), + ) + : null; + const targetKey = targetThread + ? `discussion:${targetThread.id}` + : targetComment + ? `comment:${targetComment.id}` + : null; return { rows, + targetKey, + targetThread, hiddenCount: filtered?.hiddenCount ?? 0, pending: result.isPending && !result.data, error: result.error, @@ -334,7 +367,10 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps("overview"); + const [chosenSection, setSection] = useState<"overview" | "activity" | "guide">(() => { + const kind = parsePullRequestAnchor(params.navigationUrl)?.kind; + return kind === "timeline" || kind === "commits" ? "activity" : "overview"; + }); const requestedSection = useRef(chosenSection); const [listAnimationsReady, setListAnimationsReady] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false); @@ -345,7 +381,14 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps !guidesEnabled && tab === "guide" ? "overview" : tab; const section = shown(chosenSection); - const [activityFilter, setActivityFilter] = useState(DEFAULT_ACTIVITY_FILTER); + const [activityFilter, setActivityFilter] = useState(() => { + const kind = parsePullRequestAnchor(params.navigationUrl)?.kind; + return kind === "timeline" + ? { ...DEFAULT_ACTIVITY_FILTER, bots: true, resolved: true } + : kind === "commits" + ? { ...DEFAULT_ACTIVITY_FILTER, kind: "commits" } + : DEFAULT_ACTIVITY_FILTER; + }); const activityContent = usePullRequestActivityRows(params, activityFilter); const scrollOffsets = useRef>({}); // The offset of the tab last shown, and which tab that was: a tab standing in for a hidden guide @@ -386,6 +429,74 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { + const target = parsePullRequestAnchor(params.navigationUrl); + if (target?.kind === "diff" || target?.kind === "code") { + const commit = pullRequestLinkCommit(params.navigationUrl); + navigation.navigate("PullRequestFile", { + ...params, + path: "", + ...(commit ? { commit } : {}), + }); + } else if (target?.kind === "timeline") { + selectSection("activity"); + setActivityFilter((filter) => ({ ...filter, kind: "all", bots: true, resolved: true })); + } else if (target?.kind === "commits") { + selectSection("activity"); + setActivityFilter((filter) => ({ ...filter, kind: "commits" })); + } else if (target?.kind === "checks") { + selectSection("overview"); + // Checks head Overview, which a reused screen may have scrolled past. + scrollViews.current.overview?.scrollToOffset({ offset: -contentTopInset, animated: false }); + } else if (target?.kind === "summary") { + selectSection("overview"); + } + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a link selects its native PR section or diff even when the route is reused + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- URL navigation selects the PR section and makes its target visible + followLink(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- each clicked URL is a fresh navigation request + }, [params.navigationUrl, params.navigationId]); + + const activityTargetIndex = activityContent.rows.findIndex( + (row) => row.key === activityContent.targetKey, + ); + const openedDiscussion = useRef(null); + const positionedActivity = useRef(null); + // Rows above the target measure in after the first jump, so it is held until the user scrolls. + const heldActivityRow = useRef(null); + const positionActivityRow = (index: number) => + void scrollViews.current.activity?.scrollToIndex({ + index, + viewPosition: 0, + viewOffset: contentTopInset + 12, + animated: false, + }); + const revealActivity = useEffectEvent(() => { + const key = `${params.navigationUrl}:${params.navigationId ?? 0}`; + if (activityContent.targetThread && openedDiscussion.current !== key) { + openedDiscussion.current = key; + navigation.navigate("PullRequestDiscussion", { + ...params, + discussionId: activityContent.targetThread.id, + }); + return; + } + // Later filter or refresh changes move the row; they must not pull the user back to it. + if (activityTargetIndex >= 0 && positionedActivity.current !== key) { + positionedActivity.current = key; + // Its top sits under the app bar; centering a long comment pushed its start off screen. + heldActivityRow.current = activityTargetIndex + 1; + positionActivityRow(activityTargetIndex + 1); + } + }); + const targetThreadId = activityContent.targetThread?.id; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals the requested activity once its asynchronous rows arrive + useEffect(() => { + revealActivity(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- reruns when activity resolves the URL target, even a thread the filter hides + }, [params.navigationUrl, params.navigationId, activityTargetIndex, targetThreadId]); const tabContent = activityContent; const filesY = useRef(0); const descriptionY = useRef(0); @@ -596,6 +707,13 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { + heldActivityRow.current = null; + } + : undefined + } onScroll={(event) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsets.current[tab] = offset; @@ -619,6 +737,13 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { + const held = heldActivityRow.current; + if (tab === "activity" && held !== null) { + // A filter or refresh that moved the row ends the hold. + if (held !== activityTargetIndex + 1) heldActivityRow.current = null; + else positionActivityRow(held); + return; + } if ( tab !== "overview" || section !== "overview" || @@ -848,9 +973,20 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { (detail.data ?? route.params.initialSummary)?.provider, ).changeRequest; const result = usePullRequestDiff(route.params, terminology); + // Other screens open files with the link's params spread in; only the link's own entry follows it. + const linkAnchor = + route.params.path === "" && + (route.params.commit ?? null) === pullRequestLinkCommit(route.params.navigationUrl) + ? parsePullRequestAnchor(route.params.navigationUrl) + : null; + const linkPath = + linkAnchor?.kind === "diff" && result.parsed.kind === "files" + ? result.parsed.files.find((file) => pullRequestDiffPathMatches(linkAnchor.digest, file.path)) + ?.path + : undefined; const filter = usePullRequestFileFilter( result.parsed, - route.params.focus ? route.params.path : undefined, + linkPath ?? (route.params.focus ? route.params.path : undefined), ); const { refresh: refreshDetail } = detail; useFocusEffect(refreshDetail); @@ -922,15 +1058,30 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { code: row.content ?? "", }); }; - const [jumpPath, setJumpPath] = useState(route.params.path); + const [pickedPath, setJumpPath] = useState(null); + const jumpPath = pickedPath ?? linkPath ?? route.params.path; const requestedFile = result.parsed.kind === "files" ? result.parsed.files.find((file) => file.path === jumpPath) : undefined; const targetFile = data.files.find((file) => file.path === jumpPath) ?? - (!jumpPath || requestedFile ? data.files[0] : undefined); - const focus = route.params.focus; + (linkAnchor?.kind === "diff" && !linkPath + ? undefined + : !jumpPath || requestedFile + ? data.files[0] + : undefined); + const range = pickedPath === null && linkAnchor?.kind === "diff" ? linkAnchor.range : null; + const focus = + route.params.focus ?? + (range + ? { + side: range.side === "left" ? ("old" as const) : ("new" as const), + start: range.start, + end: range.end, + endSide: range.endSide === "left" ? ("old" as const) : ("new" as const), + } + : undefined); const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus); const focusIndex = focusedRows.length ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id) @@ -941,7 +1092,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { // The filter the current jump was made under. Hiding or showing test files replaces the rows, // so a jump made under the other filter has to run again. Native applies the entry // destination before its first viewport, so the first one counts as made. - const jumpedUnder = useRef(filter.hideTestFiles); + const jumpedUnder = useRef(targetFile ? filter.hideTestFiles : null); const hasTestFiles = result.parsed.kind === "files" && result.parsed.files.some((file) => isTestChangedFile(file.path)); diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx index 52d5505c45604..03ba26f2247cf 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx @@ -1,3 +1,4 @@ +import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor"; import { usePullRequestConnected } from "./usePullRequestConnected"; import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; @@ -102,7 +103,13 @@ export function PullRequestStatusMenu(props: RowProps) { } export function PullRequestChecksRow(props: RowProps) { - const [expanded, setExpanded] = useState(false); + const linked = parsePullRequestAnchor(props.params.navigationUrl)?.kind === "checks"; + const [expanded, setExpanded] = useState(linked); + const [seenNavigation, setSeenNavigation] = useState(props.params.navigationId); + if (props.params.navigationId !== seenNavigation) { + setSeenNavigation(props.params.navigationId); + if (linked) setExpanded(true); + } const checks = pullRequestChecksSummary(props.pr); const action = usePullRequestAction(props.params, props.pr); const definition = sourceControlClients.get(props.pr.provider); diff --git a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx index e9f288ea9c60c..f3a491f2ef059 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx @@ -1,3 +1,7 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, +} from "@t3tools/shared/pullRequestAnchor"; import { PullRequestEntryRow } from "./PullRequestEntryRow"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -74,7 +78,10 @@ function TimelineComment({ params: PullRequestParams; actions: CommentActions; }) { - const [expanded, setExpanded] = useState(false); + const target = parsePullRequestAnchor(params.navigationUrl); + const linked = + target?.kind === "timeline" && pullRequestAnchorMatchesUrl(target.anchor, comment.url); + const [expanded, setExpanded] = useState(linked); const { themeVariables: colors } = useAppearancePreferences(); const long = comment.body.length > 450 || comment.body.split("\n").length > 8; const content = useMemo( @@ -108,7 +115,13 @@ function TimelineComment({ ) : null} {preview.trim() || content.attachments.length ? ( - + , "navigate" | "dispatch">, actions: CommentActions, ) { + const linkTarget = parsePullRequestAnchor(params.navigationUrl); const discussionIds = new Set( activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), ); @@ -211,7 +225,19 @@ export function pullRequestTimelineRows( .map((comment) => ({ key: `comment:${comment.id}`, date: comment.createdAt, - content: , + content: ( + + ), })), ...activity.reviewThreads.map((thread) => ({ key: `discussion:${thread.id}`, diff --git a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts new file mode 100644 index 0000000000000..49f09276d3659 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vite-plus/test"; +import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface"; +import { guideFocusedRows } from "./guideDiffFocus"; + +it("focuses a range that crosses diff sides in either direction", () => { + const rows: NativeReviewDiffRow[] = [ + { kind: "file", id: "file", fileId: "f" }, + { kind: "line", id: "a", fileId: "f", oldLineNumber: 12 }, + { kind: "line", id: "b", fileId: "f", oldLineNumber: 13 }, + { kind: "line", id: "c", fileId: "f", newLineNumber: 12 }, + { kind: "line", id: "d", fileId: "f", newLineNumber: 13 }, + { kind: "line", id: "other", fileId: "other", newLineNumber: 13 }, + ]; + expect( + guideFocusedRows(rows, "f", { side: "old", start: 12, endSide: "new", end: 13 }).map( + (row) => row.id, + ), + ).toEqual(["a", "b", "c", "d"]); + expect( + guideFocusedRows(rows, "f", { side: "new", start: 13, endSide: "old", end: 12 }).map( + (row) => row.id, + ), + ).toEqual(["a", "b", "c", "d"]); + expect( + guideFocusedRows(rows, "f", { side: "old", start: 12, end: 13 }).map((row) => row.id), + ).toEqual(["a", "b"]); + expect(guideFocusedRows(rows, "f", { side: "old", start: 99, endSide: "new", end: 13 })).toEqual( + [], + ); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts index cc03bdbb60411..d840efb041f74 100644 --- a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts +++ b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts @@ -2,6 +2,7 @@ import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface"; export type GuideDiffFocus = { readonly side: "old" | "new"; + readonly endSide?: "old" | "new"; readonly start: number; readonly end: number; }; @@ -12,6 +13,23 @@ export function guideFocusedRows( focus: GuideDiffFocus | undefined, ) { if (!fileId || !focus) return []; + if (focus.endSide && focus.endSide !== focus.side) { + const start = rows.findIndex( + (row) => + row.fileId === fileId && + (focus.side === "old" ? row.oldLineNumber : row.newLineNumber) === focus.start, + ); + const end = rows.findIndex( + (row) => + row.fileId === fileId && + (focus.endSide === "old" ? row.oldLineNumber : row.newLineNumber) === focus.end, + ); + return start < 0 || end < 0 + ? [] + : rows + .slice(Math.min(start, end), Math.max(start, end) + 1) + .filter((row) => row.fileId === fileId && row.kind === "line"); + } return rows.filter((row) => { const line = focus.side === "old" ? row.oldLineNumber : row.newLineNumber; return ( diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 8a97deffaf06a..8a95397cb3d3a 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -34,7 +34,13 @@ describe("native PR entry points", () => { context, projects, ), - ).toEqual({ ...context, host: "github.com", repository: "org/repo", number: 12 }); + ).toEqual({ + ...context, + host: "github.com", + repository: "org/repo", + number: 12, + navigationUrl: "https://github.com/org/repo/pull/12/files#diff", + }); expect( nativePullRequestDestination( "https://git.example/group/sub/repo/-/merge_requests/9", diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 6f5180d8ab6bc..6e49993a137f3 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -23,6 +23,8 @@ export type PullRequestParams = { readonly repository: string; readonly number: number; readonly host?: string; + readonly navigationUrl?: string; + readonly navigationId?: number; readonly initialSummary?: PullRequestListEntry | PullRequestSummary; }; diff --git a/apps/mobile/src/features/settings/CloudVoiceSection.tsx b/apps/mobile/src/features/settings/CloudVoiceSection.tsx index 684f8dbc83769..6ca61ece7b90a 100644 --- a/apps/mobile/src/features/settings/CloudVoiceSection.tsx +++ b/apps/mobile/src/features/settings/CloudVoiceSection.tsx @@ -21,7 +21,7 @@ import { import * as Schema from "effect/Schema"; import Constants from "expo-constants"; import { AsyncResult } from "effect/reactivity"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { ScrollView, TextInput, View } from "react-native"; import { Sheet } from "../../components/ui/Sheet"; import * as Dialog from "../../components/ui/Dialog"; @@ -30,6 +30,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { ListActionRow } from "../../components/ui/ListActionRow"; import { ListChoiceRow } from "../../components/ui/ListChoiceRow"; import { Chip } from "../../components/ui/Chip"; +import { ListSection } from "../../components/ui/ListSection"; import type { EnvironmentPresentation } from "../../state/environments"; import { useEnvironmentScope } from "../../state/session"; import { @@ -40,7 +41,6 @@ import { cloudVoiceModelsAtom, runSpeech, } from "../voice-input/voiceDestination"; -import { SettingsSection } from "./components/SettingsSection"; type CloudDestination = Extract; type Props = { environment: EnvironmentPresentation; active: CloudDestination | null }; @@ -48,23 +48,38 @@ const isApiKey = Schema.is(CloudSpeechApiKey); const isModelId = Schema.is(CloudSpeechModelId); const MODEL_ROWS = 6; -export function CloudVoiceSection({ environment, active }: Props) { - const [open, setOpen] = useState(false); +/** The cloud method row for one environment; opens the provider sheet. */ +export function CloudVoiceChoice({ + environment, + active, + open, + onOpenChange, +}: Props & { open: boolean; onOpenChange: (open: boolean) => void }) { + const usable = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null; return ( - - + setOpen(true)} + selected={active !== null} + disabled={!usable} + separated + onPress={() => onOpenChange(true)} /> {open && ( - setOpen(false)} /> + onOpenChange(false)} + /> )} - + ); } @@ -92,54 +107,70 @@ function ModelPicker({ const shown = showAll ? matches : matches.slice(0, MODEL_ROWS); const delisted = current !== undefined && !models.some((model) => model.id === current); return ( - <> - - {delisted ? ( - - OpenRouter no longer lists {current}. Dictation stays off until you choose another model. - - ) : null} + + + + {selected && !shown.some((model) => model.id === selected.id) ? ( {}} /> ) : null} - {shown.map((model, index) => ( + {shown.map((model) => ( 0} + separated disabled={disabled} onPress={() => onChange(model.id)} /> ))} {matches.length === 0 ? ( - No transcription model matches. + + + No transcription model matches. + + ) : matches.length > shown.length ? ( - setShowAll(true)} - /> + + setShowAll(true)} + /> + ) : null} - + ); } +/** A hairline above a row that has no `separated` prop of its own. */ +function Separated({ children }: { children: ReactNode }) { + return {children}; +} + function CloudChoices({ environment, active, onClose }: Props & { onClose: () => void }) { const id = environment.environmentId; const connection = useAtomValue(cloudVoiceConnectionAtom(id)); @@ -223,58 +254,35 @@ function CloudChoices({ environment, active, onClose }: Props & { onClose: () => onClose(); }; const catalog = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null; - const content = - !AsyncResult.isSuccess(keys) || !keys.value || !connection ? ( - - ) : ( - CloudSpeechProvider.literals.map((provider) => { - if (editing && provider !== editing.provider) return null; - const label = CLOUD_SPEECH_LABELS[provider]; - const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; - const routed = provider === "openrouter"; - const disclosure = cloudSpeechDisclosure(provider, environment.label); - const saved = keys.value![provider]; - const isActive = active?.provider === provider; - const isEditing = editing?.provider === provider; - const modelChosen = - isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false); - const modelChanged = model !== (activeModel ?? ""); - const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider]; - const keyChanged = - credential !== undefined && - !( - isSecretReference(credential) && - isSecretReference(original) && - original.provider === credential.provider && - original.reference === credential.reference - ); - const routedReady = - modelChosen && - (draft.trim() ? credential !== undefined : Boolean(saved)) && - (!isActive || modelChanged || keyChanged); - const saveRouted = () => { - if (!routedReady || !isModelId(model)) return; - const grantChanges = !isActive || modelChanged; - if (credential !== undefined && keyChanged) - return void changeKey(provider, credential, model); - // Model-only changes never write to the environment. - if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model)); - onClose(); - }; - return ( - - {!isEditing && ( + const content = (() => { + if (!AsyncResult.isSuccess(keys) || !keys.value || !connection) + return ( + + + + ); + const saved = keys.value; + if (!editing) + return ( + + {CloudSpeechProvider.literals.map((provider, index) => { + const label = CLOUD_SPEECH_LABELS[provider]; + const routed = provider === "openrouter"; + const key = saved[provider]; + const isActive = active?.provider === provider; + return ( /> } selected={isActive} - disabled={saved === undefined || (!saved && !canManage) || saving} + separated={index > 0} + disabled={key === undefined || (!key && !canManage) || saving} description={ - saved === undefined + key === undefined ? "Update the environment to use this provider" : isActive && routed ? `${activeModel} · Tap to change model${canManage ? " or key" : ""}` - : isActive && saved && canManage + : isActive && key && canManage ? "Tap to manage key" - : saved + : key ? routed ? "API key saved · choose a model to use it" - : "API key saved on this environment" + : "API key saved" : canManage ? "Add an API key to use this provider" : "Ask an environment administrator to add an API key" } onPress={() => { if (routed) return edit(provider, !isActive); - if (!saved) return edit(provider, true); + if (!key) return edit(provider, true); if (isActive) { if (canManage) edit(provider, false); return; } Dialog.confirm({ title: `Use ${label} for dictation?`, - message: `${disclosure} Select This device to stop sharing.`, + message: `${cloudSpeechDisclosure(provider, environment.label)} Select This device to stop sharing.`, confirmText: `Use ${label}`, onConfirm: () => enable(pendingCloudVoiceChoice(id, provider)), }); }} /> - )} - {isEditing && routed && ( - <> - {disclosure} - {catalog ? ( - + ); + + const provider = editing.provider; + const label = CLOUD_SPEECH_LABELS[provider]; + const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; + const routed = provider === "openrouter"; + const disclosure = cloudSpeechDisclosure(provider, environment.label); + const key = saved[provider]; + const isActive = active?.provider === provider; + const modelChosen = isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false); + const modelChanged = model !== (activeModel ?? ""); + const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider]; + const keyChanged = + credential !== undefined && + !( + isSecretReference(credential) && + isSecretReference(original) && + original.provider === credential.provider && + original.reference === credential.reference + ); + const routedReady = + modelChosen && + (draft.trim() ? credential !== undefined : Boolean(key)) && + (!isActive || modelChanged || keyChanged); + const saveRouted = () => { + if (!routedReady || !isModelId(model)) return; + const grantChanges = !isActive || modelChanged; + if (credential !== undefined && keyChanged) + return void changeKey(provider, credential, model); + // Model-only changes never write to the environment. + if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model)); + onClose(); + }; + const submit = () => { + if (routed) saveRouted(); + else if (credential) void changeKey(provider, credential); + }; + const keyFooter = + draft && !credential + ? (source?.invalidReferenceMessage ?? "Enter a valid API key.") + : routed + ? key && !credential + ? "Uses the API key saved on this environment. The model choice applies only to this device." + : `Anyone with dictation access to ${environment.label} can bill this key.` + : editing.enable + ? `Anyone with dictation access to ${environment.label} can bill this key.` + : `Updates the ${company} key on ${environment.label}. Your dictation destination stays the same.`; + return ( + <> + {disclosure} + {routed ? ( + catalog ? ( + + ) : ( + + + + ) + ) : null} + {canManage ? ( + + + {sources.length > 0 && ( + + - ) : ( - { + setDraft(""); + setSourceId(null); + }} /> - )} - - )} - {isEditing && (canManage || routed) && ( - <> - {canManage && sources.length > 0 && ( - + {sources.map((candidate) => ( { setDraft(""); - setSourceId(null); + setSourceId(candidate.id); }} /> - {sources.map((candidate) => ( - { - setDraft(""); - setSourceId(candidate.id); - }} - /> - ))} - - )} - {canManage && ( - { - if (routed) saveRouted(); - else if (credential) void changeKey(provider, credential); - }} - value={draft} - onChangeText={setDraft} - className="mx-4 rounded-lg bg-background px-3 py-2 text-foreground" - /> - )} - {routed && !saved && !canManage ? ( - {}} - /> - ) : ( - { - if (routed) saveRouted(); - else if (credential) void changeKey(provider, credential); - }} - /> - )} - {saved && canManage && ( - - Dialog.confirm({ - title: `Remove the ${label} key from ${environment.label}?`, - message: "Everyone using it for dictation will lose access.", - confirmText: "Remove key", - destructive: true, - onConfirm: () => void changeKey(provider, null), - }) - } - /> - )} - - + ))} + + )} + + + + ) : null} + {canManage || routed ? ( + + {routed && !key && !canManage ? ( + {}} + /> + ) : ( + )} - - ); - }) + {key && canManage ? ( + + + Dialog.confirm({ + title: `Remove the ${label} key from ${environment.label}?`, + message: "Everyone using it for dictation will lose access.", + confirmText: "Remove key", + destructive: true, + onConfirm: () => void changeKey(provider, null), + }) + } + /> + + ) : null} + + ) : null} + + + + ); + })(); return ( {content} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index a4177bfdd3376..418e4286502da 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -274,7 +274,7 @@ function useSettingsIndexSections(): SettingsListSection[] { { rows: [ serverPage("SettingsEnvironmentMaintenance", "arrow.clockwise", "Maintenance"), - page("SettingsLabs", "sparkles", "Labs"), + page("SettingsLabs", "flask", "Labs"), page("SettingsAbout", "info.circle", "About"), page("SettingsArchive", "archivebox", "Archive"), ], diff --git a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx index 5f7328214cb16..4b0ca00e88666 100644 --- a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx @@ -24,13 +24,14 @@ import * as Dialog from "../../components/ui/Dialog"; import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; import { chooseVoiceDestination, + cloudVoiceConnectionAtom, voiceConnectionAtom, voiceGrantAtom, - voiceSpeechStatusAtom, hasEnvironmentVoiceCapture, } from "../voice-input/voiceDestination"; -import { VoiceModelSection } from "./VoiceModelSection"; -import { CloudVoiceSection } from "./CloudVoiceSection"; +import { LocalModelChoice } from "./VoiceModelSection"; +import { CloudVoiceChoice } from "./CloudVoiceSection"; +import type { VoiceGrant } from "../voice-input/voiceGrantStorage"; import type { EnvironmentId } from "@t3tools/contracts"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; @@ -127,45 +128,81 @@ function saveDestination(id: EnvironmentId | null) { function EnvironmentChoice({ environment, selected, + onCloudOnly, }: { environment: EnvironmentPresentation; selected: boolean; + /** Called instead of a local grant when the environment only offers cloud transcription. */ + onCloudOnly: () => void; }) { - const usable = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null; - const status = AsyncResult.getOrElse( - useAtomValue(voiceSpeechStatusAtom(environment.environmentId)), - () => null, - ); + const local = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null; + const cloud = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null; + const usable = local || cloud; return ( { - if (!selected) - Dialog.confirm({ - title: `Send microphone audio to ${environment.label}?`, - message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`, - confirmText: "Use environment", - onConfirm: () => saveDestination(environment.environmentId), - }); + if (selected) return; + if (!local) return onCloudOnly(); + Dialog.confirm({ + title: `Send microphone audio to ${environment.label}?`, + message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`, + confirmText: "Use environment", + onConfirm: () => saveDestination(environment.environmentId), + }); }} /> ); } +/** How the selected environment transcribes: its own model or a cloud provider. */ +function EnvironmentMethodSection({ + environment, + grant, + cloudOpen, + onCloudOpenChange, +}: { + environment: EnvironmentPresentation; + grant: VoiceGrant | null; + cloudOpen: boolean; + onCloudOpenChange: (open: boolean) => void; +}) { + const id = environment.environmentId; + const destination = grant?.destination.environmentId === id ? grant.destination : null; + const cloudCapable = + environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true || + destination?.kind === "cloud"; + return ( + + saveDestination(id)} + /> + {cloudCapable ? ( + + ) : null} + + ); +} + export function SettingsVoiceRouteScreen() { const transcriber = useAtomValue(localVoiceTranscriberAtom); const grant = useAtomValue(voiceGrantAtom); @@ -175,6 +212,15 @@ export function SettingsVoiceRouteScreen() { (environment) => environment.environmentId === grant.destination.environmentId, ) : undefined; + // An environment whose cloud sheet is open; it may not hold the grant yet. + const [cloudSheet, setCloudSheet] = useState(null); + // A sheet whose environment lost cloud transcription is gone, so its pending choice is too. + const sheetEnvironment = environments.find( + (environment) => + environment.environmentId === cloudSheet && + environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true, + ); + const methodEnvironment = sheetEnvironment ?? grantEnvironment; const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -234,8 +280,11 @@ export function SettingsVoiceRouteScreen() { saveDestination(null)} + selected={!grant && !sheetEnvironment} + onPress={() => { + setCloudSheet(null); + saveDestination(null); + }} /> {hasEnvironmentVoiceCapture && environments.map((environment) => ( @@ -243,9 +292,11 @@ export function SettingsVoiceRouteScreen() { key={environment.environmentId} environment={environment} selected={ - grant?.destination.kind === "environment" && - grant.destination.environmentId === environment.environmentId + sheetEnvironment + ? sheetEnvironment.environmentId === environment.environmentId + : grant?.destination.environmentId === environment.environmentId } + onCloudOnly={() => setCloudSheet(environment.environmentId)} /> ))} {isReady && grant && !grantEnvironment ? ( @@ -259,30 +310,14 @@ export function SettingsVoiceRouteScreen() { /> ) : null} - {hasEnvironmentVoiceCapture && - environments - .filter( - (env) => - env.serverConfig?.environment.capabilities.cloudVoiceTranscription || - (grant?.destination.kind === "cloud" && - grant.destination.environmentId === env.environmentId), - ) - .map((environment) => ( - - ))} - {grantEnvironment && grant?.destination.kind === "environment" ? ( - + setCloudSheet(open ? methodEnvironment.environmentId : null) + } /> ) : null} {!grant && transcriber === null ? ( diff --git a/apps/mobile/src/features/settings/VoiceModelSection.tsx b/apps/mobile/src/features/settings/VoiceModelSection.tsx index 2403f113b2af8..0bee9748b19cb 100644 --- a/apps/mobile/src/features/settings/VoiceModelSection.tsx +++ b/apps/mobile/src/features/settings/VoiceModelSection.tsx @@ -29,7 +29,6 @@ import { voiceSpeechModelsAtom, voiceSpeechStatusAtom, } from "../voice-input/voiceDestination"; -import { SettingsSection } from "./components/SettingsSection"; const isDownloading = (model: EnvironmentSpeechModel) => model.state === "downloading" || model.state === "verifying"; @@ -272,51 +271,59 @@ export function VoiceModelRow(props: { ); } -export function VoiceModelSection(props: { +/** The environment's local-model method: chosen by its mark, opens the model list. */ +export function LocalModelChoice(props: { readonly environmentId: EnvironmentId; readonly label: string; + readonly selected: boolean; + readonly onSelect: () => void; }) { const navigation = useNavigation(); const voice = useVoiceModels(props.environmentId, props.label); const { status } = voice; - if (!voice.connection || !status) return null; - const title = `Transcription model on ${props.label}`; - if (!status.supported) { + const open = () => + navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId }); + if (!voice.connection || !status || !status.supported) return ( - - {null} - + {}} + /> ); - } const active = status.state === "missing-model" ? null : status; const pending = voice.models.find(isDownloading); const progress = pending?.downloaded === undefined ? undefined : pending.downloaded / pending.size; return ( - - - navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId }) - } - /> - + selected={props.selected} + onPress={() => { + if (!props.selected) props.onSelect(); + // Switching to an installed model needs no further step. + if (props.selected || !active) open(); + }} + /> ); } diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index ef39ef51ff4cb..49f43cc2161b2 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -1,3 +1,4 @@ +import { providerClients } from "@t3tools/client-runtime/provider-clients"; import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; import { Pressable, ScrollView, View } from "react-native"; @@ -5,8 +6,6 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; - export function ComposerUsageLimits({ report, environmentId, @@ -37,7 +36,7 @@ export function ComposerUsageLimits({ const resetCreditInput = account.resetCreditInput ?? (account.instanceId ? { instanceId: account.instanceId } : undefined); - const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + const driverLabel = providerClients.get(account.driver)?.label ?? account.driver; return ( const LINK_SYMBOL: AppSymbolName = { ios: "link", android: "desktop_windows" }; -/** The machine list's row for linking by an address typed in. */ -const ANOTHER_ADDRESS = "another-address"; - /** Whether the agent left the machine for the user to pick. */ const picksMachine = (item: LinkRequestItem) => item.environmentId === undefined && item.url === undefined; @@ -141,11 +141,6 @@ function PendingLinkRequest(props: { {item.reason.trim() ? ( {item.reason} ) : null} - {item.label !== undefined && item.url !== undefined ? ( - - {item.url} - - ) : null} {error !== null ? ( ; - readonly linked: boolean; - } - | { readonly kind: "address" }; - -function resolveTarget( - item: LinkRequestItem, - machines: ReadonlyArray, - choice: string | null, -): SheetTarget { - if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" }; - const chosen = item.environmentId ?? choice; - if (chosen === null) return { kind: "none" }; - if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; - const machine = machines.find((candidate) => candidate.environmentId === chosen); - return { - kind: "machine", - environmentId: machine?.environmentId ?? (chosen as EnvironmentId), - label: machine?.label ?? linkRequestTargetName(item), - urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])], - linked: machine?.linked ?? false, - }; -} - function LinkRequestSheet(props: { readonly environmentId: EnvironmentId; readonly item: LinkRequestItem; @@ -251,17 +215,22 @@ function LinkRequestSheet(props: { reportDefect: false, }); const selected = choice ?? linkRequestInitialMachine(item, machines); - const target = resolveTarget(item, machines, selected); + const target = linkRequestTarget(item, machines, selected); const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item); - // This phone can mint a code there when it is connected to that environment - // with a session that may create pairing links. const targetId = target.kind === "machine" ? target.environmentId : null; const connected = usePreparedConnection(targetId); const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); - const canMint = - targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); const needsAddress = target.kind === "address" || (target.kind === "machine" && target.urls.length === 0); + // This phone mints a code there when it is connected with a session that may + // create pairing links, and only for its own addresses: anything can answer + // as that machine at one typed in, so that one gets a code the user fetched. + const canMint = + targetId !== null && + !needsAddress && + Option.isSome(connected) && + mayMint && + !mintFailed.has(targetId); const useExisting = target.kind === "machine" && target.linked; const ready = target.kind !== "none" && @@ -288,10 +257,7 @@ function LinkRequestSheet(props: { } return linkRequestAnswerInput(item, { type: "link", - target: - target.kind === "address" - ? { url: address } - : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + target: target.kind === "machine" && target.urls.length > 0 ? target : { url: address }, access, pairingCode: code, }); @@ -335,12 +301,27 @@ function LinkRequestSheet(props: { Agents here can then launch, message and wait on threads there. It appears in that machine's Connections, where it can be revoked. - {!pickMachine && item.url !== undefined ? ( + {item.url !== undefined && target.kind === "machine" ? ( {item.url} ) : null} + {needsAddress && !useExisting && address.trim() ? ( + + Check this address + + This app cannot confirm it is one of your machines. The pairing code you paste is sent + there. + + + {linkDestination(address)} + + + ) : null} {pickMachine ? ( {machines.map((machine, index) => ( @@ -369,7 +350,7 @@ function LinkRequestSheet(props: { /> ) : null} - {needsAddress && !(item.url !== undefined && item.environmentId === undefined) ? ( + {needsAddress && item.url === undefined ? ( - + - - - @@ -1914,7 +1906,10 @@ function renderFeedEntry( const bubbleBodyVisible = foldRowId === null || foldExpanded; const intentBadge = props.userMessageGrouping.continuesSteer ? null - : resolveUserMessageIntentBadge(message.inputIntent); + : resolveUserMessageIntentBadge( + message.inputIntent, + message.projectedItem?.item.status === "pending", + ); const referenceIds = new Set( collectComposerContextReferences(message.text).map((reference) => reference.contextId), ); @@ -2772,10 +2767,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(); @@ -2893,17 +2890,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, }, @@ -2913,7 +2911,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); @@ -2930,7 +2928,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, ), @@ -3200,7 +3201,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 dce389c836669..3d0253e2d3a21 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -48,7 +48,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 { ThreadListV2PendingRow, @@ -592,7 +591,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 f3d3f3b5252b6..734ebf4583e03 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -90,7 +90,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 e353708847707..fc72af2f06b0f 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -25,6 +25,7 @@ import { useEffect, useId, useLayoutEffect, + useMemo, useRef, useState, type ComponentProps, @@ -36,6 +37,7 @@ import { Pressable, ScrollView, StyleSheet, + Text as RNText, useAnimatedValue, View, } from "react-native"; @@ -54,6 +56,11 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; 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"; @@ -372,7 +379,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": @@ -807,6 +814,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" : ""; @@ -816,10 +848,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} - + ); }), ]; @@ -1179,7 +1212,13 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( props.renderReasoning(reasoning.text) ) : call ? ( [ - call.command, + call.command ? ( + + ) : null, ...(call.args ?? []).map(([key, value]) => { const language = key === "html" @@ -1534,7 +1573,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 a9780d9954ae7..2f0ad0f89dfc0 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 { collectLimitAccounts, @@ -29,17 +30,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"; } @@ -444,7 +439,7 @@ export function UsageLimitsSection({ - {DRIVER_LABEL[pool.driver] ?? pool.driver} + {providerClients.get(pool.driver)?.label ?? pool.driver} {windows.map((window) => { @@ -559,7 +554,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 214e64fab628b..be4bf0c929d11 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/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 7e794ac089b9c..1b31460842f39 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -249,7 +249,7 @@ function UsageScreen(props: { onSelect={setView} size="compact" role="tab" - className="w-60" + className="flex-1" /> ) : ( 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 25946315cada2..4dd5ecbd08317 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -20,12 +20,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 b5a3d74fc31a7..48c64d1869e4c 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -92,7 +92,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/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 3c336b0292389..50c90a05106a2 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"; @@ -624,7 +625,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", 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/server/package.json b/apps/server/package.json index 284084065e61a..d7e4d21f08b82 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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/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 fd763f60a1ef6..f05120ddaf181 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -381,6 +381,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/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index c58037502512f..4eb43010db53d 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -470,10 +470,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), @@ -482,7 +486,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 f67a39904a06e..180f053f111f4 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -265,7 +265,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/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index d1f95140fa0a6..2916c8f965386 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -955,6 +955,54 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect.each(["SIGINT", "SIGTERM"] as const)( + "does not restart a connector that exits after the server receives %s", + (signal) => + Effect.gen(function* () { + const spawned: Array = []; + const firstExit = yield* Deferred.make(); + const firstKilled = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = 400 + spawned.length; + spawned.push(pid); + const handle = makeHandle({ + pid, + exitCode: + pid === 400 + ? Deferred.await(firstExit) + : (Effect.never as Effect.Effect), + onKill: () => { + if (pid === 400) Deferred.doneUnsafe(firstKilled, Exit.void); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + // Invoke only the runtime's own listeners; emitting would reach the test worker's too. + const listenersBefore = new Set(process.listeners(signal)); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const runtimeListeners = process + .listeners(signal) + .filter((listener) => !listenersBefore.has(listener)); + + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + for (const listener of runtimeListeners) listener(signal); + // The process group receives the signal too, so the connector exits cleanly. + yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.await(firstKilled); + // Holding the reconcile lock waits out the supervisor's restart decision. + yield* runtime.restartConnector(400, Effect.succeed(true)); + + expect(spawned).toEqual([400]); + }), + ); + it.effect("serializes concurrent connector config changes", () => Effect.gen(function* () { const spawned: Array = []; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 66ef48f547730..6e86f87ffef95 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -228,6 +228,22 @@ export const make = Effect.gen(function* () { const prunedRef = yield* Ref.make(false); const retryLoopRunningRef = yield* Ref.make(false); const notReadyChecksBeforeRestartRef = yield* Ref.make(CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); + // The relay client shares the server's process group, so it exits on these signals before the finalizer runs. + let shutdownSignalReceived = false; + const onShutdownSignal = () => { + shutdownSignalReceived = true; + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("SIGINT", onShutdownSignal); + process.on("SIGTERM", onShutdownSignal); + }), + () => + Effect.sync(() => { + process.off("SIGINT", onShutdownSignal); + process.off("SIGTERM", onShutdownSignal); + }), + ); // An explicit cloudflared transport in the environment is the user's choice. const env = yield* HostProcess.Environment; const transportChosenByUser = Option.isSome( @@ -363,6 +379,7 @@ export const make = Effect.gen(function* () { const desiredConfig = yield* Ref.get(desiredConfigRef); if ( + shutdownSignalReceived || !desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel" || runtimeConfigKey(desiredConfig) !== connector.configKey diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 0df57f10f3bd3..90069f46f9173 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -71,6 +71,8 @@ export class PinnedRuntimeInstallError extends Schema.TaggedError new PinnedRuntimeInstallError({ step: cause._tag === "TimeoutError" ? `${step} (timed out)` : step, + httpStatus: + HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError" + ? cause.reason.response.status + : undefined, + downloadHost: URL.parse( + HttpClientError.isHttpClientError(cause) ? cause.request.url : url, + )?.host, cause, }), ), diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index a938d6778820f..6be4288ce35e4 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import packageJson from "../../package.json" with { type: "json" }; @@ -30,6 +30,7 @@ interface HarnessOptions { readonly preflightDuration?: Duration.Input; readonly preflightStarted?: Deferred.Deferred; readonly beforeDownload?: Effect.Effect; + readonly httpClient?: HttpClient.HttpClient; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; readonly policy?: ManagedPolicyDocument; @@ -145,7 +146,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), Effect.provideService( HttpClient.HttpClient, - releaseHttpClient(order, urls, options.beforeDownload), + options.httpClient ?? releaseHttpClient(order, urls, options.beforeDownload), ), Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Architecture, "x64"), @@ -156,10 +157,45 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), ), ); - return { selfUpdate, order, urls }; + return { selfUpdate, order, urls, baseDir }; }); it.layer(NodeServices.layer)("server self update", (it) => { + it.effect.each([ + { asset: "checksums", status: 504 }, + { asset: "archive", status: 503 }, + { asset: "checksums", status: 403 }, + ])("reports an HTTP $status downloading $asset and allows retry", ({ asset, status }) => + Effect.gen(function* () { + let failDownload = true; + const { selfUpdate, order, baseDir } = yield* makeHarness({ + httpClient: releaseHttpClient([], []).pipe( + HttpClient.transform((response, request) => + failDownload && request.url.endsWith("/SHA256SUMS") === (asset === "checksums") + ? Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get("https://user:password@cdn.example/private?token=secret"), + new Response("private response body", { status }), + ), + ) + : response, + ), + ), + }); + const error = yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip); + expect(error.reason).toBe( + `Could not prepare t3@1.1.0 while downloading the t3 release ${asset} (HTTP ${status} from cdn.example).`, + ); + expect(order).toEqual([]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions"))).toEqual([]); + failDownload = false; + expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id"); + expect(order).toEqual(["extract", "preflight", "accept"]); + }), + ); + // regression: #5181 make remote updates rollback-safe it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 58baa160adf71..5e299964138da 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -282,16 +282,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ), ), }).pipe( - Effect.mapError((error) => - error._tag === "PinnedRuntimePreflightBlockedError" - ? failWith(error.reason, error) - : mirror !== undefined && isUnpublishedRelease(error) - ? failWith( - `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, - error, - ) - : failWith(`Could not prepare t3@${targetVersion}.`, error), - ), + Effect.mapError((error) => { + if (error._tag === "PinnedRuntimePreflightBlockedError") { + return failWith(error.reason, error); + } + if (mirror !== undefined && isUnpublishedRelease(error)) { + return failWith( + `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, + error, + ); + } + const downloadFailure = [ + error.httpStatus === undefined ? undefined : `HTTP ${error.httpStatus}`, + error.downloadHost === undefined ? undefined : `from ${error.downloadHost}`, + ] + .filter((part) => part !== undefined) + .join(" "); + return failWith( + `Could not prepare t3@${targetVersion} while ${error.step}${downloadFailure ? ` (${downloadFailure})` : ""}.`, + error, + ); + }), ); yield* reportProgress("installing"); 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/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 634898612b077..72b3504807c93 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3503,6 +3503,125 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect.each([ + { localBranch: "develop" }, + // Pushes to origin's develop under a name that differs from the base. + { localBranch: "origin/develop" }, + ] as const)( + "commit_push_pr refuses to commit or push on the default branch when origin/HEAD is missing ($localBranch)", + ({ localBranch }) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["branch", "-m", "main", "develop"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "develop"]); + if (localBranch !== "develop") yield* runGit(repoDir, ["checkout", "-b", localBranch]); + const readRemoteDevelop = runGit(repoDir, [ + "ls-remote", + "origin", + "refs/heads/develop", + ]).pipe(Effect.map((output) => output.stdout)); + const remoteBefore = yield* readRemoteDevelop; + const localBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + + const { manager } = yield* makeManager({ + providerScenario: { defaultBranch: "develop", prListSequence: [[], []] }, + }); + + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe(Effect.flip); + + expect(yield* readRemoteDevelop).toBe(remoteBefore); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(localBefore); + expect(error.message).toContain("develop is the base branch"); + }), + ); + + it.effect("commit_push_pr on the default branch returns its open PR into another base", () => + 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"]); + const releasePr = { + number: 43, + title: "Release", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "release", + headRefName: "main", + }; + + const { manager } = yield* makeManager({ + providerScenario: { defaultBranch: "main", prListSequence: [[releasePr], [releasePr]] }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(43); + }), + ); + + it.effect( + "commit_push_pr publishes a branch that tracks the default branch under its own name", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["branch", "-m", "main", "develop"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "develop"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/feature", "--track", "origin/develop"]); + const readRemoteDevelop = runGit(repoDir, [ + "ls-remote", + "origin", + "refs/heads/develop", + ]).pipe(Effect.map((output) => output.stdout)); + const remoteBefore = yield* readRemoteDevelop; + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + + const { manager, providerCalls } = yield* makeManager({ + providerScenario: { + defaultBranch: "develop", + prListSequence: [ + [], + [ + { + number: 506, + title: "Feature", + url: "https://github.com/pingdotgg/codething-mvp/pull/506", + baseRefName: "develop", + headRefName: "t3code/feature", + }, + ], + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(yield* readRemoteDevelop).toBe(remoteBefore); + expect( + created(providerCalls).some( + (call) => call.baseRefName === "develop" && call.headSelector === "t3code/feature", + ), + ).toBe(true); + }), + ); + it.effect("create_pr reports a created PR when the lookup after creation fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4355,6 +4474,74 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("commit_push_pr publishes the default branch to the push remote's fork", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["push", "fork", "main"]); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + const readOriginMain = runGit(repoDir, ["ls-remote", "origin", "refs/heads/main"]).pipe( + Effect.map((output) => output.stdout), + ); + const originBefore = yield* readOriginMain; + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + + const { manager, providerCalls } = yield* makeManager({ + providerScenario: { + defaultBranch: "main", + prListSequenceByHeadSelector: { + "octocat:main": [ + [], + [ + { + number: 189, + title: "Contribute from main", + url: "https://github.com/pingdotgg/codething-mvp/pull/189", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + ], + ], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(yield* readOriginMain).toBe(originBefore); + expect( + created(providerCalls).some( + (call) => call.baseRefName === "main" && call.headSelector === "octocat:main", + ), + ).toBe(true); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => 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 e6b4bdcfe4230..db0d3c91ad194 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; } @@ -2156,6 +2157,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))); @@ -2282,6 +2284,34 @@ export const make = Effect.gen(function* () { }; }); + const ensurePrHeadIsNotBase = Effect.fn("ensurePrHeadIsNotBase")(function* ( + cwd: string, + status: { branch: string; upstreamRef: string | null; hasUpstream: boolean }, + changeRequestLabel: string, + ) { + const target = yield* gitCore.resolvePushTarget(cwd, status); + if (!target) return; + const headContext = yield* resolveBranchHeadContext(cwd, { + branch: status.branch, + upstreamRef: `${target.remoteName}/${target.branchName}`, + remoteName: target.remoteName, + }); + if (headContext.isCrossRepository) return; + const { baseBranch } = yield* resolvePrBase( + cwd, + status.branch, + status.upstreamRef, + headContext, + ); + if (baseBranch !== target.branchName) return; + if (yield* findOpenPr(cwd, headContext)) return; + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd, + detail: `${baseBranch} is the base branch. Check out a feature branch to create ${changeRequestLabel}.`, + }); + }); + const runPrStep = Effect.fn("runPrStep")(function* ( settings: SourceControlTextGenerationSettings, cwd: string, @@ -2358,6 +2388,7 @@ export const make = Effect.gen(function* () { ...(threadContext ? { threadContext } : {}), ...(policy ? { policy } : {}), modelSelection: settings.modelSelection, + projectId: settings.projectId, }); const bodyFile = path.join( @@ -3203,6 +3234,7 @@ export const make = Effect.gen(function* () { settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, + projectId, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( @@ -3212,6 +3244,7 @@ export const make = Effect.gen(function* () { providers, projectId, ), + projectId, style: settings.sourceControlWritingStyle, })), ), @@ -3251,6 +3284,15 @@ export const make = Effect.gen(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; const changeRequestTerms = wantsPr ? yield* changeRequestTermsFor(input.cwd) : null; + // A stale or missing origin/HEAD hides the default branch from status, so check here + // before anything is committed or pushed to it. + if (changeRequestTerms && !input.featureBranch && initialStatus.branch) { + yield* ensurePrHeadIsNotBase( + input.cwd, + { ...initialStatus, branch: initialStatus.branch }, + changeRequestTerms.shortLabelWithArticle, + ); + } const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index bf89c7cc1baaa..794131444814e 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -37,7 +37,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; @@ -271,13 +271,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/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index eaf3e8b62a9ff..f50b97f1fd77f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -91,15 +91,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, @@ -131,7 +122,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"), @@ -160,6 +159,7 @@ describe("OrchestratorMcpService", () => { return hasNestedWork ? { ...childProjection, + contextTransfers: [], subagents: [ { ...parentProjection.subagents[0]!, status: "running" as const }, ], @@ -169,7 +169,6 @@ describe("OrchestratorMcpService", () => { const snapshot = hasNestedWork ? { ...parentProjection, - contextTransfers: [], subagents: parentProjection.subagents.map((task) => ({ ...task, result: null, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index be36aac1f5877..fa94edd0e87ed 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -584,8 +584,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, ) { @@ -600,18 +601,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< @@ -620,7 +621,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 @@ -969,8 +970,8 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null { 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": @@ -1093,20 +1094,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) => @@ -1117,43 +1109,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) { @@ -1162,8 +1120,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: { @@ -1179,7 +1136,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; }); @@ -1200,7 +1157,6 @@ const make = Effect.gen(function* () { const loadTargetThread = Effect.fn("OrchestratorMcpService.loadTargetThread")(function* ( threadId: ThreadId, - includeHistory = true, ) { const shell = yield* threadManagement .getThreadShell(threadId) @@ -1208,24 +1164,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; }); @@ -1279,7 +1232,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)); @@ -1515,7 +1468,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, @@ -1595,7 +1547,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 && @@ -1609,7 +1561,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 @@ -1678,6 +1630,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, @@ -1763,17 +1716,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, @@ -2030,7 +1973,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"]); yield* requireCheckAccess(input.check, limits); const projectId = yield* resolveProjectTarget(parent, input.projectId); yield* assertLiveCallerForOtherProject(scope, parent, projectId); @@ -2093,7 +2036,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() @@ -2112,7 +2055,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"]); yield* requireCheckAccess(input.check, limits); const existing = yield* loadScheduledTask(input.scheduledTaskId, limits); yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId); @@ -2190,7 +2133,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 @@ -2356,7 +2299,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( @@ -2987,7 +2930,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({ @@ -3033,7 +2976,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); @@ -3137,7 +3080,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 @@ -3150,7 +3093,7 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError(threadManagementFailure)); if ( pageIncludesTerminalTaskResult({ - parent, + parentThreadId: parent.thread.id, page, task, target: { ...target, ...resultRecords }, @@ -3192,7 +3135,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadReadResult; }), sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) { - 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); @@ -3243,7 +3186,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { - 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, @@ -3293,7 +3236,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { - 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); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 5ec8dfd49a17a..bbeac142dfebb 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -216,13 +216,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)), + ), + ), ), ), ); @@ -603,6 +624,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); @@ -611,11 +635,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) ) { @@ -687,6 +729,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) @@ -694,11 +748,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/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bca1d6769cbea..1add2822a01e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9,6 +9,7 @@ import { beforeEach, vi } from "vite-plus/test"; import type { ElicitationRequest, + ElicitationResult, Query as ClaudeQuery, SDKMessage, SDKResultMessage, @@ -55,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"; @@ -2465,7 +2467,7 @@ describe("ClaudeAdapterV2 native fork", () => { Effect.sync(() => { openedQueries.push(input); return { - messages: Stream.empty, + messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, applyFlagSettings: () => Effect.void, @@ -2807,7 +2809,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, @@ -5703,6 +5705,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { options: [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, { decision: "accept", label: "Approve" }, ], }); @@ -5732,16 +5735,41 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - it.effect.each(["acceptForSession", "acceptAlways"] as const)( - "declines an unsupported %s decision", - (decision) => - Effect.gen(function* () { - const { harness, elicit, awaitCard } = yield* startElicitationTurn(); - const answer = yield* elicit(approvalOnly); - const request = yield* awaitCard(1); - yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); - assert.deepEqual(yield* Fiber.join(answer), { action: "decline" }); - }).pipe(Effect.scoped, Effect.provide(testLayer)), + it.effect("declines an unsupported acceptAlways decision", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard } = yield* startElicitationTurn(); + const answer = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "acceptAlways", + }); + assert.deepEqual(yield* Fiber.join(answer), { action: "decline" }); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("answers a repeated prompt from a session grant without a card", () => + Effect.gen(function* () { + const { harness, elicit, awaitCard, runtimeRequests } = yield* startElicitationTurn(); + const first = yield* elicit(approvalOnly); + const request = yield* awaitCard(1); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "acceptForSession", + }); + const granted = { action: "accept", content: {} } satisfies ElicitationResult; + assert.deepEqual(yield* Fiber.join(first), granted); + + assert.deepEqual(yield* Fiber.join(yield* elicit(approvalOnly)), granted); + assert.equal(runtimeRequests().length, 1); + + const otherMessage = yield* elicit({ ...approvalOnly, message: "Deploy api?" }); + yield* awaitCard(2); + const otherServer = yield* elicit({ ...approvalOnly, serverName: "builds" }); + yield* awaitCard(3); + assert.isUndefined(otherMessage.pollUnsafe()); + assert.isUndefined(otherServer.pollUnsafe()); + }).pipe(Effect.scoped, Effect.provide(testLayer)), ); it.effect("declines URL and value forms without a card", () => @@ -10952,6 +10980,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", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5c71e79d98278..7b63b7efc0b65 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3825,11 +3825,12 @@ export const awaitClaudeApprovalDecision = Effect.fn("awaitClaudeApprovalDecisio return yield* Effect.raceFirst(Deferred.await(decision), cancellation); }); -// Web and mobile offer a session grant when options are omitted, and this -// path only ever answers once. +// MCP servers such as Computer Use ask on every call and expect the client to +// remember a session grant, as Codex does. The CLI drops a reply's `_meta.persist`. const CLAUDE_MCP_ELICITATION_OPTIONS: ReadonlyArray = [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, { decision: "accept", label: "Approve" }, ]; @@ -5212,6 +5213,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); + const mcpElicitationSessionGrants = yield* Ref.make(new Set()); // Background local_bash tasks can trigger wake turns on the query stream with no active provider turn. const lastTurnRouteByNativeThread = yield* Ref.make( new Map< @@ -10620,6 +10622,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); return { action: "decline" } satisfies ElicitationResult; } + const grantKey = `${request.serverName}\u0000${request.message}`; + if ((yield* Ref.get(mcpElicitationSessionGrants)).has(grantKey)) { + return acceptance; + } // The CLI waits on this answer, so a turn held for its prompt echo // must show its output now, as canUseTool does. if (context.heldRootFrames.length > 0) { @@ -10706,8 +10712,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt: now }, }); } + if (resolved === "acceptForSession") { + yield* Ref.update(mcpElicitationSessionGrants, (current) => + new Set(current).add(grantKey), + ); + return acceptance; + } // Never null: the SDK reads null as answered out of band and leaves - // the server waiting. Every accept is one-time; no grant is offered. + // the server waiting. return resolved === "accept" ? acceptance : ({ @@ -11299,8 +11311,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( executedModel: undefined, }; const attachTurn = Effect.gen(function* () { - querySession.lastTurn = context; + // Set before claiming: a query that exits after the claim finalizes this turn; one that exits before it fails the start. yield* Ref.set(activeTurn, context); + const attached = yield* Ref.modify(queryContext, (current) => { + if (current?.query !== querySession.query || querySession.stopping) { + return [false, current]; + } + querySession.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) === @@ -11377,15 +11403,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Transcript reads suspend replay; hold SDK frames until the buffer is applied to preserve order. yield* sdkMessageLock.withPermits(1)( Effect.gen(function* () { - if ( - (yield* Ref.get(queryContext))?.query !== querySession.query || - querySession.stopping - ) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CLAUDE_PROVIDER, - detail: "Claude provider query ended before the continuation could start.", - }); - } yield* attachTurn; const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; yield* Ref.update(requestedContinuations, (current) => { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 55a1e66ee7254..e8e4bf37d4794 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -4116,6 +4116,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { id: `image-${index}`, status: "completed", result, + revisedPrompt: "A tree shaped like T3", + transparentBackground: true, ...image, }, }, @@ -4136,6 +4138,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); @@ -4179,6 +4217,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` } : {}), @@ -4249,6 +4289,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); @@ -5774,8 +5839,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", @@ -5891,6 +5963,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: [ @@ -5915,6 +6018,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, ] : []), + ...(readsReset ? [premium] : []), ...(scenario.name === "deferred-reset" ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ @@ -5999,7 +6103,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" ? [ { @@ -6038,17 +6171,24 @@ 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, - undefined, + 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, + // 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( @@ -6066,7 +6206,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 997547cdb3309..66bc21d6030f2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1981,7 +1981,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 }; @@ -1990,7 +1996,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; @@ -2075,6 +2081,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); }); @@ -3926,6 +3945,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 @@ -4567,6 +4636,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( @@ -4960,7 +5049,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; @@ -5246,6 +5339,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) { @@ -5916,7 +6010,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); - const emitRootTerminal = Effect.fnUntraced(function* ( + const publishRootTerminal = Effect.fnUntraced(function* ( context: ActiveCodexTurnContext, event: CodexRootTerminalEvent, ) { @@ -5933,8 +6027,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) => @@ -5950,20 +6074,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 }), ); } }); @@ -6139,7 +6256,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 64ed215ebed5c..a5c123157bae1 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -1055,6 +1055,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 }, @@ -1380,6 +1447,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 }, @@ -1623,6 +1829,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))( @@ -2238,7 +2575,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: [], diff --git a/apps/server/src/orchestration-v2/NotificationMailbox.ts b/apps/server/src/orchestration-v2/NotificationMailbox.ts index 29ba40c5777fe..79458677c1af0 100644 --- a/apps/server/src/orchestration-v2/NotificationMailbox.ts +++ b/apps/server/src/orchestration-v2/NotificationMailbox.ts @@ -6,7 +6,10 @@ import { } from "@t3tools/contracts"; export function isBufferedProviderNotification( - message: OrchestrationV2ConversationMessage, + message: Pick< + OrchestrationV2ConversationMessage, + "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + >, ): boolean { return ( message.createdBy === "agent" && diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 9f22aa28c3ba2..07c1a544798be 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -833,7 +833,12 @@ function nextQueuedRun( } function followsCurrentConversation( - message: OrchestrationV2ConversationMessage | undefined, + message: + | Pick< + OrchestrationV2ConversationMessage, + "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + > + | undefined, ): boolean { return ( message?.delegatedCompletion !== undefined || @@ -851,6 +856,20 @@ function queuedRunModelSelection( : run.modelSelection; } +/** The selection the thread will be on once every queued run has started. */ +function queueTailModelSelection( + projection: Pick, +): ModelSelection { + return ( + queuedRunsInDeliveryOrder(projection).findLast( + (run) => + !followsCurrentConversation( + projection.messages.find((message) => message.id === run.userMessageId), + ), + )?.modelSelection ?? projection.thread.modelSelection + ); +} + function usageLimitHoldsQueue( projection: Pick, providerInstanceId: ModelSelection["instanceId"], @@ -1082,7 +1101,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( @@ -1149,9 +1174,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; }); @@ -1581,6 +1609,104 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + /** Marks a wake the provider received delivered and reserves the next one for pending siblings. */ + const acceptDelegatedCompletionDelivery = Effect.fn( + "Orchestrator.acceptDelegatedCompletionDelivery", + )(function* (input: { + readonly command: OrchestrationV2ServerCommand; + readonly events: Ref.Ref>; + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "runs" | "messages" | "subagents" + >; + readonly messageId: OrchestrationV2ConversationMessage["id"]; + readonly now: DateTime.Utc; + }) { + const { projection, now } = input; + const message = projection.messages.find((row) => row.id === input.messageId); + const ownership = message?.delegatedCompletion; + const parentRun = projection.runs.find((run) => run.id === ownership?.parentRunId); + const cohort = parentRun?.delegatedCompletion; + const delivery = cohort?.delivery; + if ( + parentRun === undefined || + cohort === undefined || + cohort.disposition !== "open" || + delivery == null || + delivery.messageId !== input.messageId || + delivery.generation !== ownership?.generation + ) { + return undefined; + } + const threadId = projection.thread.id; + const parentIsLive = hasLiveRun({ runs: [parentRun] }); + const pendingTaskIds = + projection.thread.archivedAt === null && projection.thread.deletedAt === null + ? projection.subagents + .filter( + (task) => + task.origin === "app_owned" && + task.runId === parentRun.id && + task.completionDelivery?.state === "pending" && + isTerminalDelegatedTaskStatus(task.status) && + (!parentIsLive || task.completionWake === "always"), + ) + .map((task) => task.id) + : []; + const nextDelivery = + pendingTaskIds.length === 0 + ? null + : { + generation: cohort.nextGeneration, + messageId: yield* mapDelegatedCompletionError( + idAllocator.allocate.message({ + threadId, + ordinal: + (yield* mapDelegatedCompletionError(projectionStore.getMessageCount(threadId))) + + 1, + }), + ), + taskIds: pendingTaskIds, + }; + const emitEvent = emit(input.events, input.command); + const acceptedIds = new Set(delivery.taskIds); + const pendingIds = new Set(pendingTaskIds); + for (const task of projection.subagents) { + const state = pendingIds.has(task.id) + ? "claimed" + : acceptedIds.has(task.id) && task.completionDelivery?.state === "claimed" + ? "delivered" + : undefined; + if (state === undefined) continue; + yield* emitEvent({ + type: "subagent.updated", + threadId, + runId: parentRun.id, + nodeId: task.id, + driver: task.driver, + providerInstanceId: task.providerInstanceId, + occurredAt: now, + payload: { ...task, completionDelivery: { state, observedByRunId: null }, updatedAt: now }, + }); + } + yield* emitEvent({ + type: "run.updated", + threadId, + runId: parentRun.id, + providerInstanceId: parentRun.providerInstanceId, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: { + ...cohort, + nextGeneration: cohort.nextGeneration + (nextDelivery === null ? 0 : 1), + delivery: nextDelivery, + }, + }, + }); + return parentRun.id; + }); + const emitQueuedRunCancellation = Effect.fn("Orchestrator.emitQueuedRunCancellation")( function* (input: { readonly command: OrchestrationV2ServerCommand; @@ -2666,21 +2792,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", ); @@ -2697,6 +2811,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 ( @@ -2704,23 +2823,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, @@ -2790,10 +2900,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 ( @@ -2808,17 +2914,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 = @@ -2848,10 +2952,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, }); @@ -2859,6 +2973,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, @@ -2868,7 +2987,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, @@ -2895,8 +3014,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); @@ -3635,6 +3754,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio runId: run.id, }, events, + "cohort", ); } } @@ -5679,6 +5799,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: checkpointScope, }); + // The restart replaces the run's input, so the wake it was running can no longer be + // settled at its terminal. Its provider turn was running, so the wake already landed. + yield* acceptDelegatedCompletionDelivery({ + command: input.command, + events: input.events, + projection: input.projection, + messageId: targetRun.userMessageId, + now, + }); yield* appendSteeringMessage({ runId: targetRun.id, nodeId: nextRootNodeId, @@ -5932,13 +6061,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); projection = applyToProjection(projection, event); } - const modelSelection = command.modelSelection ?? projection.thread.modelSelection; + let modelSelection = command.modelSelection ?? projection.thread.modelSelection; let dispatchMode = resolveMessageDispatchIntent( projection, 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( @@ -5950,9 +6079,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" }; } @@ -5960,7 +6093,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") && @@ -5968,6 +6101,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 && @@ -5975,16 +6116,17 @@ 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) { - yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); // 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 // that asked to be queued (no delivery intent) only queues, and a run @@ -6009,7 +6151,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" }; } @@ -6069,6 +6211,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. @@ -6168,6 +6341,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { + yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); yield* dispatchSteerIntoRun({ command, events, @@ -6220,6 +6394,20 @@ 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 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 && + queuedRunStop === undefined && + !followsCurrentConversation(incomingMessage) && + !isBufferedProviderNotification(incomingMessage) + ) { + modelSelection = queueTailModelSelection(projection); + } + yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); // Client messages resume held queues; server continuations and scheduled tasks do not. // Provider failure or usage limits re-hold the queue. const worktreeSetupUnresolved = worktreeSetupPending(projection); @@ -6367,11 +6555,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, @@ -6529,15 +6720,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, }); } @@ -8331,12 +8522,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; @@ -10149,6 +10342,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, @@ -10166,8 +10361,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, @@ -10175,64 +10370,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); @@ -13314,95 +13459,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events: Ref.Ref>, ) { const projection = yield* getProjectionWithPendingEvents(command.threadId, events); - const message = projection.messages.find((row) => row.id === command.messageId); - const ownership = message?.delegatedCompletion; - const parentRun = projection.runs.find((run) => run.id === ownership?.parentRunId); - const cohort = parentRun?.delegatedCompletion; - const delivery = cohort?.delivery; const now = yield* DateTime.now; - const emitEvent = emit(events, command); - if ( - parentRun === undefined || - cohort === undefined || - cohort.disposition !== "open" || - delivery == null || - delivery.messageId !== command.messageId || - delivery.generation !== ownership?.generation - ) { - yield* emitEvent({ + const parentRunId = yield* acceptDelegatedCompletionDelivery({ + command, + events, + projection, + messageId: command.messageId, + now, + }); + if (parentRunId === undefined) { + yield* emit( + events, + command, + )({ type: "thread.metadata-updated", threadId: command.threadId, occurredAt: now, payload: projection.thread, }); - return; } - const parentIsLive = hasLiveRun({ runs: [parentRun] }); - const pendingTaskIds = - projection.thread.archivedAt === null && projection.thread.deletedAt === null - ? projection.subagents - .filter( - (task) => - task.origin === "app_owned" && - task.runId === parentRun.id && - task.completionDelivery?.state === "pending" && - isTerminalDelegatedTaskStatus(task.status) && - (!parentIsLive || task.completionWake === "always"), - ) - .map((task) => task.id) - : []; - const nextDelivery = - pendingTaskIds.length === 0 - ? null - : { - generation: cohort.nextGeneration, - messageId: yield* mapDelegatedCompletionError( - idAllocator.allocate.message({ - threadId: command.threadId, - ordinal: - (yield* mapDelegatedCompletionError( - projectionStore.getMessageCount(projection.thread.id), - )) + 1, - }), - ), - taskIds: pendingTaskIds, - }; - const acceptedIds = new Set(delivery.taskIds); - const pendingIds = new Set(pendingTaskIds); - for (const task of projection.subagents) { - const state = pendingIds.has(task.id) - ? "claimed" - : acceptedIds.has(task.id) && task.completionDelivery?.state === "claimed" - ? "delivered" - : undefined; - if (state === undefined) continue; - yield* emitEvent({ - type: "subagent.updated", - threadId: command.threadId, - runId: parentRun.id, - nodeId: task.id, - driver: task.driver, - providerInstanceId: task.providerInstanceId, - occurredAt: now, - payload: { ...task, completionDelivery: { state, observedByRunId: null }, updatedAt: now }, - }); - } - yield* emitEvent({ - type: "run.updated", - threadId: command.threadId, - runId: parentRun.id, - providerInstanceId: parentRun.providerInstanceId, - occurredAt: now, - payload: { - ...parentRun, - delegatedCompletion: { - ...cohort, - nextGeneration: cohort.nextGeneration + (nextDelivery === null ? 0 : 1), - delivery: nextDelivery, - }, - }, - }); - return parentRun.id; + return parentRunId; }); const dispatchUnsupported = (command: OrchestrationV2ServerCommand) => @@ -14092,7 +14168,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); } - if (command.type === "notification.delivery.accept") { + if ( + command.type === "notification.delivery.accept" || + // A steer restart that settled a wake can reserve the next one for its siblings. + ((command.type === "message.dispatch" || + command.type === "queued-message.promote-to-steer") && + committed.storedEvents.some( + ({ event }) => + event.type === "subagent.updated" && + event.payload.completionDelivery?.state === "claimed", + )) + ) { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.threadId)); } if ( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 1ca7eb67036fa..40b7709870585 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, @@ -685,6 +694,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"], @@ -755,6 +811,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"], { @@ -771,6 +844,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; @@ -2964,7 +3136,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 })} ) `; @@ -3356,6 +3528,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') @@ -3517,7 +3820,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { activeAttemptId: null, status: "cancelled", requestedAt: nowIso, - startedAt: nowIso, + startedAt: null, completedAt: nowIso, checkpointId: null, contextHandoffId: null, @@ -3646,6 +3949,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"); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3d33f76b7a494..0c4976dc90598 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -101,6 +101,7 @@ import { isConversationHistoryItem, THREAD_HISTORY_MAX_RAW_TURNS, missingInterruptRequests, + DEFERRED_WORK_ITEM_TYPES, deferWorkHistoryRows, selectDeferredWorkPage, encodeThreadHistoryCursor, @@ -344,6 +345,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; @@ -1030,6 +1032,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; @@ -1041,6 +1044,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; @@ -1656,6 +1660,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, @@ -1751,6 +1757,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"; } @@ -1773,6 +1798,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; @@ -1933,6 +1959,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, @@ -3032,6 +3059,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 @@ -3039,7 +3108,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)) @@ -3048,14 +3117,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 @@ -3090,6 +3159,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) @@ -3123,6 +3193,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, @@ -3356,21 +3431,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 @@ -3378,6 +3458,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 = ( @@ -3414,7 +3500,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 @@ -3453,7 +3539,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)}))`} ${ @@ -3588,7 +3674,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``} @@ -3748,10 +3834,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') `; @@ -6192,6 +6281,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, @@ -6225,6 +6346,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, ( @@ -6282,6 +6404,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 @@ -6359,19 +6482,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 @@ -6865,6 +6977,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, @@ -6878,6 +6991,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 @@ -6918,6 +7032,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: @@ -7188,6 +7303,7 @@ export const layer: Layer.Layer = activityRunStatus: state.activityRunStatus, latestRunCompletedAt: state.latestRunCompletedAt, latestRunStopRequested: state.latestRunStopRequested, + latestRunBackgroundWake: state.latestRunBackgroundWake, pendingRuntimeRequest: state.pendingRuntimeRequest, pendingBackgroundTasks: state.pendingBackgroundTasks, })), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index b6adf6371fb60..520fea7c0575b 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1642,6 +1642,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 40a858bbeb79f..623fddb491108 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -616,6 +616,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/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index a8e2e84ba74c5..25ae18f7b6690 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3599,89 +3599,266 @@ it.effect.each(["manual_shutdown", "server_shutdown"] as const)( }), ); -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 cancels a pending approval when a detach releases its session", + () => + 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-detach-request", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-detach-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, + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + yield* eventSink.write({ events: pendingRequest.events }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); + + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + assert.equal(request?.status, "cancelled"); + assert.equal(request?.responseCapability.type, "not_resumable"); }); - 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", + + yield* effect.pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, capabilities: ExclusiveCapabilities }), ), - 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)); + }), +); - 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"); - }); +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* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); - }), + 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, + ); + 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 }))); + }), ); it.effect( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 62fdda69e5111..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 @@ -665,12 +671,23 @@ export const layerWithOptions = ( }); }); + // A detach removes its thread before releasing, but that thread's records still need releasing. + const releasedThreadIds = (input: { + readonly entry: LiveSessionEntry; + readonly detachedThreadId?: ThreadId; + }) => { + const threadIds = new Set(input.entry.attachedThreadIds); + if (input.detachedThreadId !== undefined) threadIds.add(input.detachedThreadId); + return threadIds; + }; + const writeReleasedRuntimeRequestEvents = Effect.fn( "ProviderSessionManager.writeReleasedRuntimeRequestEvents", )(function* (input: { readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; readonly releasedAt: DateTime.Utc; + readonly detachedThreadId?: ThreadId; }) { const providerSessionId = input.entry.runtime.providerSessionId; const now = yield* DateTime.now; @@ -681,7 +698,7 @@ export const layerWithOptions = ( : "Provider session was closed before this runtime request was resolved."; const events: Array = []; - for (const threadId of input.entry.attachedThreadIds) { + for (const threadId of releasedThreadIds(input)) { const projection = yield* projectionStore.getThreadRecords( threadId, ["runtimeRequests"], @@ -800,10 +817,8 @@ export const layerWithOptions = ( return []; } const providerSessionId = input.entry.runtime.providerSessionId; - const threadIds = new Set(input.entry.attachedThreadIds); - if (input.detachedThreadId !== undefined) threadIds.add(input.detachedThreadId); const released = []; - for (const threadId of threadIds) { + for (const threadId of releasedThreadIds(input)) { const projection = yield* projectionStore.getThreadRecords(threadId, [ "providerThreads", ]); @@ -892,6 +907,7 @@ export const layerWithOptions = ( readonly detail?: string; readonly releasedBackgroundTasks: ReadonlyArray; readonly releasedAt: DateTime.Utc; + readonly detachedThreadId?: ThreadId; readonly replaced: boolean; }) => Effect.all( @@ -1068,12 +1084,14 @@ export const layerWithOptions = ( onNone: () => Effect.void, onSome: (entry) => Effect.gen(function* () { + const detachedThread = + input.detachedThreadId === undefined + ? {} + : { detachedThreadId: input.detachedThreadId }; const releasedBackgroundTasks = yield* readReleasedBackgroundTasks({ entry, reason: input.reason, - ...(input.detachedThreadId === undefined - ? {} - : { detachedThreadId: input.detachedThreadId }), + ...detachedThread, }); if (input.cancelIdleFiber !== false) { yield* cancelIdleFiber(entry.idleFiber); @@ -1098,6 +1116,7 @@ export const layerWithOptions = ( ...(input.detail === undefined ? {} : { detail: input.detail }), releasedBackgroundTasks, releasedAt, + ...detachedThread, }; const recorded = yield* Effect.exit( writeReleaseRecords({ ...records, replaced: false }), @@ -2261,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, @@ -2290,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/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d1b5168258874..7c58e6ad079e8 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; @@ -337,7 +338,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, @@ -778,7 +779,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, @@ -1338,6 +1345,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" }, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 54b16d7aa2aef..98652036118fc 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -841,8 +841,12 @@ export const layer: Layer.Layer< text: message.text, records: message.context?.records ?? [], }); - // 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/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 07811415fa312..e981ab7362d2d 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -488,6 +488,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 2c311f2569753..e06118769a5f5 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { ChatAttachmentId, CommandId, + EnvironmentId, MessageId, EventId, NodeId, @@ -19,6 +20,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -27,8 +29,13 @@ 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"; const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); @@ -485,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[] = []; @@ -498,6 +507,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* supportsActiveSteering: canSteer, supportsSteeringByInterruptRestart: canRestart, activeSteeringAcceptsAttachments: steeringAcceptsAttachments, + ...(nativeGoals === undefined ? {} : { supportsNativeGoals: nativeGoals }), }, }; const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { @@ -1215,6 +1225,301 @@ it.effect("queues a steer until the turn ends when the provider cannot steer it" ), ); +// regression: #33454 a steer that restarts a completion wake run left its cohort claimed forever +const restartCompletionWake = Effect.fn("restartCompletionWake")(function* ( + harness: Pick< + Effect.Success>, + "events" | "started" | "startFirstTurn" + >, + siblingFinishesFirst: boolean, +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = yield* harness.startFirstTurn; + const watchRun = (runId: string, status: string) => + orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === runId && + event.payload.status === status, + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const watchTurnRunning = (ordinal: number) => + orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && + event.payload.status === "running" && + event.payload.ordinal === ordinal, + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const finishTurn = (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => + Effect.gen(function* () { + const providerTurn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns.find( + (candidate) => candidate.runAttemptId === turn.attemptId, + )!; + const settled = yield* watchRun(turn.runId, "waiting"); + yield* Queue.offer(harness.events, { + type: "provider_turn.updated", + driver, + providerTurn: { + ...providerTurn, + status: "completed", + completedAt: yield* DateTime.now, + }, + }); + yield* Queue.offer(harness.events, { + type: "turn.terminal", + driver, + providerThreadId: providerTurn.providerThreadId, + providerTurnId: providerTurn.id, + runOrdinal: turn.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + }); + + const origin = harness.started[0]!; + const firstTaskId = NodeId.make("task:steered-wake-first"); + const siblingTaskId = NodeId.make("task:steered-wake-sibling"); + const now = yield* DateTime.now; + yield* sink.write({ + events: [firstTaskId, siblingTaskId].map((taskId) => ({ + id: EventId.make(`event:seed:${taskId}`), + type: "subagent.updated" as const, + threadId, + runId: origin.runId, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: origin.runId, + parentNodeId: origin.rootNodeId, + origin: "app_owned" as const, + createdBy: "agent" as const, + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + remoteChild: { + environmentId: EnvironmentId.make("environment:box"), + threadId: ThreadId.make(`thread:remote:${taskId}`), + label: "box", + }, + nativeTaskRef: null, + prompt: "Do background work", + title: null, + model: null, + completionWake: "always" as const, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + status: "running" as const, + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + })), + }); + yield* finishTurn(origin); + + const completeTask = (taskId: NodeId) => + orchestrator.dispatch({ + type: "delegated_task.remote.complete", + commandId: CommandId.make(`command:remote-complete:${taskId}`), + parentThreadId: threadId, + taskId, + status: "completed", + result: `${taskId} finished`, + stoppedThere: "ran-out", + }); + yield* completeTask(firstTaskId); + const firstDelivery = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.id === origin.runId, + )!.delegatedCompletion!.delivery!; + assert.deepEqual(firstDelivery.taskIds, [firstTaskId]); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:first-wake"), + threadId, + messageId: firstDelivery.messageId, + text: "Delegated task completed", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "queue_after_active" }, + delegatedCompletion: { + parentRunId: origin.runId, + generation: firstDelivery.generation, + taskIds: firstDelivery.taskIds, + }, + createdBy: "agent", + creationSource: "server", + }); + const wakeRunning = yield* watchTurnRunning(2); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + yield* Fiber.join(wakeRunning); + const wake = harness.started[1]!; + assert.equal(wake.message.messageId, firstDelivery.messageId); + + if (siblingFinishesFirst) { + yield* completeTask(siblingTaskId); + } + + // The provider cannot steer natively, so the steer restarts the wake run. + const steeredRunning = yield* watchTurnRunning(3); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:steer-wake"), + threadId, + messageId: MessageId.make("message:steer-wake"), + text: "also check the logs", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "steer_active", targetRunId: wake.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(steeredRunning); + const steered = harness.started[2]!; + assert.equal(steered.runId, wake.runId); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).attempts.find( + (attempt) => attempt.id === steered.attemptId, + )?.reason, + "steering_restart", + ); + return { + threadId, + origin, + wake, + steered, + firstTaskId, + siblingTaskId, + watchRun, + finishTurn, + completeTask, + }; +}); + +it.effect("wakes the parent for a sibling result after a steer restarts the completion wake", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness("steered-completion-wake", true, false); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { + threadId, + origin, + wake, + steered, + firstTaskId, + siblingTaskId, + watchRun, + finishTurn, + completeTask, + } = yield* restartCompletionWake(harness, false); + + // A follow-up queued behind the wake starts once the terminal-run listener has settled it. + const followUp = MessageId.make("message:follow-up"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:follow-up"), + threadId, + messageId: followUp, + text: "follow up", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const followUpRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === followUp, + )!; + const followUpStarted = yield* watchRun(followUpRun.id, "starting"); + yield* finishTurn(steered); + yield* Fiber.join(followUpStarted); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.id === wake.runId, + )?.status, + "completed", + ); + + yield* completeTask(siblingTaskId); + const final = yield* orchestrator.getThreadProjection(threadId); + const tasks = new Map( + final.subagents.map((task) => [task.id, task.completionDelivery?.state]), + ); + assert.deepInclude( + final.runs.find((run) => run.id === origin.runId)?.delegatedCompletion?.delivery ?? {}, + { generation: 2, taskIds: [siblingTaskId] }, + "the sibling finished after the steered wake but no new completion wake was reserved", + ); + assert.equal(tasks.get(siblingTaskId), "claimed"); + assert.equal( + tasks.get(firstTaskId), + "delivered", + "the steered wake run's own task stays claimed after that run completed", + ); + }).pipe(Effect.provide(harness.layer)); + }), + ), +); + +it.effect( + "offers the next completion wake when a steer restarts a wake a sibling finished behind", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness( + "steered-completion-wake-behind", + true, + false, + ); + const offers: ProviderContinuationRequests.ProviderContinuationRequest[] = []; + const requests = Layer.succeed(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => Effect.sync(() => void offers.push(request)), + take: Effect.never, + }); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, origin, firstTaskId, siblingTaskId } = yield* restartCompletionWake( + harness, + true, + ); + const projection = yield* orchestrator.getThreadProjection(threadId); + const delivery = projection.runs.find((run) => run.id === origin.runId) + ?.delegatedCompletion?.delivery; + assert.deepInclude(delivery ?? {}, { generation: 2, taskIds: [siblingTaskId] }); + const tasks = new Map( + projection.subagents.map((task) => [task.id, task.completionDelivery?.state]), + ); + assert.equal(tasks.get(firstTaskId), "delivered"); + assert.equal(tasks.get(siblingTaskId), "claimed"); + assert.deepEqual(offers.at(-1)?.delegatedCompletion, { + parentRunId: origin.runId, + generation: 2, + messageId: delivery!.messageId, + }); + }).pipe(Effect.provide(harness.layer.pipe(Layer.provide(requests)))); + }), + ), +); + // Codex flushes held drafts after the final answer; reserve their pre-steer position before turn end. it.effect("keeps a held commentary draft before the steer when turn end flushes it", () => Effect.scoped( @@ -1475,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* () { @@ -1543,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; @@ -1584,21 +2115,105 @@ 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", () => +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: 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", () => + Effect.scoped( + Effect.gen(function* () { + const { started, layer, startFirstTurn, read } = yield* goalHarness("goal-ahead-of-pick"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + for (const [id, text, modelSelection] of [ + ["picked", "picked", composerSelection], + ["goal", "/goal all tests pass", undefined], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + ...(modelSelection === undefined ? {} : { modelSelection }), + dispatchMode: + id === "picked" ? { type: "queue_after_active" } : { type: "start_immediately" }, + ...(id === "picked" ? {} : { deliveryIntent: "auto" as const }), + createdBy: "user", + creationSource: "web", + }); + } + yield* worker.drain(); + + assert.deepEqual(yield* read(threadId), { + queue: ["picked"], + started: ["first", "/goal all tests pass"], + stopRequested: true, + }); + assert.deepEqual(started[1]?.modelSelection, runSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("restarts by stopping the running turn when the provider cannot interrupt-restart", () => Effect.scoped( Effect.gen(function* () { - const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-steer"); + 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* send("goal", "/goal all tests pass", "auto"); + 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", "/goal all tests pass"], + started: ["first", "do this instead"], stopRequested: true, }); }).pipe(Effect.provide(layer)); @@ -1606,6 +2221,111 @@ it.effect("stops the running turn and starts a goal sent into it next", () => ), ); +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 fb7a2ffc2d897..bfa50b9793490 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -197,6 +197,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.die("unused"), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7e7829ba947cf..6397bfdb3bc72 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -361,6 +361,7 @@ const make = Effect.gen(function* () { attachments: message.attachments, ...(message.context ? { context: message.context } : {}), modelSelection, + projectId: input.projectId, }) .pipe( Effect.map((result) => ({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 085be554c0c2e..81d2523890727 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -504,13 +504,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( @@ -892,8 +894,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" 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/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/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 077980acd2eec..a49e323770cc1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -162,17 +162,35 @@ const alternateProviderInstance = { instanceId: alternateInstanceId, }, } satisfies ProviderInstance; +const claudeInstanceId = ProviderInstanceId.make("claude_test"); +const claudeDriver = ProviderDriverKind.make("claudeAgent"); +const claudeProviderInstance = { + ...providerInstance, + instanceId: claudeInstanceId, + driverKind: claudeDriver, + continuationIdentity: { driverKind: claudeDriver, continuationKey: "claude:test" }, + displayName: "Claude test", + orchestrationAdapter: { + ...orchestrationAdapter, + instanceId: claudeInstanceId, + driver: claudeDriver, + }, +} satisfies ProviderInstance; const layerTestProviderInstanceRegistry = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (instanceId) => Effect.succeed( - [providerInstance, alternateProviderInstance].find( + [providerInstance, alternateProviderInstance, claudeProviderInstance].find( (instance) => instanceId === instance.instanceId, ), ), - listInstances: Effect.succeed([providerInstance, alternateProviderInstance]), + listInstances: Effect.succeed([ + providerInstance, + alternateProviderInstance, + claudeProviderInstance, + ]), listUnavailable: Effect.succeed([]), streamChanges: Stream.empty, subscribeChanges: Effect.never, @@ -6157,6 +6175,220 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("keeps a model picked ahead of a queued agent message that names none", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-queued-agent-message-model-pick"); + const pickedSelection = { ...modelSelection, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Queued model pick", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [key, selection] of [ + ["active", modelSelection], + ["picked", pickedSelection], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text: key, + attachments: [], + modelSelection: selection, + dispatchMode: { type: key === "active" ? "start_immediately" : "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + // What t3_thread_send dispatches: no model of its own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:agent`), + threadId, + messageId: MessageId.make(`${threadId}:agent`), + text: "Message from another thread", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "mcp", + }); + const [active, picked, agent] = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.isDefined(active); + assert.isDefined(picked); + assert.isDefined(agent); + const now = yield* DateTime.now; + for (const runId of [active.id, picked.id]) { + const current = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${runId}:completed`), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { + ...current.runs.find((run) => run.id === runId)!, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + } + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.id === agent.id)!; + assert.equal(delivered.status, "starting"); + assert.deepEqual(delivered.modelSelection, pickedSelection); + assert.deepEqual(after.thread.modelSelection, pickedSelection); + }), + ); + + it.effect("keeps a buffered provider wake on its own provider behind a queued pick", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-buffered-wake-behind-queued-pick"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Buffered wake behind a pick", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [key, selection] of [ + ["active", modelSelection], + ["picked", { ...modelSelection, instanceId: alternateInstanceId }], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text: key, + attachments: [], + modelSelection: selection, + dispatchMode: { type: key === "active" ? "start_immediately" : "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:wake`), + threadId, + messageId: MessageId.make(`${threadId}:wake`), + text: "Background activity updated", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "provider", + notification: { + source: { kind: "background_task" }, + outcome: "updated", + summary: "Background activity updated", + }, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const active = projection.runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:active`), + ); + const wake = projection.runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:wake`), + ); + assert.deepEqual(wake?.modelSelection, modelSelection); + assert.equal(wake?.providerThreadId, active?.providerThreadId); + }), + ); + + it.effect("checks a queued goal pause against the model picked ahead of it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-goal-pause-behind-queued-pick"); + const claudeSelection = { ...modelSelection, instanceId: claudeInstanceId }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Goal pause behind a pick", + modelSelection: claudeSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [key, selection] of [ + ["active", claudeSelection], + ["picked", modelSelection], + ] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text: key, + attachments: [], + modelSelection: selection, + dispatchMode: { type: key === "active" ? "start_immediately" : "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + const sendGoal = (key: string, text: string, deliveryIntent?: "auto") => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:${key}`), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + ...(deliveryIntent === undefined ? {} : { deliveryIntent }), + createdBy: "agent", + creationSource: "mcp", + }); + // It runs after the pick, on a provider that can pause goals. + yield* sendGoal("pause", "/goal pause"); + // A resume stops the active run and starts before the pick, so the thread's + // Claude instance still refuses it. + const sequence = yield* orchestrator.getThreadEventSequence(threadId); + const refused = yield* Effect.flip(sendGoal("resume", "/goal resume", "auto")); + assert.instanceOf(refused, Orchestrator.OrchestratorDispatchError); + assert.equal( + refused.cause, + "Claude goals cannot be paused or resumed. Send /goal clear to remove the goal.", + ); + assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence); + + const pause = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:pause`), + ); + assert.deepEqual(pause?.modelSelection, modelSelection); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 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/kiro_model_switch/kiro_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/kiro_model_switch/kiro_transcript.ndjson index 7e1ea7301c2c3..b7a821f4ad9b9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/kiro_model_switch/kiro_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/kiro_model_switch/kiro_transcript.ndjson @@ -28,7 +28,7 @@ {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","configId":"model","value":"claude-sonnet-4.5"}}} {"type":"emit_inbound","label":"notification:session/update:config_option_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"config_option_update","configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"model","name":"Model","category":"model","currentValue":"claude-sonnet-4.5","options":[{"value":"auto","name":"Auto","description":"Models chosen by task for optimal usage and consistent quality","_meta":{"kiro":{"rateMultiplier":1,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-opus-5.5","name":"Claude Opus 5.5","description":"Experimental preview of Claude Opus 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"medium","thinkingToggleable":false}}},{"value":"claude-sonnet-5.5","name":"Claude Sonnet 5.5","description":"Claude Sonnet 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-5","name":"Claude Opus 5","description":"Claude Opus 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-5","name":"Claude Sonnet 5","description":"Claude Sonnet 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.8","name":"Claude Opus 4.8","description":"Claude Opus 4.8 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"gpt-5.6-sol","name":"GPT 5.6 Sol","description":"Experimental preview of OpenAI GPT 5.6 Sol with 1M context window","_meta":{"kiro":{"rateMultiplier":4.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-terra","name":"GPT 5.6 Terra","description":"Experimental preview of OpenAI GPT 5.6 Terra with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-luna","name":"GPT 5.6 Luna","description":"Experimental preview of OpenAI GPT 5.6 Luna with 1M context window","_meta":{"kiro":{"rateMultiplier":0.6,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-4.7","name":"Claude Opus 4.7","description":"Claude Opus 4.7 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"xhigh","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.6","name":"Claude Opus 4.6","description":"Claude Opus 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-4.6","name":"Claude Sonnet 4.6","description":"Claude Sonnet 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.5","name":"Claude Opus 4.5","description":"Claude Opus 4.5 model","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4.5","name":"Claude Sonnet 4.5","description":"Claude Sonnet 4.5 model","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-haiku-4.5","name":"Claude Haiku 4.5","description":"The latest Claude Haiku model","_meta":{"kiro":{"rateMultiplier":0.4,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"deepseek-3.2","name":"Deepseek v3.2","description":"Experimental preview of DeepSeek V3.2","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.5","name":"MiniMax M2.5","description":"MiniMax M2.5 model","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.1","name":"MiniMax M2.1","description":"Experimental preview of MiniMax M2.1","_meta":{"kiro":{"rateMultiplier":0.15,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"glm-5","name":"GLM 5","description":"GLM-5 model","_meta":{"kiro":{"rateMultiplier":0.5,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"qwen3-coder-next","name":"Qwen3 Coder Next","description":"Experimental preview of Qwen3 Coder Next","_meta":{"kiro":{"rateMultiplier":0.05,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"kimi-k3","name":"Kimi K3","description":"Experimental preview of Kimi K3. Kimi K3 is a Kiro Excluded Model. For more info, see documentation.","_meta":{"kiro":{"rateMultiplier":2.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["low","high","max"],"defaultEffortLevel":"max","thinkingToggleable":false}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}}} {"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"model","name":"Model","category":"model","currentValue":"claude-sonnet-4.5","options":[{"value":"auto","name":"Auto","description":"Models chosen by task for optimal usage and consistent quality","_meta":{"kiro":{"rateMultiplier":1,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-opus-5.5","name":"Claude Opus 5.5","description":"Experimental preview of Claude Opus 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"medium","thinkingToggleable":false}}},{"value":"claude-sonnet-5.5","name":"Claude Sonnet 5.5","description":"Claude Sonnet 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-5","name":"Claude Opus 5","description":"Claude Opus 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-5","name":"Claude Sonnet 5","description":"Claude Sonnet 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.8","name":"Claude Opus 4.8","description":"Claude Opus 4.8 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"gpt-5.6-sol","name":"GPT 5.6 Sol","description":"Experimental preview of OpenAI GPT 5.6 Sol with 1M context window","_meta":{"kiro":{"rateMultiplier":4.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-terra","name":"GPT 5.6 Terra","description":"Experimental preview of OpenAI GPT 5.6 Terra with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-luna","name":"GPT 5.6 Luna","description":"Experimental preview of OpenAI GPT 5.6 Luna with 1M context window","_meta":{"kiro":{"rateMultiplier":0.6,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-4.7","name":"Claude Opus 4.7","description":"Claude Opus 4.7 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"xhigh","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.6","name":"Claude Opus 4.6","description":"Claude Opus 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-4.6","name":"Claude Sonnet 4.6","description":"Claude Sonnet 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.5","name":"Claude Opus 4.5","description":"Claude Opus 4.5 model","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4.5","name":"Claude Sonnet 4.5","description":"Claude Sonnet 4.5 model","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-haiku-4.5","name":"Claude Haiku 4.5","description":"The latest Claude Haiku model","_meta":{"kiro":{"rateMultiplier":0.4,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"deepseek-3.2","name":"Deepseek v3.2","description":"Experimental preview of DeepSeek V3.2","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.5","name":"MiniMax M2.5","description":"MiniMax M2.5 model","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.1","name":"MiniMax M2.1","description":"Experimental preview of MiniMax M2.1","_meta":{"kiro":{"rateMultiplier":0.15,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"glm-5","name":"GLM 5","description":"GLM-5 model","_meta":{"kiro":{"rateMultiplier":0.5,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"qwen3-coder-next","name":"Qwen3 Coder Next","description":"Experimental preview of Qwen3 Coder Next","_meta":{"kiro":{"rateMultiplier":0.05,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"kimi-k3","name":"Kimi K3","description":"Experimental preview of Kimi K3. Kimi K3 is a Kiro Excluded Model. For more info, see documentation.","_meta":{"kiro":{"rateMultiplier":2.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["low","high","max"],"defaultEffortLevel":"max","thinkingToggleable":false}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: kiro model switch second"},{"type":"text","text":"In case you're asked: you are running in T3 Code through the Kiro harness, as claude-sonnet-4.5. 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\n\n"}]}}} +{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: kiro model switch second"},{"type":"text","text":"In case you're asked: you are running in T3 Code through the Kiro harness, as claude-sonnet-4.5. 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\n\n"}]}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"userMessageId":"00000000-0000-4000-9000-000000000005","kind":"user_message_id_assigned"}}}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"in_progress"},"kind":"focus_update","status":"in_progress","activity":{"turnActive":true,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnStart":true,"kind":"turn_start","messageId":"00000000-0000-4000-9000-000000000006-turn-start"}}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/kiro_unknown_model/kiro_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/kiro_unknown_model/kiro_transcript.ndjson index 4f1a1de7cddbf..bfa904c815564 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/kiro_unknown_model/kiro_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/kiro_unknown_model/kiro_transcript.ndjson @@ -22,7 +22,7 @@ {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","configId":"model","value":"claude-haiku-4.5"}}} {"type":"emit_inbound","label":"notification:session/update:config_option_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"config_option_update","configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"model","name":"Model","category":"model","currentValue":"claude-haiku-4.5","options":[{"value":"auto","name":"Auto","description":"Models chosen by task for optimal usage and consistent quality","_meta":{"kiro":{"rateMultiplier":1,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-opus-5.5","name":"Claude Opus 5.5","description":"Experimental preview of Claude Opus 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"medium","thinkingToggleable":false}}},{"value":"claude-opus-5","name":"Claude Opus 5","description":"Claude Opus 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-5","name":"Claude Sonnet 5","description":"Claude Sonnet 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.8","name":"Claude Opus 4.8","description":"Claude Opus 4.8 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"gpt-5.6-sol","name":"GPT 5.6 Sol","description":"Experimental preview of OpenAI GPT 5.6 Sol with 1M context window","_meta":{"kiro":{"rateMultiplier":4.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-terra","name":"GPT 5.6 Terra","description":"Experimental preview of OpenAI GPT 5.6 Terra with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-luna","name":"GPT 5.6 Luna","description":"Experimental preview of OpenAI GPT 5.6 Luna with 1M context window","_meta":{"kiro":{"rateMultiplier":0.6,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-4.7","name":"Claude Opus 4.7","description":"Claude Opus 4.7 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"xhigh","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.6","name":"Claude Opus 4.6","description":"Claude Opus 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-4.6","name":"Claude Sonnet 4.6","description":"Claude Sonnet 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.5","name":"Claude Opus 4.5","description":"Claude Opus 4.5 model","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4.5","name":"Claude Sonnet 4.5","description":"Claude Sonnet 4.5 model","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4","name":"Claude Sonnet 4","description":"[EOL] Hybrid reasoning and coding for regular use. Model reaches end of life on October 14, 2026.","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-haiku-4.5","name":"Claude Haiku 4.5","description":"The latest Claude Haiku model","_meta":{"kiro":{"rateMultiplier":0.4,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"deepseek-3.2","name":"Deepseek v3.2","description":"Experimental preview of DeepSeek V3.2","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.5","name":"MiniMax M2.5","description":"MiniMax M2.5 model","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.1","name":"MiniMax M2.1","description":"Experimental preview of MiniMax M2.1","_meta":{"kiro":{"rateMultiplier":0.15,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"glm-5","name":"GLM 5","description":"GLM-5 model","_meta":{"kiro":{"rateMultiplier":0.5,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"qwen3-coder-next","name":"Qwen3 Coder Next","description":"Experimental preview of Qwen3 Coder Next","_meta":{"kiro":{"rateMultiplier":0.05,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}}} {"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"model","name":"Model","category":"model","currentValue":"claude-haiku-4.5","options":[{"value":"auto","name":"Auto","description":"Models chosen by task for optimal usage and consistent quality","_meta":{"kiro":{"rateMultiplier":1,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-opus-5.5","name":"Claude Opus 5.5","description":"Experimental preview of Claude Opus 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"medium","thinkingToggleable":false}}},{"value":"claude-opus-5","name":"Claude Opus 5","description":"Claude Opus 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-5","name":"Claude Sonnet 5","description":"Claude Sonnet 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.8","name":"Claude Opus 4.8","description":"Claude Opus 4.8 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"gpt-5.6-sol","name":"GPT 5.6 Sol","description":"Experimental preview of OpenAI GPT 5.6 Sol with 1M context window","_meta":{"kiro":{"rateMultiplier":4.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-terra","name":"GPT 5.6 Terra","description":"Experimental preview of OpenAI GPT 5.6 Terra with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-luna","name":"GPT 5.6 Luna","description":"Experimental preview of OpenAI GPT 5.6 Luna with 1M context window","_meta":{"kiro":{"rateMultiplier":0.6,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-4.7","name":"Claude Opus 4.7","description":"Claude Opus 4.7 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"xhigh","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.6","name":"Claude Opus 4.6","description":"Claude Opus 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-4.6","name":"Claude Sonnet 4.6","description":"Claude Sonnet 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.5","name":"Claude Opus 4.5","description":"Claude Opus 4.5 model","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4.5","name":"Claude Sonnet 4.5","description":"Claude Sonnet 4.5 model","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4","name":"Claude Sonnet 4","description":"[EOL] Hybrid reasoning and coding for regular use. Model reaches end of life on October 14, 2026.","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-haiku-4.5","name":"Claude Haiku 4.5","description":"The latest Claude Haiku model","_meta":{"kiro":{"rateMultiplier":0.4,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"deepseek-3.2","name":"Deepseek v3.2","description":"Experimental preview of DeepSeek V3.2","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.5","name":"MiniMax M2.5","description":"MiniMax M2.5 model","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.1","name":"MiniMax M2.1","description":"Experimental preview of MiniMax M2.1","_meta":{"kiro":{"rateMultiplier":0.15,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"glm-5","name":"GLM 5","description":"GLM-5 model","_meta":{"kiro":{"rateMultiplier":0.5,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"qwen3-coder-next","name":"Qwen3 Coder Next","description":"Experimental preview of Qwen3 Coder Next","_meta":{"kiro":{"rateMultiplier":0.05,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: kiro unknown model recovered\n"},{"type":"text","text":"In case you're asked: you are running in T3 Code through the Kiro harness, as claude-haiku-4.5. 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\n\n"}]}}} +{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: kiro unknown model recovered\n"},{"type":"text","text":"In case you're asked: you are running in T3 Code through the Kiro harness, as claude-haiku-4.5. 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\n\n"}]}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"userMessageId":"00000000-0000-4000-9000-000000000004","kind":"user_message_id_assigned"}}}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"in_progress"},"kind":"focus_update","status":"in_progress","activity":{"turnActive":true,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnStart":true,"kind":"turn_start","messageId":"00000000-0000-4000-9000-000000000005-turn-start"}}}}}} 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/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 09f285954992b..ab16197ba05b7 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -469,14 +469,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" }, @@ -675,7 +675,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"); }), @@ -904,6 +904,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..2ff34859c8a97 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -603,10 +603,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..f5796d7a6994d 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -92,15 +92,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 +173,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 +191,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), @@ -409,6 +407,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 +462,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 +472,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 +553,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 +635,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 +692,6 @@ const make = Effect.gen(function* () { return PeerLinks.of({ enabled, link, - describe: describeUrl, list, get, unlink, diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index f5db1d2fefc1e..ff8442306f2ff 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -46,6 +46,10 @@ export class PreviewManager extends Context.Service< readonly markHeadless: ( input: PreviewCloseInput & { readonly tabId: string }, ) => Effect.Effect; + /** Publishes the tab's unchanged snapshot again, for clients that dropped the tab. */ + readonly announce: ( + input: PreviewCloseInput & { readonly tabId: string }, + ) => Effect.Effect; readonly requestReveal: ( input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, ) => Effect.Effect; @@ -298,6 +302,28 @@ export const make = Effect.gen(function* PreviewManagerMake() { ); }); + const announce: PreviewManager["Service"]["announce"] = Effect.fn("PreviewManager.announce")( + function* (input) { + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.announceSession")(function* (session) { + return { + next: session, + emit: { + type: "resized", + threadId: session.threadId, + tabId: session.tabId, + createdAt: yield* currentIsoTimestamp, + snapshot: session.snapshot, + }, + result: undefined, + }; + }), + ); + }, + ); + const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")( function* (input) { return yield* mutateExistingSession( @@ -452,6 +478,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { requestReveal, reportStatus, markHeadless, + announce, resize, adjust, close, diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 97d75c2c4ecb0..c32947b30b995 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -321,6 +321,9 @@ const desktopRenders = (tabId: string) => { } return desktopTabs.has(tabId); }; +/** When set, a desktop that has not attached a tab waits for it the way the real channel does. */ +let desktopAttachesLate = false; +let desktopAttachWaiting = Promise.withResolvers(); const shownDesktopTabs = new Set(); const releasedDesktopTabs: Array = []; const desktopTurns = new NodeEvents.EventEmitter(); @@ -355,7 +358,30 @@ const dependencies = Layer.mergeAll( }), Layer.succeed(DesktopChannel.DesktopBrowserChannel, { available: true, - awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + awaitAttached: (key, timeout) => + !desktopAttachesLate || desktopRenders(key.tabId) + ? Effect.sync(() => desktopRenders(key.tabId)) + : Effect.raceFirst( + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onAttach = (attached: { tabId: string }) => + Queue.offerUnsafe(queue, attached.tabId); + desktopDetaches.on("attach", onAttach); + return onAttach; + }), + (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), + ), + ).pipe( + Stream.filter((tabId) => tabId === key.tabId), + Stream.runHead, + Effect.as(true), + ), + Effect.sync(() => desktopAttachWaiting.resolve()).pipe( + Effect.andThen(Effect.sleep(timeout)), + Effect.as(false), + ), + ), detached: Stream.callback<{ threadId: string; tabId: string }>((queue) => Effect.acquireRelease( Effect.sync(() => { @@ -460,6 +486,8 @@ beforeEach(() => { desktopTabs.clear(); shownDesktopTabs.clear(); desktopRendersNext = false; + desktopAttachesLate = false; + desktopAttachWaiting = Promise.withResolvers(); desktopPageUrl = "about:blank"; desktopSizeAnswer = null; desktopDetachReady = Promise.withResolvers(); @@ -627,6 +655,63 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st ).pipe(Effect.provide(layer)), ); +it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + const manager = yield* Manager.PreviewManager; + const threadId = scope.thread.threadId; + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { url: "https://example.com/?t=1", reuseExistingTab: false, open: false }, + }); + const tabId = PreviewTabId.make(opened.tabId!); + // The renderer reloads on another thread: its webview goes, and it only mounts this + // thread's tabs again once a preview event for the thread reaches it. + yield* Effect.promise(() => desktopDetachReady.promise); + desktopAttachesLate = true; + desktopTabs.delete(tabId); + desktopDetaches.emit("detach", { threadId, tabId }); + yield* Effect.promise(() => desktopReleased.promise); + // It is still reloading when the agent's call arrives, so it misses the first event. + yield* Effect.gen(function* () { + yield* Effect.promise(() => desktopAttachWaiting.promise); + const events = yield* manager.subscribeEvents; + const nextForThread = Effect.gen(function* () { + let event = yield* PubSub.take(events); + while (event.threadId !== threadId) event = yield* PubSub.take(events); + }); + yield* nextForThread; + yield* nextForThread; + desktopTabs.add(tabId); + desktopDetaches.emit("attach", { threadId, tabId }); + }).pipe(Effect.forkScoped); + + const evaluating = yield* broker + .invoke({ + scope, + tabId, + operation: "evaluate", + input: { expression: "navigator.userAgent" }, + }) + .pipe(Effect.forkScoped); + yield* Effect.raceFirst( + Fiber.join(evaluating), + Effect.promise(() => desktopAttachWaiting.promise).pipe( + Effect.andThen(TestClock.adjust("10 seconds")), + Effect.andThen(Fiber.join(evaluating)), + ), + ); + const { sessions } = yield* manager.list({ threadId }); + + expect(sessions.find((session) => session.tabId === tabId)?.headless).toBeUndefined(); + expect(desktopConnections).toHaveLength(2); + }), + ).pipe(Effect.provide(layer)), +); + it.effect("a desktop page that loaded before the server attached reports its loaded title", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 2f681d0c151c9..ee31b17cc5889 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -766,10 +766,17 @@ const make = Effect.gen(function* () { const desktopRenders = (snapshot: PreviewSessionSnapshot) => desktopChannel.available && !snapshot.headless ? runPromise( - desktopChannel.awaitAttached( - { threadId: snapshot.threadId, tabId: snapshot.tabId }, - DESKTOP_ATTACH_TIMEOUT, - ), + Effect.gen(function* () { + const key = { threadId: snapshot.threadId, tabId: snapshot.tabId }; + // A reloaded desktop mounts a thread's tabs again only once an event for it arrives, + // and a renderer still reloading misses events, so repeat it until the wait ends. + if (!(yield* desktopChannel.isAttached(key))) { + yield* manager + .announce({ threadId: ThreadId.make(key.threadId), tabId: key.tabId }) + .pipe(Effect.ignore, Effect.repeat(Schedule.spaced("1 second")), Effect.forkScoped); + } + return yield* desktopChannel.awaitAttached(key, DESKTOP_ATTACH_TIMEOUT); + }).pipe(Effect.scoped), ) : Promise.resolve(false); diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 8b69f41cbdb29..855d30f80ee0d 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,7 +5,16 @@ import { type CDPSession, type Page, } from "playwright-core"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; @@ -171,7 +180,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(/: •+$/); @@ -269,6 +277,67 @@ describe("server browser element refs", () => { await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' }); expect(await page.locator("button").textContent()).toBe("saved"); }); + + it("evaluates top-level await and returns the last expression's value", async () => { + await page.setContent("Ready"); + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "await Promise.resolve(); document.title" }, + 5_000, + ); + expect(result).toBe("Ready"); + }); + + it("still awaits the promise an async function returns", async () => { + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "(async () => { await 0; return 1 })()" }, + 5_000, + ); + expect(result).toBe(1); + }); + + it("does not rerun an expression whose SyntaxError is thrown at runtime", async () => { + const evaluation = ServerBrowserPage.evaluate( + cdp, + { expression: "window.runs = (window.runs ?? 0) + 1; new Function('await 0')" }, + 5_000, + ); + await expect(evaluation).rejects.toThrow("await is only valid"); + expect(await page.evaluate("window.runs")).toBe(1); + }); + + it("does not rerun an expression whose awaited browser call rejects with a SyntaxError", async () => { + let posts = 0; + await page.route("http://preview.test/**", (route) => { + if (route.request().method() === "POST") posts++; + return route.fulfill({ contentType: "text/html", body: "Ready" }); + }); + await page.goto("http://preview.test/"); + const evaluation = ServerBrowserPage.evaluate( + cdp, + { + expression: + "(async () => { const r = await fetch('/api', { method: 'POST' }); return r.json() })()", + }, + 5_000, + ); + await expect(evaluation).rejects.toThrow("is not valid JSON"); + expect(posts).toBe(1); + }); + + it("returns the result of a top-level await expression that reloads the page", async () => { + await page.route("http://preview.test/", (route) => + route.fulfill({ contentType: "text/html", body: "Ready" }), + ); + await page.goto("http://preview.test/"); + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "await 0; location.reload(); ({ clicked: true })" }, + 5_000, + ); + expect(result).toEqual({ clicked: true }); + }); }); describe("server browser snapshot screenshot", () => { @@ -417,6 +486,40 @@ describe("server browser drag", () => { }); }); +describe("server browser evaluate", () => { + it("does not retry a parse error that arrives after the timeout", async () => { + vi.useFakeTimers(); + try { + const classic = Promise.withResolvers(); + const send = vi.fn((method: string) => + method === "Runtime.evaluate" ? classic.promise : Promise.resolve({}), + ); + const cdp = { send } as unknown as CDPSession; + const evaluation = ServerBrowserPage.evaluate(cdp, { expression: "await 0; 1" }, 10); + const rejected = expect(evaluation).rejects.toMatchObject({ + tag: "PreviewAutomationTimeoutError", + }); + await vi.advanceTimersByTimeAsync(10); + await rejected; + classic.resolve({ + result: { type: "object" }, + exceptionDetails: { + text: "Uncaught", + scriptId: "4", + exception: { type: "object", className: "SyntaxError" }, + }, + }); + await classic.promise; + expect(send.mock.calls.map(([method]) => method)).toEqual([ + "Runtime.evaluate", + "Runtime.terminateExecution", + ]); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser net errors", () => { it.each([ ["net::ERR_CONNECTION_REFUSED", "ERR_CONNECTION_REFUSED", -102], diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index fdba996799180..31cff821c6e42 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -892,8 +892,10 @@ export const evaluate = async ( timeoutMs: number, ) => { let timer: ReturnType | undefined; + let timedOut = false; const expired = new Promise((_, reject) => { timer = setTimeout(() => { + timedOut = true; // Stops a busy script; an awaited promise is abandoned and settles unread. void cdp.send("Runtime.terminateExecution").catch(constVoid); reject( @@ -904,10 +906,23 @@ export const evaluate = async ( ); }, timeoutMs); }); - const evaluation = cdp.send("Runtime.evaluate", { + const params = { expression: input.expression, awaitPromise: input.awaitPromise ?? true, returnByValue: input.returnByValue ?? true, + }; + // The devtools console's replMode allows top-level await but returns a trailing promise unawaited, + // so it only retries what the page's parser rejected. A parse error has a scriptId but no stack and + // ran nothing; a browser-internal rejection has neither. + const evaluation = cdp.send("Runtime.evaluate", params).then((classic) => { + const failure = classic.exceptionDetails; + const unparsed = + failure?.exception?.className === "SyntaxError" && + failure.scriptId !== undefined && + failure.stackTrace === undefined; + return unparsed && !timedOut && input.expression.includes("await") + ? cdp.send("Runtime.evaluate", { ...params, replMode: true }) + : classic; }); void evaluation.catch(constVoid); const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 9f975ee209e3d..4a410df612799 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -95,6 +95,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", () => diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 7f4417d1fb67a..b76f26c52870b 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, @@ -500,21 +501,22 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const target = expandHomePath(input.cwd, yield* HostProcess.HomeDirectory); if (input.reveal === true) { - return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + return yield* resolveFileManagerRevealLaunch(target, platform, env, command); } return { editor: editorDef.id, - target: input.cwd, + target, command, args: command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined - ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + ? [resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME)] : platform === "win32" ? // Explorer interprets / as a switch and silently opens a fallback folder. - [normalizeWindowsFileManagerPath(input.cwd)] - : [input.cwd], + [normalizeWindowsFileManagerPath(target)] + : [target], }; }); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index f9c7aaf3fd3d4..6ab04f7e302a4 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", () => { @@ -1345,6 +1378,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 dc67bd9387e04..1b32de22a8878 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, @@ -55,9 +56,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; @@ -159,7 +170,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), ); @@ -224,6 +237,7 @@ interface RawCandidate { readonly transcripts: ReadonlyArray<{ readonly filePath: string; readonly mtimeMs: number | null; + readonly projectRoots?: ReadonlyArray | undefined; }>; } @@ -233,6 +247,7 @@ interface TranscriptCandidate { readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; readonly size: number; + readonly projectRoots?: ReadonlyArray | undefined; } interface MetadataReadBudget { @@ -1234,6 +1249,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"); @@ -1295,7 +1351,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, + }; }, ); @@ -1365,13 +1429,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) { @@ -1718,7 +1783,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]; }) : [], @@ -1804,7 +1870,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* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index bf6f43a3fdb30..f289a3ebe6186 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"; @@ -111,23 +112,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/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/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 1f45e78320e5a..b1cf5c4f349d2 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -273,7 +273,7 @@ describe("ACP Registry probe", () => { }, ); - it("bounds and de-duplicates advertised commands", () => { + it("keeps every distinct advertised command and de-duplicates the rest", () => { const commands = normalizeAcpRegistryCommands([ { name: "create_plan", @@ -285,9 +285,11 @@ describe("ACP Registry probe", () => { name: `command_${index}`, description: "", })), + { name: "$late-skill", description: "Advertised after 128 commands" }, ]); - expect(commands.slashCommands).toHaveLength(128); + expect(commands.slashCommands).toHaveLength(141); + expect(commands.slashCommands.at(-1)).toEqual({ name: "command_139" }); expect(commands.slashCommands[0]).toEqual({ name: "create_plan", description: "Create a plan", @@ -296,7 +298,7 @@ describe("ACP Registry probe", () => { expect( commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"), ).toHaveLength(1); - expect(commands.skills).toEqual([]); + expect(commands.skills.map((skill) => skill.name)).toEqual(["late-skill"]); }); it("routes dollar-prefixed ACP commands to the provider skill menu", () => { diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 5ea745622bb34..72221ff3187b4 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -33,13 +33,18 @@ import { COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, isCommandMissingCause, - parseGenericCliVersion, + parseCliVersionResult, providerModelsFromSettings, + providerProbeEnvironment, spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; -import { hasClaudeManagedMcpConfig, makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; +import { + claudeSignedOutMessage, + hasClaudeManagedMcpConfig, + makeClaudeEnvironment, +} from "./Drivers/ClaudeHome.ts"; import { discoverClaudeSkills, readClaudeSettingsEnv } from "./Drivers/ClaudeSkills.ts"; import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; @@ -232,7 +237,7 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { mcpServers: {}, ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }), env: { - ...input.environment, + ...providerProbeEnvironment(input.environment), // Disable claude.ai MCP separately; it is discovered outside filesystem config. ENABLE_CLAUDEAI_MCP_SERVERS: "false", // Skipping this avoids a Windows tasklist | findstr process tree on every refresh. @@ -552,7 +557,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const versionProbe = yield* runClaudeCommand( claudeSettings, ["--version"], - resolvedEnvironment, + providerProbeEnvironment(resolvedEnvironment), cwd, ).pipe(Effect.timeoutOption(CLAUDE_VERSION_CHECK_TIMEOUT_MS), Effect.result); @@ -596,7 +601,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + const parsedVersion = parseCliVersionResult(version); if (version.code !== 0) { yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { exitCode: version.code, @@ -673,6 +678,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( endpoint.hostname !== "" && endpoint.hostname !== "api.anthropic.com"; 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, @@ -686,7 +694,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "error", auth: { status: "unauthenticated" }, - message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + message: + 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 e8939f2368ada..5f4a28e886b6a 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.ts b/apps/server/src/provider/CodexProvider.ts index 2d546b36a9b15..2e56eede84cd6 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -41,6 +41,7 @@ import { COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, providerModelsFromSettings, + providerProbeEnvironment, spawnAndCollect, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; @@ -650,7 +651,10 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) { - const { client } = yield* withCodexAppServerClient(input); + const { client } = yield* withCodexAppServerClient({ + ...input, + environment: providerProbeEnvironment(input.environment ?? process.env), + }); const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); return parseCodexSkillsListResponse(skillsResponse, input.cwd); }); @@ -753,7 +757,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { - const resolvedEnvironment = environment ?? process.env; + const resolvedEnvironment = providerProbeEnvironment(environment ?? process.env); const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2d4b63861074..3ada12297649d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -309,9 +309,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/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index e998332d3e92f..95866780189ac 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -664,7 +664,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 6acb9bb9741f9..5c53f76237b6a 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1737,6 +1737,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"); @@ -3125,6 +3216,49 @@ it.layer( ), ); + it.effect("does not let a lazy mise shim install Claude during a status check (#33415)", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-mise-claude-" }); + const installMarker = path.join(directory, "installed"); + const binaryPath = path.join(directory, "claude"); + // A `lazy = true` mise shim downloads and installs the tool unless MISE_OFFLINE is set. + yield* fs.writeFileString( + binaryPath, + [ + `#!/bin/sh`, + `case "$MISE_OFFLINE" in`, + ` 1|true) cat >&2 <<'EOF'`, + `mise WARN Failed to resolve tool version list for claude: [/home/u/.config/mise/config.toml] claude@latest: no versions found for claude`, + `mise ERROR Failed to install aqua:anthropics/claude-code@latest: no versions found for claude`, + `mise ERROR Version: 2026.11.5 linux-x64 (2026-11-09)`, + `mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information`, + `EOF`, + ` exit 1 ;;`, + `esac`, + `touch "${installMarker}"`, + `echo "2.1.0 (Claude Code)"`, + ].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + const { MISE_OFFLINE: _, ...environment } = process.env; + + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, binaryPath }, + claudeCapabilities(), + environment, + ); + + assert.isFalse( + yield* fs.exists(installMarker), + "the Claude --version status probe let mise install Claude", + ); + assert.isNull(status.version, "mise's own version is not Claude's"); + }), + ); + it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( @@ -3149,6 +3283,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 e36bc05cf8703..a0ea5c78fbc61 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>; @@ -951,6 +953,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) { @@ -989,7 +992,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 50ce63aa8a2b3..4439c50940722 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5547, - "codexAdditionalContextBytes": 7277, + "claudeSystemAppendBytes": 5597, + "codexAdditionalContextBytes": 7327, "mcpToolListJsonBytes": 48217 } 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 94a10cb44275b..678ad71161c3f 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", @@ -126,7 +126,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": { @@ -875,6 +882,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/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/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index ccd28c165f14e..1463a1a30270f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -318,6 +318,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/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 f67129be60423..d86f77fbdbd83 100644 --- a/apps/server/src/speech/http.test.ts +++ b/apps/server/src/speech/http.test.ts @@ -80,6 +80,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" }), @@ -164,7 +165,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), @@ -228,6 +253,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), ); @@ -268,6 +309,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/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 40fd6e7eed9c2..86c6cb9a8cfef 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 @@ -625,6 +628,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)({ @@ -639,11 +649,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 fcdddd2052d8e..612d473071bcf 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, @@ -81,6 +81,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, @@ -129,7 +130,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" && @@ -155,23 +156,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( @@ -180,6 +193,7 @@ export const make = Effect.gen(function* () { return generate( "generateTranscriptionPostProcessing", input.modelSelection, + null, (service, modelSelection) => service.generateTranscriptionPostProcessing({ ...input, modelSelection }), ); @@ -188,6 +202,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/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b9f357522bb7b..431af7b5971ff 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,12 +49,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function fixture( options: { - accounts?: Array< - (typeof accounts)[number] & { - disabled?: boolean; - id_token: { chatgpt_account_id: string; 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; } = {}, @@ -433,6 +437,188 @@ 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) before upstream calls", + (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"]); + }), + ); + + 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(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 87ec1e05b8f77..030983f50e33d 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -5,18 +5,33 @@ 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"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + GROK_USAGE_URL, + GrokUsageResponse, + grokUsageResponseToLimits, +} from "@t3tools/provider-grok/server"; import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; -import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + antigravityAccountFingerprint, + antigravitySummaryWindows, + antigravityUserAgent, +} from "../provider/antigravityUsageLimits.ts"; +import { + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; /** A hub with its management key already read from wherever the setting keeps it. */ export interface CliproxyHub { @@ -30,6 +45,7 @@ const AuthFile = Schema.Struct({ provider: Schema.String, email: Schema.optional(Schema.String), disabled: Schema.optional(Schema.Boolean), + project_id: Schema.optional(Schema.String), id_token: Schema.optional( Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String), @@ -106,6 +122,8 @@ const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage)); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); +const decodeGrokUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(GrokUsageResponse)); +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. @@ -174,9 +192,216 @@ 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)}`; }); +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; +} + +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 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)); +}); + +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 } + : {}), + }), + 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)), + } + : {}), + }, + } + : {}), + }, + }; + }), +}; + +const GROK_CLI_VERSION = "1.0.77"; +const ANTIGRAVITY_SUMMARY_URL = + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + +// 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 }) { + const project = account.project_id?.trim(); + if (!project) { + return yield* new UsageLimitSourceError({ detail: "No Antigravity project reported." }); + } + const requestedAt = DateTime.toEpochMillis(yield* DateTime.now); + const summary = yield* call(ANTIGRAVITY_SUMMARY_URL, { project }); + const windows = yield* antigravitySummaryWindows(yield* decodeJson(summary), requestedAt); + if (windows.length === 0) { + return yield* new UsageLimitSourceError({ detail: "No Antigravity quota reported." }); + } + // 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 }, + }), + }); + }), + }, + ], +]); + 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, @@ -215,27 +440,11 @@ 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, data?: unknown, ) { - const header = - account.provider === "codex" - ? { - 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 } - : {}), - } - : { - 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", - }; const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", @@ -252,117 +461,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); - 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 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: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), - ...(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* () { - if (account.provider === "claude") { - const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); - 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, - }; - } - const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); - 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; - 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)), - } - : {}), - }, - } - : {}), - }, - }; - }); - 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", @@ -370,6 +480,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }), })), ); + return { id: account.id, driver: provider.driver, ...reading }; }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( @@ -383,11 +494,11 @@ export const makeCliproxyApi = Effect.gen(function* () { ), ); return yield* Effect.forEach( - accounts.filter( - (account) => - !account.disabled && (account.provider === "codex" || account.provider === "claude"), - ), - (account) => readAccount(hub, account), + accounts.flatMap((account) => { + const provider = HUB_PROVIDERS.get(account.provider); + return provider && !account.disabled ? [{ account, provider }] : []; + }), + ({ account, provider }) => readAccount(hub, account, provider), { concurrency: 4 }, ); }); @@ -404,13 +515,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`, { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Digest.Digest, digest)), - credit_id: creditId, - }); + 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/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index b22625b7e490c..dc0f37cf1376e 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -277,6 +277,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 1f515abb736c8..609a573f54f63 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -126,9 +126,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 }); @@ -180,6 +181,11 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } +// 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("["); 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 59db351198182..ff129690b7b24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -181,6 +181,11 @@ export interface GitPushResult { setUpstream?: boolean | undefined; } +export interface GitPushTarget { + remoteName: string; + branchName: string; +} + export interface GitRangeContext { commitSummary: string; diffSummary: string; @@ -339,6 +344,11 @@ export class GitVcsDriver extends Context.Service< fallbackBranch: string | null, options?: { readonly remoteName?: string | null }, ) => Effect.Effect; + /** Where `pushCurrentBranch` would push the checked-out branch; null with no remote or when Git picks. */ + readonly resolvePushTarget: ( + cwd: string, + current: { readonly branch: string; readonly hasUpstream: boolean }, + ) => Effect.Effect; readonly readRangeContext: ( cwd: string, baseRef: string, @@ -862,6 +872,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--git-common-dir", "--git-path", "index", + "--show-prefix", "--verify", "--quiet", "HEAD", @@ -871,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), }; }); @@ -920,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; @@ -1113,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 baf1368054d4e..d9a5e7d2b9fde 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -23,6 +23,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 { @@ -4001,6 +4002,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(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4fcf1b05fe6f5..4dabf3b493ebe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1744,25 +1744,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, @@ -2663,6 +2670,54 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // `publishes` is false only when HEAD goes to the head of its current `upstream`. + const resolvePushDestination = Effect.fn("resolvePushDestination")(function* ( + cwd: string, + branch: string, + hasUpstream: boolean, + ) { + if (!hasUpstream) { + const remoteName = yield* resolvePushRemoteName(cwd, branch); + if (!remoteName) return null; + const branchName = yield* resolvePublishBranchName(cwd, branch); + return { remoteName, branchName, publishes: true, upstream: null }; + } + + const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); + if (!upstream) return null; + // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. + const isAliasOfUpstreamHead = + branch === upstream.branchName || + (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)); + if (isAliasOfUpstreamHead) { + return { + remoteName: upstream.remoteName, + branchName: upstream.branchName, + publishes: false, + upstream, + }; + } + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const branchName = yield* resolvePublishBranchName(cwd, branch); + return { + remoteName: publishRemoteName ?? upstream.remoteName, + branchName, + publishes: true, + upstream, + }; + }); + + const resolvePushTarget: GitVcsDriver.GitVcsDriver["Service"]["resolvePushTarget"] = Effect.fn( + "resolvePushTarget", + )(function* (cwd, current) { + const destination = yield* resolvePushDestination(cwd, current.branch, current.hasUpstream); + return destination + ? { remoteName: destination.remoteName, branchName: destination.branchName } + : null; + }); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -2697,30 +2752,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } + if (hasNoLocalDelta && details.hasUpstream) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + const destination = yield* resolvePushDestination(cwd, branch, details.hasUpstream); + if (hasNoLocalDelta) { const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ); - if (!publishRemoteName) { + if (!destination) { return { status: "skipped_up_to_date" as const, branch, }; } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); // The configured diff base can contain HEAD while the publish branch is still behind. const publishContainsHead = yield* executeGit( "GitVcsDriver.pushCurrentBranch.publishContainsHead", @@ -2729,7 +2781,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "merge-base", "--is-ancestor", "HEAD", - `refs/remotes/${publishRemoteName}/${publishBranch}`, + `refs/remotes/${destination.remoteName}/${destination.branchName}`, ], { allowNonZeroExit: true }, ).pipe( @@ -2745,87 +2797,61 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.pushCurrentBranch", - cwd, - args: ["push"], - }), - detail: "Cannot push because no git remote is configured for this repository.", + if (!details.hasUpstream && !destination) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.pushCurrentBranch", + cwd, + args: ["push"], + }), + detail: "Cannot push because no git remote is configured for this repository.", + }); + } + + if (destination?.publishes) { + const { remoteName, branchName: publishBranch, upstream } = destination; + // Publishing retargets the upstream, so preserve its comparison base first. + if (upstream && (yield* readBranchBase(cwd, branch)) === null) { + yield* writeConfigValue({ + cwd, + key: branchBaseConfigKey(branch), + value: upstream.branchName, }); + if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { + yield* writeConfigValue({ + cwd, + key: `branch.${branch}.t3-merge-base-ref`, + value: `refs/remotes/${upstream.upstreamRef}`, + }); + } } - const publishBranch = yield* resolvePublishBranchName(cwd, branch); yield* runGit( - "GitVcsDriver.pushCurrentBranch.pushWithUpstream", + upstream + ? "GitVcsDriver.pushCurrentBranch.pushOwnBranch" + : "GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, - ["push", "--", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], + ["push", "--", remoteName, `HEAD:refs/heads/${publishBranch}`], { timeoutMs: null }, ); return { status: "pushed" as const, branch, - upstreamBranch: `${publishRemoteName}/${publishBranch}`, - setUpstream: yield* recordPushedUpstream(cwd, branch, publishRemoteName, publishBranch), + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: yield* recordPushedUpstream(cwd, branch, remoteName, publishBranch), }; } - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.orElseSucceed(() => null), - ); - if (currentUpstream) { - // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. - const isAliasOfUpstreamHead = - branch === currentUpstream.branchName || - (branch.endsWith(`/${currentUpstream.branchName}`) && - currentUpstream.upstreamRef.endsWith(`/${branch}`)); - if (!isAliasOfUpstreamHead) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ); - const remoteName = publishRemoteName ?? currentUpstream.remoteName; - const publishBranch = yield* resolvePublishBranchName(cwd, branch); - // Publishing retargets the upstream, so preserve its comparison base first. - if ((yield* readBranchBase(cwd, branch)) === null) { - yield* writeConfigValue({ - cwd, - key: branchBaseConfigKey(branch), - value: currentUpstream.branchName, - }); - if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { - yield* writeConfigValue({ - cwd, - key: `branch.${branch}.t3-merge-base-ref`, - value: `refs/remotes/${currentUpstream.upstreamRef}`, - }); - } - } - yield* runGit( - "GitVcsDriver.pushCurrentBranch.pushOwnBranch", - cwd, - ["push", "--", remoteName, `HEAD:refs/heads/${publishBranch}`], - { timeoutMs: null }, - ); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${remoteName}/${publishBranch}`, - setUpstream: yield* recordPushedUpstream(cwd, branch, remoteName, publishBranch), - }; - } - + if (destination?.upstream) { yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, - ["push", "--", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + ["push", "--", destination.remoteName, `HEAD:refs/heads/${destination.branchName}`], { timeoutMs: null }, ); return { status: "pushed" as const, branch, - upstreamBranch: currentUpstream.upstreamRef, + upstreamBranch: destination.upstream.upstreamRef, setUpstream: false, }; } @@ -5104,6 +5130,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* commit: Effect.fnUntraced(function* (cwd, subject, body, options) { return yield* withListRefsInvalidation(cwd, commit(cwd, subject, body, options)); }), + resolvePushTarget, pushCurrentBranch: Effect.fnUntraced(function* (cwd, fallbackBranch, options) { return yield* withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)); }), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index a9df07044943a..ec4d4942d84e4 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -375,17 +375,19 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(subscribed); state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + state.currentRemoteStatus = baseRemoteStatus; const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); assert.equal(refreshed.refName, "main"); assert.isNull(refreshed.pr); assert.equal(state.localInvalidationCalls, 1); assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 2); + assert.equal(state.remoteInvalidationCalls, 1); assert.deepStrictEqual((yield* Fiber.join(events)).slice(1), [ { _tag: "remoteUpdated", remote: null }, { _tag: "localUpdated", local: state.currentLocalStatus }, ]); assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); + assert.equal(state.remoteStatusCalls, 2); state.currentLocalStatus = { ...state.currentLocalStatus, hasWorkingTreeChanges: true }; yield* broadcaster.refreshLocalStatus("/repo"); state.currentLocalStatus = { ...baseLocalStatus, refName: "union" }; @@ -433,6 +435,98 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect("a local-only refresh that overlaps the first remote poll keeps its PR", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const pollStarted = yield* Deferred.make(); + const releasePoll = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(pollStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePoll)), + ); + const localSeen = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( + Stream.runForEach((event) => + event._tag === "localUpdated" ? Deferred.succeed(localSeen, undefined) : Effect.void, + ), + Effect.forkScoped, + ); + yield* Deferred.await(pollStarted); + state.beforeRemoteStatus = Effect.void; + + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const refresh = yield* broadcaster + .refreshStatus("/repo", { localOnly: true }) + .pipe(Effect.forkScoped); + yield* Deferred.await(localSeen); + yield* Deferred.succeed(releasePoll, undefined); + yield* Fiber.join(refresh); + + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(status.pr, remoteStatusWithPr.pr); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state))); + }); + + it.effect( + "a local-only refresh before any remote read leaves the PR refresh able to read", + () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + + const remote = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(remote?.pr, remoteStatusWithPr.pr); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state))); + }, + ); + + it.effect("a local-only refresh after a branch switch leaves the PR refresh able to read", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; + const nextBranchRemote: VcsStatusRemoteResult = { + ...baseRemoteStatus, + pr: { ...remoteStatusWithPr.pr!, number: 3001, headRef: "feature/next" }, + }; + state.currentRemoteStatus = nextBranchRemote; + yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + + const remote = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(remote?.pr, nextBranchRemote.pr); + assert.equal(state.remoteStatusCalls, 2); + }).pipe(Effect.provide(layerTestFor(state))); + }); + it.effect("clears the previous branch's remote status after an in-flight PR refresh", () => { const remoteReadStarted = Deferred.makeUnsafe(); const releaseRemoteRead = Deferred.makeUnsafe(); @@ -463,13 +557,14 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.succeed(releaseRemoteRead, undefined); yield* Fiber.join(remoteRefresh); yield* Fiber.join(localRefresh); + state.currentRemoteStatus = baseRemoteStatus; const status = yield* broadcaster.getStatus({ cwd: "/repo" }); assert.equal(status.refName, "main"); assert.isNull(status.pr); assert.equal(status.aheadCount, 0); assert.equal(status.behindCount, 0); - assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 3); }).pipe(Effect.provide(layerTestFor(state))); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index be1c86940e64e..1868d3418198d 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -537,24 +537,11 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - const previous = options?.localOnly ? yield* getCachedStatus(cwd) : null; - const local = yield* refreshLocalStatusCore(cwd); + yield* refreshLocalStatusCore(cwd); if (options?.localOnly) { - return yield* withRemoteWriteLock( - cwd, - Effect.gen(function* () { - if (previous?.local?.value.refName !== local.refName || !previous?.remote) { - yield* workflow.invalidateRemoteStatus(cwd); - yield* updateCachedRemoteStatus( - cwd, - { branch: local.refName, fingerprint: fingerprintStatusPart(null), value: null }, - { publish: true }, - ); - } - const parts = yield* getStatusParts(cwd); - return mergeGitStatusParts(parts.local, parts.remote); - }), - ); + // Never cache a remote here: readers take `value: null` as "read and found no PR". + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); } return yield* withRemoteWriteLock( cwd, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 6105b12d82601..6374204471964 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -31,7 +31,9 @@ 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"; @@ -132,6 +134,11 @@ import * as ManagedPolicy from "./policy/ManagedPolicy.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"; @@ -1166,6 +1173,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 @@ -1350,6 +1360,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(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1c204481350d6..9721864fcdfd5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2516,6 +2516,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 @@ -2648,7 +2649,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({ diff --git a/apps/web/package.json b/apps/web/package.json index 9fcc2367f9de7..e42f4e3bcc61b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -68,11 +68,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:", @@ -86,7 +84,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", @@ -112,7 +109,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/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 3cc21392cea8d..b03612507f917 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -9,6 +9,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"; @@ -40,6 +41,7 @@ vi.mock(import("../state/server"), async (original) => { const { Atom } = await import("effect/reactivity"); const noConfig = Atom.make(null); const modelsConfig = Atom.make({ + environment: { platform: { os: "darwin" } }, availableEditors: [], settings: { providerInstances: {} }, providers: [ @@ -161,6 +163,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)"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 06960b273a057..0b0116be549cb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { clientHostEnvironmentAtom } from "../state/clientHostEnvironment"; import { buildFileLinkParentSuffixByPath, fileLinkLabel } from "@t3tools/shared/fileLinks"; import { isWindowsDrivePathHref, @@ -36,7 +37,6 @@ import { MessageSquareWarningIcon, OctagonAlertIcon, PresentationIcon, - SparklesIcon, TriangleAlertIcon, type LucideIcon, } from "lucide-react"; @@ -170,10 +170,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 "@t3tools/client-runtime/change-request-links"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -294,11 +293,8 @@ function CodexArtifactTemplateCard(props: { data-skill-name={props.template.skillName} >
- + - - -
@@ -326,13 +322,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` }; } @@ -411,6 +412,7 @@ type MarkdownServerConfigFields = ReturnType; function markdownServerConfigFields(config: ServerConfig | null) { return { pullRequestsEnabled: config !== null, + isMacOS: config?.environment.platform.os === "darwin", availableEditors: config?.availableEditors ?? NO_MARKDOWN_EDITORS, revealInFileManagerLabel: config?.shellRevealInFileManager === true && config.availableEditors.includes("file-manager") @@ -427,6 +429,7 @@ function markdownServerConfigFieldsEqual( ): boolean { return ( previous.pullRequestsEnabled === next.pullRequestsEnabled && + previous.isMacOS === next.isMacOS && previous.revealInFileManagerLabel === next.revealInFileManagerLabel && previous.availableEditors.length === next.availableEditors.length && previous.availableEditors.every((editor, index) => editor === next.availableEditors[index]) @@ -552,6 +555,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 && @@ -621,7 +625,7 @@ function useChatMarkdownState({ }, ); }; - const { pullRequestsEnabled, availableEditors, revealInFileManagerLabel } = useAtomValue( + const { pullRequestsEnabled, availableEditors, revealInFileManagerLabel, isMacOS } = useAtomValue( markdownServerConfigFieldsAtom(environmentId), ); const projects = useProjects(); @@ -762,33 +766,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; @@ -799,8 +794,7 @@ function useChatMarkdownState({ if ( fileLinkMeta.workspaceLookupPath === null || !cwd || - (!needsWorkspaceBasenameLookup(panelPath) && - stripRepeatedWorkspacePrefix(panelPath, cwd) === null) + workspaceLinkLookupQuery(panelPath, cwd) === null ) { openAt(panelPath); return; @@ -822,6 +816,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( @@ -836,15 +837,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, )} @@ -852,12 +859,18 @@ function useChatMarkdownState({ theme={resolvedTheme} threadRef={threadRef} onOpen={ - canUseShellActions + canOpenApp ? async () => - openInPreferredEditor( - markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), - ) - : undefined + openInEditor({ + environmentId, + input: { cwd: (await resolveHostPath()).absolutePath, editor: "file-manager" }, + }) + : canUseShellActions + ? async () => + openInPreferredEditor( + markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), + ) + : undefined } resolveCopyPaths={async () => markdownFileCopyPaths(fileLinkMeta, await resolveHostPath(), cwd) @@ -868,7 +881,7 @@ function useChatMarkdownState({ ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) : undefined } - openInEditorMenuLabel={preferredEditorMenuLabel} + openInEditorMenuLabel={canOpenApp ? "Open app" : preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined ? async () => revealFileInFileManager((await resolveHostPath()).absolutePath) @@ -1009,10 +1022,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 (
    ); diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 96eb837db4d69..dc4908867a3e1 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -17,6 +17,7 @@ import { ENVIRONMENT_ID, PROJECT_ID, installFakeEnvironment, + threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; import { render } from "../../test/render"; @@ -65,3 +66,28 @@ 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(); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6daf189f7d6df..f4561188df0cc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -117,7 +117,10 @@ 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 { + assistantCitationFromLocation, + useSideChatCitationReveal, +} from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useStore } from "zustand"; import { shallow } from "zustand/vanilla/shallow"; @@ -380,6 +383,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 = []; @@ -567,12 +571,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( @@ -3575,6 +3583,7 @@ export default function ChatView(props: ChatViewProps) { draftId={draftId} activeThreadId={activeThreadId} activeThread={activeThread} + activeThreadRuns={serverProjection?.runs ?? EMPTY_RUNS} promptHistoryEntries={timelineEntries} forceExpandedOnMobile={ forceExpandedMobileComposer && isDraftHeroState 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 112181def5b46..c7106f0cff289 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 c0b5b3269ce81..03cde4f46240c 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) => @@ -56,6 +62,7 @@ vi.mock("../state/environments", async (original) => ({ serverConfig: { environment: { platform: { os: "linux" }, capabilities: { projectCloneTracking: true } }, settings: { addProjectBaseDirectory: "/workspace/" }, + newProjectsRoot: "/workspace/", }, }, ], @@ -84,7 +91,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) => ({ @@ -105,6 +112,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(); @@ -134,7 +142,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(); @@ -147,3 +192,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 cc8fd9091ded8..db7ea4895b7ce 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], @@ -194,6 +196,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; @@ -466,6 +469,7 @@ export function buildThreadActionItems + { @@ -2647,6 +2650,7 @@ function OpenCommandPaletteDialog(props: { scrollCommandPaletteRowIntoView( resultListRef.current, resultRows.rowIndexByItemIndex[0] ?? 0, + true, ); return; } @@ -2784,9 +2788,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.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index a1a69a7968ee9..8da54f5f72141 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -31,9 +31,11 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `; function Composer({ question, editorRef, + send = "clears", }: { question: boolean; editorRef: React.RefObject; + send?: "clears" | "blocked" | "refusedLater"; }) { const [prompt, setPrompt] = useState(promptBefore); const [answer, setAnswer] = useState(""); @@ -61,6 +63,11 @@ function Composer({ onCitationDraftSave={(replacedValue, savedValue) => { if (replacedValue === prompt) setPrompt(savedValue); }} + onCitationSubmitAndSend={() => { + if (send === "blocked") return false; + if (send === "clears") setPrompt(""); + return true; + }} onPaste={() => {}} /> @@ -97,3 +104,58 @@ it("keeps an unsaved citation comment when a question takes the composer", async ); await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); + +it("clears the sent prompt when an empty citation comment sends it", async () => { + const editorRef = createRef(); + await using screen = await render(); + + const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: promptBefore, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toHaveFocus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + + await expect.element(comment).not.toBeInTheDocument(); + await expect.element(page.getByTestId("composer-editor")).toHaveFocus(); + await expect.element(screen.getByRole("status")).toHaveAttribute("data-prompt", ""); +}); + +it.each(["blocked", "refusedLater"] as const)( + "keeps the caret after the citation when save-and-send is %s", + async (send) => { + const editorRef = createRef(); + await using screen = await render( + , + ); + + const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: promptBefore, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toHaveFocus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + await expect.element(comment).not.toBeInTheDocument(); + await userEvent.keyboard("x"); + + await expect + .element(screen.getByRole("status")) + .toHaveAttribute("data-prompt", `See ${serializeAssistantCitation(citation)}x `); + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index bb8f5a5a2211a..1986cc888efba 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -21,6 +21,7 @@ import { Plugin, PluginKey, TextSelection, + type Transaction, } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; @@ -186,7 +187,7 @@ export interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; - onCitationSubmitAndSend?: () => void; + onCitationSubmitAndSend?: () => boolean; /** Saves unsaved citation text into replacedValue when a controlled rewrite replaces the document. */ onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; onPaste: React.ClipboardEventHandler; @@ -213,12 +214,12 @@ const ComposerCitationCommentContext = createContext<{ openComment: OpenCitationComment | null; onOpenChange: (citeKey: string, open: boolean) => void; onDraftChange: (citeKey: string, comment: string) => void; - onSubmitAndSend: () => void; + onSubmitAndSend: () => boolean; }>({ openComment: null, onOpenChange: () => {}, onDraftChange: () => {}, - onSubmitAndSend: () => {}, + onSubmitAndSend: () => false, }); const RichComposerSkillsContext = createContext>([]); @@ -477,13 +478,23 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { editor.chain().focus().deleteRange({ from, to }).run(); }; + const sentRef = useRef(false); const onRestoreFocus = () => { + const sent = sentRef.current; + sentRef.current = false; 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") { + if (sent) editor.view.focus(); + return; + } + // The send's controlled rewrite may still be pending; emitting this caret move would restore the old prompt. + editor + .chain() + .setMeta(SILENT_SELECTION_META, sent) + .focus(pos + current.nodeSize) + .run(); }; return ( @@ -525,7 +536,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment), onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; - commentContext.onSubmitAndSend(); + sentRef.current = commentContext.onSubmitAndSend(); return true; }, onRestoreFocus, @@ -729,6 +740,8 @@ function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSe type TiptapEditor = NonNullable>; +const SILENT_SELECTION_META = "composerSilentSelection"; + function replaceSkillChips( editor: TiptapEditor, replacements: ReturnType, @@ -995,10 +1008,10 @@ function ComposerPromptEditorTiptapInner( onDraftChange: (nodeKey: string, comment: string) => { citationDraftRef.current = { key: nodeKey, comment }; }, - onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + onSubmitAndSend: onCitationSubmitAndSend ?? (() => false), }; - 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; @@ -1025,7 +1038,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?.(); @@ -1459,10 +1472,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/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 91d4a54fd9e64..6781cf1b4a091 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -85,7 +85,7 @@ function CustomSnoozeDialog() { if (!open) finish(null); }} > - +
{ 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.tsx b/apps/web/src/components/DiffPanel.tsx index e7e6d614ef77f..2e9af946d59bd 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"} @@ -1159,10 +1161,11 @@ export default function DiffPanel({ /> } > - + {allDiffFilesCollapsed ? ( + + ) : ( + + )} {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} diff --git a/apps/web/src/components/MorphIcon.tsx b/apps/web/src/components/MorphIcon.tsx deleted file mode 100644 index 881721682db7a..0000000000000 --- a/apps/web/src/components/MorphIcon.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MorphIcon as BaseMorphIcon, type MorphIconProps } from "morphicons/react"; - -export function MorphIcon({ reducedMotion = "user", ...props }: MorphIconProps) { - return ; -} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 596bdee96ad1f..d3c650e8a4c53 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -46,8 +46,9 @@ import { MessagesSquare, Plus, TerminalSquare, + Volume2Icon, + VolumeOffIcon, } from "lucide-react"; -import { Volume2, VolumeOff } from "lucide"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, @@ -67,7 +68,6 @@ import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings import { readLocalApi } from "~/localApi"; import { useStableCallback } from "~/hooks/useStableCallback"; import { Button } from "~/components/ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; @@ -940,7 +940,11 @@ function RightPanelTab( .catch(() => undefined); }} > - + {audio === "muted" ? ( + + ) : ( + + )} } /> diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index b948ed74b279b..c1cae81000123 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -1,18 +1,43 @@ -import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ProjectId, + ThreadId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { isMacPlatform } from "@t3tools/shared/keybindings"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import { render } from "../../test/render"; +import { act, fakeTimers, render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; +import { makeThreadFixture } from "../test-fixtures"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings"; -import { useProjects } from "../state/entities"; +import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; +import { useProjects, useQuietThreadShells } from "../state/entities"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; import type { Project } from "../types"; import Sidebar from "./Sidebar"; +import { THREAD_JUMP_HINT_SHOW_DELAY_MS } from "./Sidebar.logic"; +import { SortableThreadRow } from "./sidebar/SidebarThreadRow"; import { SidebarProvider } from "./ui/sidebar"; vi.mock("../state/entities", { spy: true }); vi.mock("../hooks/useHandleNewThread", { spy: true }); +vi.mock("../hooks/useThreadRouteTarget", { spy: true }); +vi.mock("./sidebar/SidebarThreadRow", { spy: true }); +vi.mock(import("../state/server"), async (original) => { + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings"); + // The desktop defaults: holding Mod alone already matches a thread jump. + const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+1", command: "thread.jump.1" }, + { key: "mod+2", command: "thread.jump.2" }, + ]); + return { ...(await original()), primaryServerKeybindingsAtom: Atom.make(keybindings) }; +}); vi.mock("../commandPaletteBus", () => ({ openCommandPalette: vi.fn(), isCommandPaletteOpen: () => false, @@ -86,3 +111,82 @@ describe("sidebar header new thread", () => { } }); }); + +function threadShell(projectId: ProjectId, index: number): EnvironmentThreadShell { + return makeThreadFixture({ + environmentId: EnvironmentId.make("local"), + id: ThreadId.make(`thread-${index}`), + projectId, + title: `Thread ${index}`, + }); +} + +describe("sidebar render isolation", () => { + // regression: #33100 Cmd+J re-rendered every sidebar row, which took hundreds of ms with many threads + it("does not re-render thread rows when a quick Mod shortcut opens and focuses the terminal", async () => { + using stack = new DisposableStack(); + const activeProject = project("active"); + const threads = Array.from({ length: 5 }, (_, index) => threadShell(activeProject.id, index)); + const routeThreadRef: ScopedThreadRef = { + environmentId: activeProject.environmentId, + threadId: threads[0]!.id, + }; + vi.mocked(useProjects).mockReturnValue([activeProject]); + vi.mocked(useQuietThreadShells).mockReturnValue(threads); + vi.mocked(useThreadRouteTarget).mockReturnValue({ kind: "server", threadRef: routeThreadRef }); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: { + environmentId: activeProject.environmentId, + projectId: activeProject.id, + }, + defaultProjectFallbackReady: true, + handleNewThread: vi.fn().mockResolvedValue(null), + routeDraftId: null, + routeThreadRef, + }); + const terminal = document.createElement("div"); + terminal.dataset.terminalOwner = "drawer"; + const terminalInput = document.createElement("textarea"); + terminal.append(terminalInput); + document.body.append(terminal); + stack.defer(() => terminal.remove()); + stack.defer(() => useTerminalUiStateStore.getState().setTerminalOpen(routeThreadRef, false)); + await using screen = await render( + + + , + ); + await expect.element(screen.getByText("Thread 4").first()).toBeInTheDocument(); + const rowRenders = () => vi.mocked(SortableThreadRow).mock.calls.length; + + using _timers = fakeTimers(); + const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control"; + const beforeShortcut = rowRenders(); + await userEvent.keyboard(`{${modifier}>}`); + await act(() => useTerminalUiStateStore.getState().setTerminalOpen(routeThreadRef, true)); + await act(() => terminalInput.focus()); + await userEvent.keyboard(`{/${modifier}}`); + await act(() => terminalInput.blur()); + expect( + rowRenders() - beforeShortcut, + "thread rows re-rendered while opening the terminal with a quick Mod shortcut", + ).toBe(0); + + const jumpLabelsSince = (start: number) => + vi + .mocked(SortableThreadRow) + .mock.calls.slice(start) + .map(([props]) => props.jumpLabel) + .filter((label) => label !== null); + const beforeHold = rowRenders(); + await userEvent.keyboard(`{${modifier}>}`); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS)); + expect(jumpLabelsSince(beforeHold), "holding Mod showed no jump hints").toHaveLength(2); + const beforeRelease = rowRenders(); + await userEvent.keyboard(`{/${modifier}}`); + expect(rowRenders()).toBeGreaterThan(beforeRelease); + expect(jumpLabelsSince(beforeRelease)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.browser.test.tsx b/apps/web/src/components/Sidebar.drag.browser.test.tsx new file mode 100644 index 0000000000000..b6494d089ae1c --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.browser.test.tsx @@ -0,0 +1,83 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +// The thread route loads ChatView lazily; its first transform would eat the test's timeout. +import "~/components/ChatView"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + installFakeEnvironment, + threadWithHistory, + type FakeDriver, +} from "../../test/fakeEnvironment"; +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) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +it("keeps the title when dragging a search result from a collapsed folder", async () => { + await page.viewport(1400, 900); + const [first, second] = [threadWithHistory("visible", 1), threadWithHistory("search-target", 1)]; + using _environment = installFakeEnvironment([first, second]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: true, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + const folder = page.getByRole("button", { name: /^render/ }); + await expect.element(folder).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(folder); + await expect.element(page.getByText(second.title, { exact: true })).not.toBeInTheDocument(); + await userEvent.fill(page.getByRole("combobox", { name: "Search threads" }), "search-target"); + const result = page.getByRole("option", { name: /Thread search-target/ }); + await expect.element(result).toBeVisible(); + const bounds = result.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, + }); + using _release = { + [Symbol.dispose]: () => document.dispatchEvent(pointer("pointerup", -100, 100)), + }; + await act(() => { + result.element().dispatchEvent(pointer("pointerdown", bounds.x + 10, bounds.y + 10)); + document.dispatchEvent(pointer("pointermove", -100, 100)); + }); + await vi.waitFor(() => { + expect( + document.querySelector('body > div[aria-hidden="true"].pointer-events-none')?.textContent, + ).toBe(second.title); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.browser.test.tsx b/apps/web/src/components/Sidebar.logic.browser.test.tsx index 329783cf1862f..a323b5abdc094 100644 --- a/apps/web/src/components/Sidebar.logic.browser.test.tsx +++ b/apps/web/src/components/Sidebar.logic.browser.test.tsx @@ -1,7 +1,16 @@ +import { EnvironmentId, ThreadId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig, isMacPlatform } from "@t3tools/shared/keybindings"; import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../test/render"; -import { useSnoozeWakeClock, useThreadJumpHintsVisible } from "./Sidebar.logic"; +import { registerModelPickerContent } from "../modelPickerVisibility"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + THREAD_JUMP_HINT_SHOW_DELAY_MS, + useSnoozeWakeClock, + useThreadJumpHintsVisible, +} from "./Sidebar.logic"; const MOUNTED_AT_MS = Date.parse("2026-10-24T08:00:00.000Z"); @@ -32,28 +41,115 @@ describe("useSnoozeWakeClock", () => { }); }); -function JumpHints(props: { readonly held: boolean }) { - return {useThreadJumpHintsVisible(props.held) ? "shown" : "hidden"}; +const ROUTE_THREAD_REF = { + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("thread-1"), +}; + +function JumpHints(props: { readonly keybindings: ResolvedKeybindingsConfig }) { + const shown = useThreadJumpHintsVisible(props.keybindings, ROUTE_THREAD_REF); + return {shown ? "shown" : "hidden"}; } describe("useThreadJumpHintsVisible", () => { it("waits for the delay again on a second hold", async () => { - // The first render reads the clock. - using _timers = fakeTimers({ now: 0 }); - await using screen = await render(, { + const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+1", command: "thread.jump.1" }, + ]); + const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control"; + await using screen = await render(, { // A bare hook host; nothing here links or navigates. router: false, }); - await act(() => vi.advanceTimersByTime(1_000)); - await expect.element(screen.getByRole("status")).toHaveTextContent("shown"); + using _timers = fakeTimers(); + const output = screen.getByRole("status"); + + await userEvent.keyboard(`{${modifier}>}`); + await expect.element(output).toHaveTextContent("hidden"); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS)); + await expect.element(output).toHaveTextContent("shown"); + + await userEvent.keyboard(`{/${modifier}}`); + await expect.element(output).toHaveTextContent("hidden"); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS)); + + await userEvent.keyboard(`{${modifier}>}`); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS - 1)); + await expect.element(output).toHaveTextContent("hidden"); + await act(() => vi.advanceTimersByTime(1)); + await expect.element(output).toHaveTextContent("shown"); + await userEvent.keyboard(`{/${modifier}}`); + }); + + it("follows terminal focus and the route thread's terminal while the modifier is held", async () => { + using stack = new DisposableStack(); + const terminal = document.createElement("div"); + terminal.dataset.terminalOwner = "drawer"; + const terminalInput = document.createElement("textarea"); + terminal.append(terminalInput); + document.body.append(terminal); + stack.defer(() => terminal.remove()); + stack.defer(() => useTerminalUiStateStore.getState().setTerminalOpen(ROUTE_THREAD_REF, false)); + const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+1", command: "thread.jump.1", when: "terminalOpen" }, + ]); + const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control"; + await using screen = await render(, { + // A bare hook host; nothing here links or navigates. + router: false, + }); + using _timers = fakeTimers(); + const output = screen.getByRole("status"); + + await userEvent.keyboard(`{${modifier}>}`); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS)); + await expect.element(output).toHaveTextContent("hidden"); + await act(() => useTerminalUiStateStore.getState().setTerminalOpen(ROUTE_THREAD_REF, true)); + await expect.element(output).toHaveTextContent("shown"); + await act(() => terminalInput.focus()); + await expect.element(output).toHaveTextContent("hidden"); + await act(() => terminalInput.blur()); + await expect.element(output).toHaveTextContent("shown"); + await userEvent.keyboard(`{/${modifier}}`); + await expect.element(output).toHaveTextContent("hidden"); + }); - await screen.rerender(); - await expect.element(screen.getByRole("status")).toHaveTextContent("hidden"); - await act(() => vi.advanceTimersByTime(1_000)); + it("returns when the model picker closes while the modifier is still held", async () => { + const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+1", command: "thread.jump.1", when: "!modelPickerOpen" }, + ]); + const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control"; + await using screen = await render(, { + // A bare hook host; nothing here links or navigates. + router: false, + }); + using _timers = fakeTimers(); + const output = screen.getByRole("status"); + + await userEvent.keyboard(`{${modifier}>}`); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS)); + await expect.element(output).toHaveTextContent("shown"); + let unregisterModelPicker = () => {}; + await act(() => { + unregisterModelPicker = registerModelPickerContent(); + }); + await expect.element(output).toHaveTextContent("hidden"); + await act(() => unregisterModelPicker()); + await expect.element(output).toHaveTextContent("shown"); + await userEvent.keyboard(`{/${modifier}}`); + }); + + it("shows after the delay for a jump binding without a modifier", async () => { + const keybindings = compileResolvedKeybindingsConfig([{ key: "f1", command: "thread.jump.1" }]); + // Mounting starts the hold delay. + using _timers = fakeTimers(); + await using screen = await render(, { + // A bare hook host; nothing here links or navigates. + router: false, + }); + const output = screen.getByRole("status"); - await screen.rerender(); - await expect.element(screen.getByRole("status")).toHaveTextContent("hidden"); - await act(() => vi.advanceTimersByTime(1_000)); - await expect.element(screen.getByRole("status")).toHaveTextContent("shown"); + await act(() => vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS)); + await expect.element(output).toHaveTextContent("shown"); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef229d1424c38..5ed1320b59e55 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -20,6 +20,8 @@ import { isListedThreadLineage, type ContextMenuItem, type EnvironmentId, + type ResolvedKeybindingsConfig, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -39,9 +41,17 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { observeIntersection } from "../lib/observeIntersection"; -import { deadlineAfter, deadlineStore } from "@t3tools/client-runtime/clock"; +import { deadlineAfter, deadlineStore, type ClockStore } from "@t3tools/client-runtime/clock"; import { useDeadlinePassed } from "../hooks/useDeadline"; import { legacyProjectCwdPreferenceKey } from "../uiStateStore"; +import { isElectron } from "../env"; +import { shouldShowThreadJumpHintsForModifiers } from "../keybindings"; +import { isTerminalFocused, subscribeToTerminalFocusChanges } from "../lib/terminalFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isModelPickerOpen, subscribeToModelPickerVisibility } from "../modelPickerVisibility"; +import { shortcutModifierStore } from "../shortcutModifierState"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; export function shouldNavigateAfterThreadPark(input: { readonly threadKey: string; @@ -59,7 +69,7 @@ export function shouldNavigateAfterThreadPark(input: { ); } -const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; export function resolveSidebarRowAccessibility(input: { readonly title: string; @@ -670,19 +680,102 @@ type ThreadStatusInput = Pick< blockedOnThreadWait?: boolean | undefined; }; +// Read at event time: subscribing Sidebar to terminal or modifier state re-renders every row. +export function threadShortcutContext(routeThreadRef: ScopedThreadRef | null) { + return { + terminalFocus: isTerminalFocused(), + terminalOpen: + routeThreadRef !== null && + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen: + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview", + modelPickerOpen: isModelPickerOpen(), + isWeb: !isElectron, + isDesktop: isElectron, + }; +} + +/** Whether the current modifier combination, including none, has been held unchanged for `delayMs`. */ +function createModifierHoldStore(delayMs: number): ClockStore { + const listeners = new Set<() => void>(); + let held = false; + let stopDeadline = () => {}; + let stopModifiers: (() => void) | null = null; + + const setHeld = (next: boolean) => { + if (held === next) return; + held = next; + for (const listener of listeners) listener(); + }; + const onModifiersChange = () => { + stopDeadline(); + const deadline = deadlineStore(deadlineAfter(delayMs)); + stopDeadline = deadline.subscribe(() => setHeld(true)); + // A deadline another subscriber already passed never notifies a late joiner. + setHeld(deadline.getSnapshot()); + }; + + return { + subscribe: (listener) => { + listeners.add(listener); + if (stopModifiers === null) { + stopModifiers = shortcutModifierStore.subscribe(onModifiersChange); + onModifiersChange(); + } + return () => { + listeners.delete(listener); + if (listeners.size > 0) return; + stopModifiers?.(); + stopModifiers = null; + stopDeadline(); + stopDeadline = () => {}; + held = false; + }; + }, + getSnapshot: () => held, + }; +} + +const threadJumpModifierHold = createModifierHoldStore(THREAD_JUMP_HINT_SHOW_DELAY_MS); + +function subscribeToThreadJumpHintInputs(listener: () => void): () => void { + const unsubscribeHold = threadJumpModifierHold.subscribe(listener); + const unsubscribeFocus = subscribeToTerminalFocusChanges(listener); + const unsubscribeTerminals = useTerminalUiStateStore.subscribe(listener); + const unsubscribeRightPanels = useRightPanelStore.subscribe(listener); + const unsubscribeModelPicker = subscribeToModelPickerVisibility(listener); + return () => { + unsubscribeHold(); + unsubscribeFocus(); + unsubscribeTerminals(); + unsubscribeRightPanels(); + unsubscribeModelPicker(); + }; +} + /** * Thread jump hints appear once the jump modifier has been held for a moment, * so a quick shortcut never flashes them, and hide as soon as it is released. + * The snapshot is the visibility itself, so a quick shortcut never re-renders the caller. */ -export function useThreadJumpHintsVisible(shouldShow: boolean): boolean { - const [showAt, setShowAt] = React.useState(null); - // The clock is read inside the updater: the compiler caches a bare clock read - // in render once per instance, so a second hold would reuse the first deadline. - // React discards this render and re-runs with the update. - if (shouldShow !== (showAt !== null)) { - setShowAt(shouldShow ? () => deadlineAfter(THREAD_JUMP_HINT_SHOW_DELAY_MS) : null); - } - return useDeadlinePassed(showAt); +export function useThreadJumpHintsVisible( + keybindings: ResolvedKeybindingsConfig, + routeThreadRef: ScopedThreadRef | null, +): boolean { + return React.useSyncExternalStore( + subscribeToThreadJumpHintInputs, + () => + threadJumpModifierHold.getSnapshot() && + shouldShowThreadJumpHintsForModifiers(shortcutModifierStore.getSnapshot(), keybindings, { + platform: navigator.platform, + context: threadShortcutContext(routeThreadRef), + }), + ); } export function resolveThreadLastVisitedAt( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b4351..661b50a00b248 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,7 +1,6 @@ import { attachSidebarTree } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; -import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; import { dropThreadContext, endThreadContextDrag, @@ -92,17 +91,11 @@ import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand, - shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { useShortcutModifierState } from "../shortcutModifierState"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isPreviewFocused } from "../lib/previewFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { configuredHostedAppUrl } from "../hostedPairing"; @@ -131,7 +124,6 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; import { useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; -import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -210,6 +202,7 @@ import { sortThreadsForSidebar, sortWorkingThreadsBySend, useSnoozeWakeClock, + threadShortcutContext, useThreadJumpHintsVisible, type SidebarListItem, type SidebarSection, @@ -1064,8 +1057,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } | null>(null); const snoozingThreadKeysRef = useRef(new Set()); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); - const shortcutModifiers = useShortcutModifierState(); - const terminalFocused = useTerminalFocus(); const openAddProjectCommandPalette = () => openCommandPalette({ open: "add-project" }); const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; @@ -1766,8 +1757,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } restoreDragScroll(); const threads = contextDragThreads(); - const title = - threadByKeyRef.current.get(contextDragKeyRef.current ?? "")?.title.trim() || "Thread"; + const threadRef = parseScopedThreadKey(contextDragKeyRef.current ?? ""); + const title = (threadRef && readThreadShell(threadRef)?.title.trim()) || "Thread"; moveThreadContextDragGhost(point, { title, count: threads.length }); return true; }; @@ -2880,14 +2871,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ); - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const routePreviewOpen = useRightPanelStore( - (state) => selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview", - ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for thread navigation shortcuts using the current thread list useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -2896,15 +2879,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, - context: { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - previewFocus: isPreviewFocused(), - previewOpen: routePreviewOpen, - modelPickerOpen: isModelPickerOpen(), - isWeb: !isElectron, - isDesktop: isElectron, - }, + context: threadShortcutContext(routeThreadRef), }); const navigateToThreadKey = (targetThreadKey: string | null) => { if (!targetThreadKey) return false; @@ -2936,29 +2911,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve keybindings, navigateToThread, orderedThreadKeys, - routePreviewOpen, routeRowThreadKey, - routeTerminalOpen, + routeThreadRef, threadByKey, ]); - const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { - platform: navigator.platform, - context: { - terminalFocus: terminalFocused, - terminalOpen: routeTerminalOpen, - previewFocus: isPreviewFocused(), - previewOpen: routePreviewOpen, - modelPickerOpen: isModelPickerOpen(), - isWeb: !isElectron, - isDesktop: isElectron, - }, - }, - ); - const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); + const showJumpHints = useThreadJumpHintsVisible(keybindings, routeThreadRef); const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( @@ -2992,7 +2950,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> - ({ mode: "notifications", @@ -106,6 +107,7 @@ const thread = { archivedAt: null as string | null, hasPendingApprovals: false, hasPendingUserInput: false, + pendingRequestId: "request-1", session: null as { status: string } | null, latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null }, }; @@ -144,9 +146,9 @@ function toV2ThreadShell(input: typeof thread) { ? "failed" : "running", pendingRuntimeRequest: input.hasPendingUserInput - ? { id: "request-1", kind: "user_input", createdAt: SHELL_NOW } + ? { id: input.pendingRequestId, kind: "user_input", createdAt: SHELL_NOW } : input.hasPendingApprovals - ? { id: "request-1", kind: "command", createdAt: SHELL_NOW } + ? { id: input.pendingRequestId, kind: "command", createdAt: SHELL_NOW } : null, latestVisibleMessage: null, latestUserMessageAt: null, @@ -240,6 +242,18 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, ); +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "notifies again when a new %s request replaces the pending one in the same run", + async (flag) => { + await using app = setup(); + app.start(); + state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-1" })); + state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-2" })); + const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed"; + expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]); + }, +); + it("presents completion notifications and sound as soon as the shell completes", async () => { await using app = setup(); state.mode = "notifications-and-sound"; @@ -254,6 +268,69 @@ it("presents completion notifications and sound as soon as the shell completes", expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); }); +it.each([false, true])( + "presents one completion when a monitor ends with a final wake (disconnected: %s)", + async (disconnect) => { + await using app = setup(); + using _timers = fakeTimers(); + state.mode = "notifications-and-sound"; + app.start(); + const watching = ( + turnId: string, + turnState: string, + completedAt: string | null, + latestRunBackgroundWake: boolean, + pendingBackgroundTasks: ReadonlyArray<{ taskId: string; kind: "monitor" }>, + ) => ({ + status: "live", + snapshot: Option.some({ + threads: [ + { + ...toV2ThreadShell({ + ...thread, + latestTurn: { turnId, state: turnState, completedAt }, + }), + latestRunBackgroundWake, + pendingBackgroundTasks, + }, + ], + }), + }); + const monitor = [{ taskId: "watch", kind: "monitor" as const }]; + const titles = () => TestNotification.sent.map(({ title }) => title); + state.shells.set( + "one", + watching("turn", "completed", "2026-09-13T07:00:01.000Z", false, monitor), + ); + expect(titles()).toEqual(["Thread completed"]); + + state.shells.set("one", watching("wake", "running", null, true, monitor)); + state.shells.set( + "one", + watching("wake", "completed", "2026-09-13T07:00:09.000Z", true, monitor), + ); + expect(titles()).toEqual(["Thread completed"]); + + // The adapter drains the roster before the final wake starts. + state.shells.set("one", watching("wake", "completed", "2026-09-13T07:00:09.000Z", true, [])); + expect(titles()).toEqual(["Thread completed"]); + if (disconnect) { + state.shells.set("one", { + ...watching("wake", "completed", "2026-09-13T07:00:09.000Z", true, []), + status: "cached", + }); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(titles()).toEqual(["Thread completed"]); + } + + state.shells.set("one", watching("final", "running", null, false, [])); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + state.shells.set("one", watching("final", "completed", "2026-09-13T07:00:11.000Z", false, [])); + expect(titles()).toEqual(["Thread completed", "Thread completed"]); + expect(state.sound).toHaveBeenCalledTimes(2); + }, +); + it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "notifies for offline completion and %s once across reconnects", async (flag) => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index a89e4159cae10..965dba45ce8a2 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -2,21 +2,36 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { createThreadNotificationGate, + RELEASED_COMPLETION_GRACE_MS, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; const working: ThreadNotificationObservation = { attention: null, + requestId: null, settled: false, completedAt: null, + backgroundWake: false, }; const completed = (at: number): ThreadNotificationObservation => ({ attention: null, + requestId: null, settled: true, completedAt: at, + backgroundWake: false, +}); +const waking: ThreadNotificationObservation = { ...working, backgroundWake: true }; +const woke = (at: number): ThreadNotificationObservation => ({ + ...completed(at), + backgroundWake: true, }); const visible = { archived: false, quiet: false }; +function fakeTimers(): Disposable { + vi.useFakeTimers(); + return { [Symbol.dispose]: () => void vi.useRealTimers() }; +} + function harness() { const notify = vi.fn(); const gate = createThreadNotificationGate(notify); @@ -54,17 +69,32 @@ describe("createThreadNotificationGate", () => { expect(notify).toHaveBeenCalledTimes(2); }); - it("notifies attention once and still notifies the subsequent completion", () => { + it("notifies each attention once per stretch and still notifies the subsequent completion", () => { const { notify, observe } = harness(); observe(working); - const attention = { attention: "run-1:approval", settled: false, completedAt: null }; + const attention = { ...working, attention: "run-1:approval:a", requestId: "a" }; observe(attention); expect(notify.mock.calls).toEqual([["thread", "input"]]); observe({ ...attention }); expect(notify).toHaveBeenCalledTimes(1); + observe({ ...attention, attention: "run-1:approval:b", requestId: "b" }); + observe(attention); + expect(notify).toHaveBeenCalledTimes(2); + observe(working); + observe(attention); + expect(notify).toHaveBeenCalledTimes(3); + const subagent = { ...attention, attention: "run-1:approval:", requestId: null }; + observe(subagent); + observe(attention); + observe(subagent); + expect(notify).toHaveBeenCalledTimes(5); observe(working); observe(completed(1)); expect(notify.mock.calls).toEqual([ + ["thread", "input"], + ["thread", "input"], + ["thread", "input"], + ["thread", "input"], ["thread", "input"], ["thread", "completion"], ]); @@ -74,7 +104,7 @@ describe("createThreadNotificationGate", () => { const { notify, observe } = harness(); observe(working); observe(completed(1)); - observe({ attention: "run-1:limited", settled: false, completedAt: null }); + observe({ ...working, attention: "run-1:limited" }); observe(completed(1)); expect(notify.mock.calls).toEqual([ ["thread", "completion"], @@ -110,4 +140,58 @@ describe("createThreadNotificationGate", () => { expect(notify.mock.calls).toEqual([["thread", "completion"]]); } }); + + it("waits out the grace before notifying a wake's completion, and a final wake replaces it", () => { + using _timers = fakeTimers(); + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + // Held until the monitor left, or completing after it already had: either way unheld here. + observe(waking); + observe(woke(2)); + expect(notify).toHaveBeenCalledTimes(1); + observe(working); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(notify).toHaveBeenCalledTimes(1); + observe(completed(3)); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(notify.mock.calls).toEqual([ + ["thread", "completion"], + ["thread", "completion"], + ]); + }); + + it("notifies a wake's completion once after the grace when no run follows, even across a pause", () => { + using _timers = fakeTimers(); + const { gate, notify, observe } = harness(); + observe(waking); + observe(woke(1)); + gate.pause(); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(notify).not.toHaveBeenCalled(); + observe({ ...woke(1) }); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS - 1); + observe({ ...woke(1) }); + expect(notify).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); + observe({ ...woke(1) }); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("drops a pending wake completion when the thread is removed or the gate is cleared", () => { + using _timers = fakeTimers(); + for (const reset of [ + (gate: ReturnType>) => gate.retain(new Set()), + (gate: ReturnType>) => gate.clear(), + ]) { + const { gate, notify, observe } = harness(); + observe(waking); + observe(woke(1)); + reset(gate); + vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS); + expect(notify).not.toHaveBeenCalled(); + } + }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 1fe126ad37da3..c8f0ca7df7a9a 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,22 +1,64 @@ export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Run and status key while the thread needs attention, otherwise null. */ + /** Run, status and request key while the thread needs attention, otherwise null. */ readonly attention: string | null; + /** The pending request's id, when it has one; null whenever attention is. */ + readonly requestId: string | null; /** The thread is idle and its latest run completed. */ readonly settled: boolean; /** When the latest run completed, if it did. */ readonly completedAt: number | null; + /** The latest run answers a background-work progress wake, like a Monitor line. */ + readonly backgroundWake: boolean; } +/** Matches the push relay's completion confirmation: a final wake can start right after a progress wake. */ +export const RELEASED_COMPLETION_GRACE_MS = 5_000; + export function createThreadNotificationGate( notify: (key: Key, kind: ThreadNotificationKind) => void, ) { // The newest completion already notified, or deliberately skipped. const handled = new Map(); + // Wake completions waiting out the grace, by completion time; no timer while paused. + const releases = new Map< + Key, + { readonly at: number; readonly timer: ReturnType | undefined } + >(); + const release = (key: Key, at: number) => { + const timer = setTimeout(() => { + releases.delete(key); + handled.set(key, at); + notify(key, "completion"); + }, RELEASED_COMPLETION_GRACE_MS); + releases.set(key, { at, timer }); + }; const skip = (key: Key, at: number | null) => { if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at); }; + const cancel = (key: Key) => { + clearTimeout(releases.get(key)?.timer); + releases.delete(key); + }; + // Attention keys with a request id seen since the thread last stopped needing attention. + const attended = new Map>(); + const attend = ( + key: Key, + prior: ThreadNotificationObservation | undefined, + next: ThreadNotificationObservation, + ): boolean => { + if (next.attention === null) { + attended.delete(key); + return false; + } + if (next.requestId === null) return next.attention !== prior?.attention; + const seen = attended.get(key); + if (seen?.has(next.attention)) return false; + if (seen) seen.add(next.attention); + else attended.set(key, new Set([next.attention])); + return true; + }; return { observe( key: Key, @@ -24,24 +66,47 @@ export function createThreadNotificationGate( next: ThreadNotificationObservation, options: { readonly archived: boolean; readonly quiet: boolean }, ): void { + // The grace survives updates that keep the wake's completion; anything else cancels it. + const pending = releases.get(key); + if (pending?.at === next.completedAt && !options.archived && !options.quiet) { + if (pending.timer === undefined) release(key, pending.at); + return; + } + cancel(key); // The first sighting, archived threads, and stopped or silent runs never notify completion. if (!prior || options.archived || options.quiet) skip(key, next.completedAt); + const fresh = attend(key, prior, next); if (!prior || options.archived) return; - if (next.attention && next.attention !== prior.attention) { + if (fresh) { notify(key, "input"); return; } const at = next.completedAt; if (!next.settled || at === null || at <= (handled.get(key) ?? -Infinity)) return; + if (next.backgroundWake) { + release(key, at); + return; + } handled.set(key, at); notify(key, "completion"); }, + /** Stops grace timers while the snapshot is stale; the next observation restarts or drops them. */ + pause(): void { + for (const [key, { at, timer }] of releases) { + clearTimeout(timer); + releases.set(key, { at, timer: undefined }); + } + }, /** Forgets threads that left the snapshot. */ retain(keys: ReadonlySet): void { for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); + for (const key of releases.keys()) if (!keys.has(key)) cancel(key); + for (const key of attended.keys()) if (!keys.has(key)) attended.delete(key); }, clear(): void { + for (const key of releases.keys()) cancel(key); handled.clear(); + attended.clear(); }, }; } diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index a7134b481d131..2051d4ad633a8 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -7,6 +7,7 @@ import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import type { AnyRouter } from "@tanstack/react-router"; import { threadKey } from "@t3tools/client-runtime/state/entities"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; +import { monitorHoldsWakeCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import type { ClientSettings } from "@t3tools/contracts/settings"; import * as Option from "effect/Option"; import type { AtomRegistry } from "effect/reactivity"; @@ -30,6 +31,7 @@ import { threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; +import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement"; import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; import { createThreadNotificationGate, @@ -252,24 +254,30 @@ export function startThreadNotifications( const thread = presentThreadShell(environmentId, rawThread, subagentRequest); let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; - const attention = + const needsAttention = status === "input" || status === "request" || status === "approval" || status === "failed" || - status === "limited" - ? `${thread.latestRun?.runId ?? ""}:${status}` - : null; - const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); - const settled = status === "ready" && thread.latestRun?.status === "completed"; + status === "limited"; + const requestId = needsAttention ? resolveBackgroundThreadRequest(thread).requestId : null; + const attention = needsAttention + ? `${thread.latestRun?.runId ?? ""}:${status}:${requestId ?? ""}` + : null; + const completed = status === "ready" && thread.latestRun?.status === "completed"; + const settled = completed && !monitorHoldsWakeCompletion(rawThread); + const parsedCompletedAt = Date.parse(thread.latestRun?.completedAt ?? ""); + const completedAt = Number.isFinite(parsedCompletedAt) ? parsedCompletedAt : null; const state = { raw: rawThread, subagentRequest, thread, status, attention, + requestId, settled, - completedAt: settled && Number.isFinite(completedAt) ? completedAt : null, + completedAt: settled ? completedAt : null, + backgroundWake: rawThread.latestRunBackgroundWake === true, }; next.set(thread.id, state); changed.push([prior, state]); @@ -308,6 +316,7 @@ export function startThreadNotifications( return; } entry.reconnecting = true; + entry.gate.pause(); }, { immediate: true }, ); diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 30ab2465a9dec..a7b7da0a6b53b 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,7 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; import { visibilityParentKey } from "@t3tools/client-runtime/state/threads"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Navigate, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; @@ -16,8 +17,9 @@ import { useAnyEnvironmentHiding, useQuietThreadShell, } from "../state/entities"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironmentShellStatus } from "../state/shell"; -import { environmentThreadShells, useEnvironmentThread } from "../state/threads"; +import { environmentThreadShells, threadFamilies, useEnvironmentThread } from "../state/threads"; import { buildThreadRouteParams, isThreadRouteSnapshotAuthoritative, @@ -25,6 +27,24 @@ import { type ThreadRouteTarget, } from "../threadRoutes"; +/** + * Keeps the family of an open thread the shell leaves out (an older settled thread) in the shell + * rows until the route leaves it, so every reader of its shell row finds it. + */ +function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean) { + const key = threadRef === null ? null : scopedThreadKey(threadRef); + // Sticks to the route thread, so the row the family brings in does not release it. + const [mountedKey, setMountedKey] = useState(null); + const nextKey = missing || mountedKey === key ? key : null; + if (nextKey !== mountedKey) setMountedKey(nextKey); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the open thread's family in the thread family registry until the route leaves it + useEffect(() => { + const ref = mountedKey === null ? null : parseScopedThreadKey(mountedKey); + if (ref === null) return; + return threadFamilies.mount(appAtomRegistry, ref.environmentId, ref.threadId); + }, [mountedKey]); +} + /** * Unkeyed so navigating between server threads reuses one ChatView. Rendered by the `_chat` * layout, since an element only survives a route swap when the same parent renders it. Draft @@ -88,6 +108,7 @@ export function ThreadRouteView({ unlistedThreadRef?.environmentId ?? null, unlistedThreadRef?.threadId ?? null, ); + useOpenThreadFamily(serverThreadRef, bootstrapComplete && unlistedThreadRef !== null); const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); const unlistedThread = Option.getOrNull(Option.map(unlistedThreadDetail.data, (p) => p.thread)); const unlistedParentKey = diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 45318dd598133..049589d0b671a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1790,6 +1790,17 @@ export default function ThreadTerminalDrawer({ }; }, [isPanel, visible]); + const resizeHandle = isPanel ? null : ( +
+ ); + if (normalizedTerminalIds.length === 0) { return (