From fd7ee2c30a7eeef910c99e7bc92e944526555db7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 16:07:50 -0700 Subject: [PATCH 01/82] chore(lint): forbid declaring tests inside for loops (#14921) Co-authored-by: Claude Opus 5.5 (1M context) --- .../scripts/browser-secret-native.test.mjs | 14 +- apps/desktop/src/app/DesktopClerk.test.ts | 8 +- apps/desktop/src/app/DesktopLifecycle.test.ts | 9 +- .../src/app/DesktopPreReadyPlatform.test.ts | 92 +- apps/desktop/src/app/DesktopUserData.test.ts | 70 +- .../BrowserImport/FirefoxCookies.test.ts | 74 +- .../src/preview/BrowserImport/Sources.test.ts | 16 +- .../settings/DesktopClientSettings.test.ts | 138 +- .../src/snapShot/DesktopSnapShot.test.ts | 9 +- .../remoteRegistration.test.ts | 98 +- apps/server/src/cli/app.test.ts | 8 +- apps/server/src/device/DeviceService.test.ts | 56 +- .../Adapters/AcpAdapterV2.test.ts | 16 +- .../Adapters/ClaudeAdapterV2.test.ts | 242 +-- .../Adapters/CodexAdapterV2.test.ts | 633 +++--- .../Adapters/CursorAdapterV2.test.ts | 10 +- .../Adapters/GrokAdapterV2.test.ts | 24 +- .../Adapters/OpenCode2AdapterV2.test.ts | 319 ++- .../Adapters/OpenCodeAdapterV2.test.ts | 24 +- .../Adapters/PiAdapterV2.test.ts | 171 +- .../Adapters/piT3McpExtensionSource.test.ts | 9 +- .../ContextHandoffBudget.test.ts | 92 +- .../FoundationPersistence.test.ts | 122 +- .../orchestration-v2/LiveStreamBudget.test.ts | 16 +- ...penCode2OrchestratorV2.integration.test.ts | 108 +- .../orchestration-v2/ProjectCommands.test.ts | 9 +- .../ProjectionControlReads.test.ts | 22 +- .../ProjectionSettlement.test.ts | 339 ++- .../ProviderEventIngestor.test.ts | 8 +- .../ProviderSessionManager.test.ts | 8 +- .../ProviderSwitchService.test.ts | 93 +- .../RestartContinuation.test.ts | 108 +- .../RunExecutionService.test.ts | 16 +- .../RunFinalizationService.test.ts | 154 +- .../SelectionRestart.integration.test.ts | 330 ++- .../SteeringCompletion.integration.test.ts | 706 +++--- .../ThreadFork.execution.test.ts | 366 ++-- .../ThreadLaunchService.test.ts | 314 ++- .../ThreadManagementService.test.ts | 136 +- .../ThreadTitleRegenerationService.test.ts | 8 +- .../src/orchestration-v2/runtimeLayer.test.ts | 560 +++-- ...estratorReplayFixtures.integration.test.ts | 83 +- .../ProviderSwitch.integration.test.ts | 1930 ++++++++--------- .../ThreadMergeBack.integration.test.ts | 14 +- .../Layers/OrchestrationEventStore.test.ts | 8 +- .../reconcileV2PreviewMigration.test.ts | 8 +- .../src/provider/CodexChatGptAuth.test.ts | 225 +- .../src/provider/CodexInstallation.test.ts | 42 +- .../src/provider/CodexManagedRuntime.test.ts | 332 ++- .../provider/acp/AcpJsonRpcConnection.test.ts | 8 +- .../pullRequest/PullRequestService.test.ts | 294 ++- apps/server/src/serverSettings.test.ts | 149 +- .../GitHubSourceControlProvider.test.ts | 16 +- .../GitLabSourceControlProvider.test.ts | 16 +- apps/server/src/terminal/Manager.test.ts | 8 +- .../src/terminal/NodePtyAdapter.test.ts | 32 +- .../src/terminal/OutputProtocol.test.ts | 114 +- .../ClaudeTextGeneration.test.ts | 152 +- .../CodexTextGeneration.test.ts | 56 +- .../CursorTextGeneration.test.ts | 16 +- apps/server/src/usage/UsageService.test.ts | 165 +- apps/server/src/usage/cliproxyApi.test.ts | 24 +- apps/server/src/vcs/GitVcsDriver.test.ts | 752 +++---- apps/server/src/vcs/GitVcsDriverCore.test.ts | 274 ++- .../components/chat/queuedMessageEdit.test.ts | 35 +- apps/web/src/keybindings.test.ts | 18 +- .../src/agentActivity/ApnsClient.test.ts | 148 +- .../src/agentActivity/ApnsDeliveries.test.ts | 147 +- .../src/agentActivity/FcmDeliveries.test.ts | 148 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-test-in-loop.test.ts | 114 + oxlint-plugin-t3code/rules/no-test-in-loop.ts | 79 + .../src/connection/resolver.test.ts | 8 +- .../src/operations/commands.test.ts | 136 +- .../client-runtime/src/rpc/session.test.ts | 157 +- .../client-runtime/src/state/assets.test.ts | 208 +- .../src/state/pullRequests.test.ts | 396 ++-- .../src/state/threadCommands.test.ts | 92 +- .../src/state/threads-sync.test.ts | 24 +- .../contracts/src/providerInstance.test.ts | 62 +- packages/effect-acp/src/protocol.test.ts | 8 +- packages/shared/src/gitPatchPath.test.ts | 16 +- scripts/build-desktop-artifact.test.ts | 56 +- scripts/dev-runner.test.ts | 46 +- scripts/legend-list-initial-reveal.test.ts | 9 +- vite.config.ts | 1 + 86 files changed, 6133 insertions(+), 6050 deletions(-) create mode 100644 oxlint-plugin-t3code/rules/no-test-in-loop.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-test-in-loop.ts diff --git a/apps/desktop/scripts/browser-secret-native.test.mjs b/apps/desktop/scripts/browser-secret-native.test.mjs index 754a91f1342c..bba047636ba6 100644 --- a/apps/desktop/scripts/browser-secret-native.test.mjs +++ b/apps/desktop/scripts/browser-secret-native.test.mjs @@ -78,7 +78,7 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { expect(result.stderr.length).toBe(0); }); - for (const [scenario, code] of [ + it.each([ ["missing", 2], ["empty", 2], ["locked", 3], @@ -86,13 +86,11 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { ["denied", 3], ["unavailable", 4], ["unloaded", 4], - ]) { - it(`reports ${scenario} without emitting a secret`, () => { - const result = run([scenario]); - expect(result.status).toBe(code); - expect(result.stdout.length).toBe(0); - }); - } + ])("reports %s without emitting a secret", (scenario, code) => { + const result = run([scenario]); + expect(result.status).toBe(code); + expect(result.stdout.length).toBe(0); + }); it("rejects invalid arguments before accessing the keyring", () => { for (const args of [[], [""], ["chrome", "extra"]]) { const result = run(args); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 339675bd65a6..b629301b1691 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -303,8 +303,9 @@ it.effect( }, ); -for (const entry of ["startup", "open-url"] as const) { - it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () => +it.effect.each(["startup", "open-url"] as const)( + "receives hosted web sign-in through the desktop %s handler", + (entry) => Effect.gen(function* () { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); @@ -382,5 +383,4 @@ for (const entry of ["startup", "open-url"] as const) { ), ); }).pipe(Effect.scoped), - ); -} +); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 33c74f5a8b9b..9e24f9db3e05 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -101,8 +101,9 @@ function makeDesktopWindowLayer( } describe("DesktopLifecycle", () => { - for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { - it.effect(`lets the updater's quit event proceed on ${platform}`, () => { + it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray)( + "lets the updater's quit event proceed on %s", + (platform) => { const appListeners = new Map void>(); let windowsDestroyed = false; const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { @@ -152,8 +153,8 @@ describe("DesktopLifecycle", () => { assert.isTrue(yield* Ref.get(state.quitting)); }), ).pipe(Effect.provide(layer)); - }); - } + }, + ); it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 7e859aaf981a..a2d4483e6d2b 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -81,54 +81,52 @@ describe("DesktopPreReadyPlatform", () => { ); }); - for (const previousEntry of [undefined, 'Exec="/Applications/deleted-previous.AppImage" %U']) { - it.effect( - `prepares a ${previousEntry ? "stale" : "missing"} Linux desktop entry before startup yields`, - () => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); - getSwitchValueMock.mockReturnValue(""); - let desktopName = "t3code.desktop"; - let desktopEntry = previousEntry; - let iconInstalled = false; - copyFileSyncMock.mockImplementation((_source: string, destination: string) => { - iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; - }); - setDesktopNameMock.mockImplementation((name: string) => { - desktopName = name; - }); - writeFileSyncMock.mockImplementation((path: string, contents: string) => { - if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; - }); + it.effect.each([ + { previousEntry: undefined, label: "missing" }, + { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, + ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); + getSwitchValueMock.mockReturnValue(""); + let desktopName = "t3code.desktop"; + let desktopEntry = previousEntry; + let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + }); + setDesktopNameMock.mockImplementation((name: string) => { + desktopName = name; + }); + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; + }); - return Effect.scoped( - Effect.gen(function* () { - const portalIdentity = Promise.resolve().then(() => ({ - desktopName, - desktopEntry, - iconInstalled, - })); - yield* Layer.build( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), - ), - ); - const identity = yield* Effect.promise(() => portalIdentity); - assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); - assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); - assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); - assert.include( - identity.desktopEntry ?? "", - "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", - ); - assert.isTrue(identity.iconInstalled); - }), - ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); - }, - ); - } + return Effect.scoped( + Effect.gen(function* () { + const portalIdentity = Promise.resolve().then(() => ({ + desktopName, + desktopEntry, + iconInstalled, + })); + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ); + const identity = yield* Effect.promise(() => portalIdentity); + assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); + assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); + assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); + assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); + assert.include( + identity.desktopEntry ?? "", + "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", + ); + assert.isTrue(identity.iconInstalled); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); it.effect("keeps startup available when the early desktop entry cannot be written", () => { getSwitchValueMock.mockReturnValue(""); diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 374948ec921d..41e497463a0d 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -37,39 +37,37 @@ it.effect("identifies a failed source read and preserves its cause", () => { ); }); -for (const sourceName of ["t3code", "T3 Code (Alpha)"]) { - it.effect( - `preserves Windows credential keys from ${sourceName} without copying browser databases`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); - const source = path.join(directory, sourceName); - const destination = path.join(directory, "t3code-v2"); - const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; - yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); - yield* fs.writeFileString(path.join(source, "Local State"), state); - yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); - yield* resolveUserDataPath({ - appDataDirectory: directory, - isDevelopment: false, - platform: "win32", - }); - assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); - assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); - assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); - yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); - yield* resolveUserDataPath({ - appDataDirectory: directory, - isDevelopment: false, - platform: "win32", - }); - assert.equal( - yield* fs.readFileString(path.join(destination, "Local State")), - "existing V2 state", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} +it.effect.each(["t3code", "T3 Code (Alpha)"])( + "preserves Windows credential keys from %s without copying browser databases", + (sourceName) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); + const source = path.join(directory, sourceName); + const destination = path.join(directory, "t3code-v2"); + const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; + yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); + yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); + yield* fs.writeFileString(path.join(source, "Local State"), state); + yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); + yield* resolveUserDataPath({ + appDataDirectory: directory, + isDevelopment: false, + platform: "win32", + }); + assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); + assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); + assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); + yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); + yield* resolveUserDataPath({ + appDataDirectory: directory, + isDevelopment: false, + platform: "win32", + }); + assert.equal( + yield* fs.readFileString(path.join(destination, "Local State")), + "existing V2 state", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 84e7678cce4a..17f9ff9b9867 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -386,45 +386,43 @@ describe("parseFirefoxProfiles", () => { }), ); - for (const [platform, root] of [ - ["Linux", "/home/user/.mozilla/firefox"], - ["macOS", "/Users/user/Library/Application Support/Firefox"], - ] as const) { - it.effect(`validates relative and absolute ${platform} profile paths`, () => - Effect.gen(function* () { - const parsed = yield* parsePosixFirefoxProfiles( - [ - "[Profile0]", - "Name=Relative", - "IsRelative=1", - "Path=Profiles/relative.default", - "[Profile1]", - "Name=Custom", - "IsRelative=0", - "Path=/mnt/custom/firefox-profile", - "[Profile2]", - "IsRelative=1", - "Path=../../escape", - "[Profile3]", - "IsRelative=1", - "Path=/absolute-marked-relative", - "[Profile4]", - "IsRelative=0", - "Path=relative-marked-absolute", - "[Profile5]", - "IsRelative=1", - "Path=Profiles/nul\u0000escape", - ].join("\n"), - root, - ); + it.effect.each([ + { platform: "Linux", root: "/home/user/.mozilla/firefox" }, + { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" }, + ])("validates relative and absolute $platform profile paths", ({ root }) => + Effect.gen(function* () { + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles/relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=/mnt/custom/firefox-profile", + "[Profile2]", + "IsRelative=1", + "Path=../../escape", + "[Profile3]", + "IsRelative=1", + "Path=/absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + "[Profile5]", + "IsRelative=1", + "Path=Profiles/nul\u0000escape", + ].join("\n"), + root, + ); - expect(parsed).toEqual([ - { directory: "Profiles/relative.default", name: "Relative" }, - { directory: "/mnt/custom/firefox-profile", name: "Custom" }, - ]); - }), - ); - } + expect(parsed).toEqual([ + { directory: "Profiles/relative.default", name: "Relative" }, + { directory: "/mnt/custom/firefox-profile", name: "Custom" }, + ]); + }), + ); it.effect("uses Windows path rules for relative and absolute profiles", () => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index a867f78497b4..df27c4f96f1a 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -763,8 +763,9 @@ describe("listSourceProfiles Firefox fallback", () => { { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") }, ]; - for (const { platform, profileDirectory } of cases) { - it.effect(`scans the ${platform} profile location and excludes stale entries`, () => + it.effect.each(cases)( + "scans the $platform profile location and excludes stale entries", + ({ platform, profileDirectory }) => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -800,8 +801,7 @@ describe("listSourceProfiles Firefox fallback", () => { ]); }), ), - ); - } + ); it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => run( @@ -1166,8 +1166,9 @@ describe("Safari profiles", () => { ), ); - for (const metadataState of ["missing", "corrupt"] as const) { - it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => + it.effect.each(["missing", "corrupt"] as const)( + "recovers separate cookie stores when metadata is %s", + (metadataState) => run( Effect.gen(function* () { const { context, store, metadata } = yield* fixture(); @@ -1181,8 +1182,7 @@ describe("Safari profiles", () => { assert.isTrue(yield* isSourceInstalled(safari, context)); }), ), - ); - } + ); it.effect("keeps Safari without profiles available", () => run( diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index e77d08344644..94fe587136ef 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -149,58 +149,56 @@ describe("DesktopClientSettings", () => { ), ); - for (const failure of [ + it.effect.each([ { label: "permission", reason: "PermissionDenied" }, { label: "I/O", reason: "Unknown" }, - ] as const) { - it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - const savedSettings = { - ...clientSettings, - onboardingCompletedAt: "2026-09-05T12:00:00.000Z", - }; - yield* settings.set(savedSettings); - const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); - const cause = PlatformError.systemError({ - _tag: failure.reason, - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: environment.clientSettingsPath, - }); - let failRead = true; - const retryableSettings = yield* DesktopClientSettings.make.pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.FileSystem.of({ - ...fileSystem, - readFileString: (path) => - Effect.suspend(() => - failRead ? Effect.fail(cause) : fileSystem.readFileString(path), - ), - }), - ), - ); + ] as const)("preserves saved preferences across $label read failures and retries", (failure) => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); - const error = yield* retryableSettings.get.pipe(Effect.flip); - assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); - assert.equal(error.operation, "read-file"); - assert.equal(error.path, environment.clientSettingsPath); - assert.strictEqual(error.cause, cause); - assert.equal( - yield* fileSystem.readFileString(environment.clientSettingsPath), - savedContents, - ); + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); - failRead = false; - assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); - }), - ), - ); - } + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); it.effect("reports the failed client settings write operation and path", () => withClientSettings( @@ -288,31 +286,29 @@ describe("DesktopClientSettings", () => { ), ); - for (const document of [ + it.effect.each([ { label: "malformed JSON", contents: "{not-json" }, { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, - ]) { - it.effect(`reports ${document.label} without treating the settings file as absent`, () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); + ])("reports $label without treating the settings file as absent", (document) => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - const error = yield* settings.get.pipe(Effect.flip); - assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); - assert.equal(error.operation, "decode-document"); - assert.equal(error.path, environment.clientSettingsPath); - assert.instanceOf(error.cause, Schema.SchemaError); - assert.equal( - yield* fileSystem.readFileString(environment.clientSettingsPath), - document.contents, - ); - }), - ), - ); - } + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); }); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 205a72a55265..b5ba16d614ca 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -3873,8 +3873,9 @@ it.effect("waits to apply settings while permissions are pending", () => { ).pipe(Effect.provide(layer)); }); -for (const fails of [false, true]) { - it.effect(`tests macOS capture without publishing it and cleans up, failure=${fails}`, () => { +it.effect.each([false, true])( + "tests macOS capture without publishing it and cleans up, failure=%s", + (fails) => { const active = { platform: "macos", id: 42, @@ -3915,8 +3916,8 @@ for (const fails of [false, true]) { }), ), ); - }); -} + }, +); it.effect("rejects macOS test capture on other platforms", () => Effect.scoped( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 994f8852e173..836e529e5965 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -949,59 +949,57 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); - for (const os of ["ios", "android"] as const) { - it.effect( - `does not enable ${os} notifications when a token rotates after permission is revoked`, - () => { - vi.spyOn(Platform, "OS", "get").mockReturnValue(os); - vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); - vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ - type: os, - data: "initial", + it.effect.each(["ios", "android"] as const)( + "does not enable %s notifications when a token rotates after permission is revoked", + (os) => { + vi.spyOn(Platform, "OS", "get").mockReturnValue(os); + vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: os, + data: "initial", + }); + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + registrations.push(await request.json()); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: true }, }); - const registrations: unknown[] = []; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init); - if (request.url.endsWith("/v1/client/dpop-token")) { - return Response.json({ - access_token: "dpop", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - }); - } - registrations.push(await request.json()); - return Response.json({ ok: true }); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as Awaited>); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: os, data: "rotated" }); + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: false }, }); - Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: true }, - }); - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ - granted: false, - } as Awaited>); - const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; - listener({ type: os, data: "rotated" }); - yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: false }, - }); - expect(registrations.at(-1)).not.toHaveProperty("pushToken"); - }).pipe( - Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), - Effect.provide( - managedRelayClientLayer("https://permission-relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), - ), + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide( + managedRelayClientLayer("https://permission-relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), ), - ); - }, - ); - } + ), + ); + }, + ); it.effect("preserves relay rejection errors with React Native response headers", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index fe5feadaf619..e478f66f0731 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -256,8 +256,9 @@ describe("t3 app", () => { ), ); - for (const responseKind of ["failure", "invalid"] as const) { - it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => + it.effect.each(["failure", "invalid"] as const)( + "never falls back after the default desktop sends a %s response", + (responseKind) => withTempDirectory("t3-app-response-test-", (root) => Effect.gen(function* () { vi.mocked(NodeOS.homedir).mockReturnValue(root); @@ -302,6 +303,5 @@ describe("t3 app", () => { } }).pipe(Effect.scoped), ), - ); - } + ); }); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 83a9d2f7e15b..9a9663533259 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -361,29 +361,39 @@ describe("device discovery after server restart", () => { ); }); -for (const [diagnostic, reason, message] of [ - ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"], - ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"], - ["Unexpected failure: secret-token", "launch_failed", "could not start"], -] as const) { - it.effect(`normalizes boot failure: ${reason}`, () => - Effect.gen(function* () { - const { service } = yield* fixture(Effect.void, diagnostic); - yield* service.configure({ enabled: true }); - const error = yield* service - .open({ - threadId: ThreadId.make("boot-failure"), - deviceId: "Pixel_API_35", - platform: "android", - }) - .pipe(Effect.flip); - expect(error._tag).toBe("DeviceBootError"); - expect(error.message).toContain(message); - expect(error.message).not.toContain(diagnostic); - expect((yield* service.state).bootingDevices).toEqual([]); - }).pipe(Effect.scoped), - ); -} +it.effect.each([ + { + diagnostic: "Insufficient disk space at /private/user/path", + reason: "disk_space", + message: "not enough free disk space", + }, + { + diagnostic: "Timed out spawning /private/user/command", + reason: "timeout", + message: "did not become ready in time", + }, + { + diagnostic: "Unexpected failure: secret-token", + reason: "launch_failed", + message: "could not start", + }, +] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) => + Effect.gen(function* () { + const { service } = yield* fixture(Effect.void, diagnostic); + yield* service.configure({ enabled: true }); + const error = yield* service + .open({ + threadId: ThreadId.make("boot-failure"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.flip); + expect(error._tag).toBe("DeviceBootError"); + expect(error.message).toContain(message); + expect(error.message).not.toContain(diagnostic); + expect((yield* service.state).bootingDevices).toEqual([]); + }).pipe(Effect.scoped), +); it.effect("keeps shutdown successful when subsequent discovery fails", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 33b91614b4da..146924586763 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -604,8 +604,9 @@ function makeTurnInput(input: { } describe("AcpAdapterV2", () => { - for (const outcome of ["failed", "recovered", "completed", "cancelled"] as const) { - it.live(`projects Mistral retry notices and their ${outcome} outcome`, () => + it.live.each(["failed", "recovered", "completed", "cancelled"] as const)( + "projects Mistral retry notices and their %s outcome", + (outcome) => Effect.gen(function* () { const path = yield* Path.Path; const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`); @@ -692,8 +693,7 @@ describe("AcpAdapterV2", () => { assert.equal(retries.at(-1)?.title, "Provider recovered"); } }).pipe(Effect.provide(testLayer), Effect.scoped), - ); - } + ); it("preserves legacy ids and scopes v2 ids by provider instance", () => { const instanceId = ProviderInstanceId.make("acp-identity-test"); @@ -2914,8 +2914,9 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(testLayer)), ); - for (const model of ["grok-build", "composer-2"]) { - it.effect(`Grok configures the native session for ${model}`, () => + it.effect.each(["grok-build", "composer-2"])( + "Grok configures the native session for %s", + (model) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -2968,8 +2969,7 @@ describe("AcpAdapterV2", () => { model === "grok-build" ? 0 : 1, ); }).pipe(Effect.provide(testLayer), Effect.scoped), - ); - } + ); it.live("Grok reapplies an explicit return to the session's setup-time model", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6d3d1f9cdd07..95f8753ae688 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2252,78 +2252,78 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); - for (const terminalReason of ["aborted_tools", "aborted_streaming"] as const) { - for (const steered of [true, false]) { - it.effect(`handles ${terminalReason} with active steering=${steered}`, () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attemptId = RunAttemptId.make("attempt-steering-abort"); - const input = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId, - text: "Audit the settings pages.", + it.effect.each( + (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => + [true, false].map((steered) => ({ terminalReason, steered })), + ), + )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attemptId = RunAttemptId.make("attempt-steering-abort"); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Audit the settings pages.", + attachments: [], + }); + yield* harness.runtime.startTurn(input); + if (steered) { + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-steering-abort"), + text: "Include the hierarchy mock.", attachments: [], - }); - yield* harness.runtime.startTurn(input); - if (steered) { - yield* harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }), - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-steering-abort"), - text: "Include the hierarchy mock.", - attachments: [], - }, - }); - assert.equal(harness.offeredMessages[1]?.priority, "now"); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000901", - result: "", - terminalReason, - }), - ); - if (steered) { - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000902", - result: "Audit finished after the steer.", - }), - ); - } - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, steered ? "completed" : "interrupted"); - if (steered) { - assert.isTrue( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === WAKE_ASSISTANT_TEXT, - ), - ); - } - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - } - } + }, + }); + assert.equal(harness.offeredMessages[1]?.priority, "now"); + } + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000901", + result: "", + terminalReason, + }), + ); + if (steered) { + yield* Queue.offer(harness.sdkMessages, wakeAssistant); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000902", + result: "Audit finished after the steer.", + }), + ); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, steered ? "completed" : "interrupted"); + if (steered) { + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === WAKE_ASSISTANT_TEXT, + ), + ); + } + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); it.effect("announces usage-limit pauses once per window and again on a new turn", () => Effect.gen(function* () { @@ -3187,7 +3187,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - for (const terminalReason of [ + it.effect.each([ "api_error", "malformed_tool_use_exhausted", "budget_exhausted", @@ -3200,56 +3200,54 @@ describe("ClaudeAdapterV2 background wake turns", () => { "image_error", "model_error", "overloaded_status", - ] as const) { - it.effect(`fails a success-shaped Claude result with ${terminalReason}`, () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), - text: "Complete the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000205", - result: "Provider failure details.", - isError: false, - ...(terminalReason === "overloaded_status" - ? { apiErrorStatus: 529 } - : { terminalReason }), - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.isNotEmpty(terminal.failure.message); - assert.equal( - terminal.failure.class, - terminalReason === "blocking_limit" ? "usage_limit" : "provider_error", - ); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === "Provider failure details.", - ), - ); - assert.equal( - terminal.failure.code, - terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, - ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - } + ] as const)("fails a success-shaped Claude result with %s", (terminalReason) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), + text: "Complete the task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000205", + result: "Provider failure details.", + isError: false, + ...(terminalReason === "overloaded_status" + ? { apiErrorStatus: 529 } + : { terminalReason }), + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.isNotEmpty(terminal.failure.message); + assert.equal( + terminal.failure.class, + terminalReason === "blocking_limit" ? "usage_limit" : "provider_error", + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Provider failure details.", + ), + ); + assert.equal( + terminal.failure.code, + terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); const providerThreadRosterEvents = (events: ReadonlyArray) => events.filter( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 27fadf2c4381..3145e33dabd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1728,8 +1728,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { }; }); - for (const response of ["supported", "unsupported", "invalid"] as const) { - it.effect(`delivers native history with ${response} app-server protocol`, () => + it.effect.each(["supported", "unsupported", "invalid"] as const)( + "delivers native history with %s app-server protocol", + (response) => Effect.gen(function* () { const nativeThreadId = `inject-${response}`; const prompt = "Only the current request"; @@ -1830,8 +1831,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(requests.filter((method) => method === "turn/start").length, 1); assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ); - } + ); it.effect("identifies sessions to Codex with the same client info as main", () => Effect.gen(function* () { @@ -2926,8 +2926,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const terminalStatus of ["completed", "interrupted"] as const) { - it.effect(`retains Codex reasoning parts when the turn is ${terminalStatus}`, () => + it.effect.each(["completed", "interrupted"] as const)( + "retains Codex reasoning parts when the turn is %s", + (terminalStatus) => Effect.scoped( Effect.gen(function* () { const scenario = `codex-reasoning-${terminalStatus}`; @@ -3087,8 +3088,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.deepEqual(assistantMessages(harness.events), []); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), - ); - } + ); const finalAnswerTranscript = ( scenario: string, @@ -3714,9 +3714,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const terminated of [true, false, "still_running"] as const) { + const backgroundStopCases = [true, false, "still_running"] as const; + const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { const stillRunning = terminated === "still_running"; - const transcript = makeCodexReplayTranscript({ + return makeCodexReplayTranscript({ scenario: `codex-bg-stop-${terminated}`, entries: [ ...backgroundExecTranscript.entries.slice(0, -1), @@ -3796,9 +3797,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { backgroundExecTranscript.entries.at(-1)!, ], }); + }; - it.effect(`stops a command after root completion when termination returns ${terminated}`, () => - Effect.scoped( + it.effect.each(backgroundStopCases)( + "stops a command after root completion when termination returns %s", + (terminated) => { + const stillRunning = terminated === "still_running"; + const transcript = makeBackgroundStopTranscript(terminated); + return Effect.scoped( Effect.gen(function* () { const stopped = yield* Deferred.make(); const harness = yield* makeCodexReplayHarness(transcript, (event) => @@ -3852,140 +3858,137 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.lengthOf(harness.continuationRequests, 0); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - if (terminated === true) { - it.effect("interrupts a completed run's background command through orchestration", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(transcript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), - ), - ); - const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - assert.equal( - Number( - yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd })), - ), - 0, - ); - assert.equal( - Number( - yield* spawner.exitCode( - ChildProcess.make( - "git", - [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "--quiet", - "-m", - "Initial commit", - ], - { cwd }, - ), - ), - ), - 0, - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop"), - threadId, - projectId: ProjectId.make("project:background-stop"), - title: "Background stop", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const waiting = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "waiting", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop"), - threadId, - messageId: MessageId.make("message:background-stop"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - assert.isNull((yield* Ref.get(replayDriver.state)).failure); - yield* Fiber.join(waiting); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs.at(-1)!; - assert.equal(run.status, "completed"); - assert.equal( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, - 1, - ); - const stopped = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "command_execution" && - event.payload.status === "interrupted", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("stop-background-command"), - threadId, - runId: run.id, - }); - yield* worker.drain(); - yield* Fiber.join(stopped); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "completed", - ); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, - makeCodexProviderAdapterRegistryReplayLayer({ - transcript: localTranscript, - driver: replayDriver, - }), - { runEffectWorker: false }, - ), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), - ), ); - } - } + }, + ); + it.effect("interrupts a completed run's background command through orchestration", () => { + const transcript = makeBackgroundStopTranscript(true); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(transcript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), + ); + const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + assert.equal( + Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), + 0, + ); + assert.equal( + Number( + yield* spawner.exitCode( + ChildProcess.make( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "--quiet", + "-m", + "Initial commit", + ], + { cwd }, + ), + ), + ), + 0, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:background-stop"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-stop"), + threadId, + projectId: ProjectId.make("project:background-stop"), + title: "Background stop", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const waiting = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-background-stop"), + threadId, + messageId: MessageId.make("message:background-stop"), + text: BG_PROMPT, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + assert.isNull((yield* Ref.get(replayDriver.state)).failure); + yield* Fiber.join(waiting); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.at(-1)!; + assert.equal(run.status, "completed"); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, + 1, + ); + const stopped = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + event.payload.status === "interrupted", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop-background-command"), + threadId, + runId: run.id, + }); + yield* worker.drain(); + yield* Fiber.join(stopped); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "completed", + ); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, + makeCodexProviderAdapterRegistryReplayLayer({ + transcript: localTranscript, + driver: replayDriver, + }), + { runEffectWorker: false }, + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); // The app-server exits after the root turn, before the command's own // item/completed (Codex always sends one, so only a lost notification or a @@ -5506,7 +5509,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const scenario of [ + it.effect.each([ { name: "usage", code: "usageLimitExceeded", @@ -5557,192 +5560,190 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedClass: "usage_limit", }, { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, - ] as const) { - it.effect(`classifies Codex terminal failures from ${scenario.name} evidence`, () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = `native-limit-${scenario.name}`; - const nativeTurnId = `turn-limit-${scenario.name}`; - const message = "Provider stopped this request."; - const resetAt = "2033-05-19T07:20:00.000Z"; - const snapshot = { - type: "emit_inbound" as const, - label: "account/rateLimits/updated", - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, - }, + ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = `native-limit-${scenario.name}`; + const nativeTurnId = `turn-limit-${scenario.name}`; + const message = "Provider stopped this request."; + const resetAt = "2033-05-19T07:20:00.000Z"; + const snapshot = { + type: "emit_inbound" as const, + label: "account/rateLimits/updated", + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, }, }, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-limit-${scenario.name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" - ? [snapshot] - : []), - ...(scenario.name === "deferred-reset" - ? [ - { - type: "emit_inbound" as const, - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "subAgentActivity", - id: "limit-child-spawn", - kind: "started", - agentThreadId: "native-limit-child", - agentPath: "/root/limit_child", - }, + }, + }; + const transcript = makeCodexReplayTranscript({ + scenario: `codex-limit-${scenario.name}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" + ? [snapshot] + : []), + ...(scenario.name === "deferred-reset" + ? [ + { + type: "emit_inbound" as const, + label: "item/completed/subAgentActivity-started", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { + type: "subAgentActivity", + id: "limit-child-spawn", + kind: "started", + agentThreadId: "native-limit-child", + agentPath: "/root/limit_child", }, }, }, - { - type: "emit_inbound" as const, - label: "turn/started/child", - frame: { - method: "turn/started", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "inProgress", - }), - }, + }, + { + type: "emit_inbound" as const, + label: "turn/started/child", + frame: { + method: "turn/started", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "inProgress", + }), }, }, - ] - : []), - ...(scenario.notification - ? [ - { - type: "emit_inbound" as const, - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: scenario.name === "retry", - error: { - message, - codexErrorInfo: scenario.code, - additionalDetails: - scenario.name === "matching-details" - ? "Detailed provider allowance explanation." - : null, - }, + }, + ] + : []), + ...(scenario.notification + ? [ + { + type: "emit_inbound" as const, + label: "error", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: scenario.name === "retry", + error: { + message, + codexErrorInfo: scenario.code, + additionalDetails: + scenario.name === "matching-details" + ? "Detailed provider allowance explanation." + : null, }, }, }, - ] - : []), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message: scenario.name === "replacement" ? "A different failure." : message, - ...(scenario.notification && scenario.name !== "matching-details" - ? {} - : { codexErrorInfo: scenario.code }), - }, + }, + ] + : []), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message: scenario.name === "replacement" ? "A different failure." : message, + ...(scenario.notification && scenario.name !== "matching-details" + ? {} + : { codexErrorInfo: scenario.code }), }, }, }, }, - ...(scenario.name === "late-reset" ? [snapshot] : []), - ...(scenario.name === "deferred-reset" - ? [ - { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { - usedPercent: 100, - resetsAt: 2000200000, - windowDurationMins: 300, - }, + }, + ...(scenario.name === "late-reset" ? [snapshot] : []), + ...(scenario.name === "deferred-reset" + ? [ + { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { + usedPercent: 100, + resetsAt: 2000200000, + windowDurationMins: 300, }, }, }, }, - { - type: "emit_inbound" as const, - label: "turn/completed/child", - frame: { - method: "turn/completed", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "completed", - }), - }, + }, + { + type: "emit_inbound" as const, + label: "turn/completed/child", + frame: { + method: "turn/completed", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "completed", + }), }, }, - ] - : []), - ], - }); - const resetReceipt = 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, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), - }), + }, + ] + : []), + ], + }); + const resetReceipt = 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, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.class, scenario.expectedClass); + assert.equal(terminal.threadDisposition, "reusable"); + if (scenario.name === "known-reset" || scenario.name === "deferred-reset") + assert.equal(terminal.failure.resetAt, resetAt); + if (scenario.name === "matching-details") + assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); + if (scenario.name === "late-reset") { + yield* Deferred.await(resetReceipt); + const item = harness.events.find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt, ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, scenario.expectedClass); - assert.equal(terminal.threadDisposition, "reusable"); - if (scenario.name === "known-reset" || scenario.name === "deferred-reset") - assert.equal(terminal.failure.resetAt, resetAt); - if (scenario.name === "matching-details") - assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); - if (scenario.name === "late-reset") { - yield* Deferred.await(resetReceipt); - const item = harness.events.find( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt, - ); - assert.isDefined(item); - } - if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - } + assert.isDefined(item); + } + if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); const FAILED_SCENARIO = "codex-failed-mid-command"; const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; @@ -6511,7 +6512,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const [nativeStatus, expectedStatus] of [ + it.effect.each([ ["pendingInit", "pending"], ["running", "running"], ["interrupted", "interrupted"], @@ -6523,8 +6524,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ["late-activity-completed", "completed"], ["stale-running", "completed"], ["duplicate-completed", "completed"], - ] as const) { - it.effect(`normalizes subagent ${nativeStatus} without losing its lifecycle`, () => + ] as const)( + "normalizes subagent %s without losing its lifecycle", + ([nativeStatus, expectedStatus]) => Effect.scoped( Effect.gen(function* () { const marker = yield* Deferred.make(); @@ -6670,8 +6672,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), - ); - } + ); const codexReplayThreadResult = (input: { readonly nativeThreadId: string; diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index d31e91eac08c..3a172baa67c8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -39,12 +39,13 @@ import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAge const decodeCursorSettings = Schema.decodeEffect(CursorSettings); describe("CursorAdapterV2", () => { - for (const { status, model } of [ + it.effect.each([ { status: "finished", model: undefined }, { status: "cancelled", model: "claude-opus-4-6" }, { status: "error", model: "custom-fable" }, - ] as const) { - it.effect(`settles missing task completions when the Cursor run is ${status}`, () => + ] as const)( + "settles missing task completions when the Cursor run is $status", + ({ status, model }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -185,8 +186,7 @@ describe("CursorAdapterV2", () => { ); assert.isNotNull(rows.at(-1)?.subagent.completedAt); }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), - ); - } + ); it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 60be8b82f067..281882c9eb55 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -371,17 +371,19 @@ describe("Grok launch permission mode", () => { ...override, }); - for (const [runtimeMode, args] of [ - ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], - ["auto", ["--permission-mode", "auto", "agent", "stdio"]], - ["full-access", ["agent", "--always-approve", "stdio"]], - ] as const) { - it.effect(`launches ${runtimeMode} threads with ${args.join(" ")}`, () => - Effect.gen(function* () { - assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]); - }), - ); - } + it.effect.each( + ( + [ + ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], + ["auto", ["--permission-mode", "auto", "agent", "stdio"]], + ["full-access", ["agent", "--always-approve", "stdio"]], + ] as const + ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const), + )("launches %s threads with %s", ([runtimeMode, , args]) => + Effect.gen(function* () { + assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]); + }), + ); it.effect("launches a thread stored as Auto-accept edits asking", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e321a1369cc0..b437ae9cf926 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -3090,81 +3090,78 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), ); - for (const running of [true, false]) { - it.effect( - running - ? "refuses a rollback while a timed-out Stop's run is still going" - : "rolls back once a timed-out Stop's run has left the server", - () => - Effect.gen(function* () { - const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; - const { runtime, thread } = yield* resumed([ - ...stopTimedOut, - // The server says whether the stopped run still goes. - out("session.active"), - reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), - // Gone: the cut is made. Running: nothing is read or cut meanwhile. - ...(running - ? [] - : [ - out("message.list", ""), - reply("message.list", { - data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }], - cursor: {}, - }), - out("session.revert.stage", { - sessionID: SESSION, - messageID: prompt, - files: false, - }), - replyData("session.revert.stage", { messageID: prompt, files: [] }), - out("session.revert.commit", { sessionID: SESSION }), - reply("session.revert.commit", null), - out("message.list", ""), - reply("message.list", { data: [], cursor: {} }), - ]), - ]); - yield* stopFirstTurn(runtime, thread); - const now = yield* DateTime.now; - const first = { - id: yield* providerTurnId, - providerThreadId: thread.id, - nodeId: NodeId.make("node:opencode2-adapter"), - runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"), - nativeTurnRef: { - driver: OPENCODE_PROVIDER, - nativeId: prompt, - strength: "weak" as const, - }, - ordinal: 1, - status: "interrupted" as const, - startedAt: now, - completedAt: now, - }; - const rollback = yield* runtime - .rollbackThread({ - providerThread: thread, - target: { - type: "thread_start", - checkpointId: CheckpointId.make("checkpoint:start"), - appRunOrdinal: 0, - }, - providerThreadTurns: [first], - }) - .pipe(Effect.exit); - if (running) { - assert.isTrue(Exit.isFailure(rollback)); - const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; - assert.equal( - (error as { _tag?: string } | undefined)?._tag, - "ProviderAdapterProtocolError", - ); - } else { - assert.isTrue(Exit.isSuccess(rollback)); - } - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - } + it.effect.each([ + ["refuses a rollback while a timed-out Stop's run is still going", true], + ["rolls back once a timed-out Stop's run has left the server", false], + ] as const)("%s", ([, running]) => + Effect.gen(function* () { + const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; + const { runtime, thread } = yield* resumed([ + ...stopTimedOut, + // The server says whether the stopped run still goes. + out("session.active"), + reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), + // Gone: the cut is made. Running: nothing is read or cut meanwhile. + ...(running + ? [] + : [ + out("message.list", ""), + reply("message.list", { + data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }], + cursor: {}, + }), + out("session.revert.stage", { + sessionID: SESSION, + messageID: prompt, + files: false, + }), + replyData("session.revert.stage", { messageID: prompt, files: [] }), + out("session.revert.commit", { sessionID: SESSION }), + reply("session.revert.commit", null), + out("message.list", ""), + reply("message.list", { data: [], cursor: {} }), + ]), + ]); + yield* stopFirstTurn(runtime, thread); + const now = yield* DateTime.now; + const first = { + id: yield* providerTurnId, + providerThreadId: thread.id, + nodeId: NodeId.make("node:opencode2-adapter"), + runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"), + nativeTurnRef: { + driver: OPENCODE_PROVIDER, + nativeId: prompt, + strength: "weak" as const, + }, + ordinal: 1, + status: "interrupted" as const, + startedAt: now, + completedAt: now, + }; + const rollback = yield* runtime + .rollbackThread({ + providerThread: thread, + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint:start"), + appRunOrdinal: 0, + }, + providerThreadTurns: [first], + }) + .pipe(Effect.exit); + if (running) { + assert.isTrue(Exit.isFailure(rollback)); + const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; + assert.equal( + (error as { _tag?: string } | undefined)?._tag, + "ProviderAdapterProtocolError", + ); + } else { + assert.isTrue(Exit.isSuccess(rollback)); + } + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); it.effect("takes back a stranded steer again when its first cancel failed", () => Effect.gen(function* () { @@ -3376,92 +3373,92 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); - for (const running of [true, false]) { - it.effect( - running - ? "refuses to roll back a session not loaded yet while the server runs it" - : "keeps the recorded turns before the target when rolling back a session not loaded yet", - () => - Effect.gen(function* () { - const first = `msg_t3_turn_${SESSION}:attempt:first`; - const second = `msg_t3_turn_${SESSION}:attempt:second`; - // A runtime that never loaded the session, as after a T3 restart - // against a server that kept running. - const runtime = yield* openCode2ReplayRuntime([ - ...opening, - out("session.active"), - reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), - ...(running - ? [] - : [ - out("session.get", { sessionID: SESSION }), - replyData("session.get", sessionInfo()), - ...noOpenRequests, - out("message.list", ""), - reply("message.list", { - data: [ - { id: second, time: { created: 2 }, text: "second", type: "user" }, - { id: first, time: { created: 1 }, text: "first", type: "user" }, - ], - cursor: {}, - }), - out("session.revert.stage", { - sessionID: SESSION, - messageID: second, - files: false, - }), - replyData("session.revert.stage", { messageID: second, files: [] }), - out("session.revert.commit", { sessionID: SESSION }), - reply("session.revert.commit", null), - out("message.list", ""), - reply("message.list", { - data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }], - cursor: {}, - }), - ]), - ]); - const thread = providerThread(yield* DateTime.now); - const now = yield* DateTime.now; - const recorded = (key: string, ordinal: number, nativeId: string) => ({ - id: ProviderTurnId.make(`provider-turn:${key}`), - providerThreadId: thread.id, - nodeId: NodeId.make(`node:${key}`), - runAttemptId: RunAttemptId.make(`attempt:${key}`), - nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const }, - ordinal, - status: "completed" as const, - startedAt: now, - completedAt: now, - }); - const kept = recorded("first", 1, first); - const rollback = yield* runtime - .rollbackThread({ - providerThread: thread, - target: { - type: "provider_turn", - checkpointId: CheckpointId.make("checkpoint:first"), - appRunOrdinal: 1, - providerTurn: kept, - }, - providerThreadTurns: [kept, recorded("second", 2, second)], - }) - .pipe(Effect.exit); - if (running) { - const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; - assert.equal( - (error as { _tag?: string } | undefined)?._tag, - "ProviderAdapterProtocolError", - ); - } else { - assert.isTrue(Exit.isSuccess(rollback)); - assert.deepEqual( - Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [], - [kept.id], - ); - } - }).pipe(Effect.scoped), - ); - } + it.effect.each([ + ["refuses to roll back a session not loaded yet while the server runs it", true], + [ + "keeps the recorded turns before the target when rolling back a session not loaded yet", + false, + ], + ] as const)("%s", ([, running]) => + Effect.gen(function* () { + const first = `msg_t3_turn_${SESSION}:attempt:first`; + const second = `msg_t3_turn_${SESSION}:attempt:second`; + // A runtime that never loaded the session, as after a T3 restart + // against a server that kept running. + const runtime = yield* openCode2ReplayRuntime([ + ...opening, + out("session.active"), + reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), + ...(running + ? [] + : [ + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + ...noOpenRequests, + out("message.list", ""), + reply("message.list", { + data: [ + { id: second, time: { created: 2 }, text: "second", type: "user" }, + { id: first, time: { created: 1 }, text: "first", type: "user" }, + ], + cursor: {}, + }), + out("session.revert.stage", { + sessionID: SESSION, + messageID: second, + files: false, + }), + replyData("session.revert.stage", { messageID: second, files: [] }), + out("session.revert.commit", { sessionID: SESSION }), + reply("session.revert.commit", null), + out("message.list", ""), + reply("message.list", { + data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }], + cursor: {}, + }), + ]), + ]); + const thread = providerThread(yield* DateTime.now); + const now = yield* DateTime.now; + const recorded = (key: string, ordinal: number, nativeId: string) => ({ + id: ProviderTurnId.make(`provider-turn:${key}`), + providerThreadId: thread.id, + nodeId: NodeId.make(`node:${key}`), + runAttemptId: RunAttemptId.make(`attempt:${key}`), + nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + }); + const kept = recorded("first", 1, first); + const rollback = yield* runtime + .rollbackThread({ + providerThread: thread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint:first"), + appRunOrdinal: 1, + providerTurn: kept, + }, + providerThreadTurns: [kept, recorded("second", 2, second)], + }) + .pipe(Effect.exit); + if (running) { + const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; + assert.equal( + (error as { _tag?: string } | undefined)?._tag, + "ProviderAdapterProtocolError", + ); + } else { + assert.isTrue(Exit.isSuccess(rollback)); + assert.deepEqual( + Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [], + [kept.id], + ); + } + }).pipe(Effect.scoped), + ); it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 3a3f9a2af6cb..eca4aa9115a4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -237,8 +237,9 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { - for (const ending of ["completed", "failed", "unresolved", "unavailable", "reconnect"] as const) { - it.effect(`normalizes OpenCode step usage for ${ending} turns`, () => + it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( + "normalizes OpenCode step usage for %s turns", + (ending) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); let promptId = ""; @@ -375,11 +376,11 @@ describe("OpenCodeAdapterV2", () => { }, ); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - } + ); - for (const kind of ["permission", "question"] as const) { - it.effect(`cancels an undelivered ${kind} reply at its deadline`, () => + it.effect.each(["permission", "question"] as const)( + "cancels an undelivered %s reply at its deadline", + (kind) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const called = promiseGate(); @@ -453,8 +454,7 @@ describe("OpenCodeAdapterV2", () => { // Failed delivery leaves the request available for an explicit retry. yield* harness.runtime.respondToRuntimeRequest(response); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - } + ); it.effect("aborts external root and descendants before closing the event stream", () => Effect.gen(function* () { @@ -494,8 +494,9 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); - for (const failure of ["enumeration", "abort", "not-found", "timeout"] as const) { - it.effect(`reports descendant cleanup ${failure}`, () => + it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( + "reports descendant cleanup %s", + (failure) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const called = promiseGate(); @@ -545,8 +546,7 @@ describe("OpenCodeAdapterV2", () => { assert.equal(Exit.isSuccess(result), failure === "not-found"); if (failure === "timeout") assert.isTrue(childSignal?.aborted); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - } + ); it.effect( "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 9d1f11d6e287..fb912714f12f 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -589,8 +589,9 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - for (const invalidReplacement of ["veto", "same identity"] as const) { - it.effect(`rejects a replacement with ${invalidReplacement}`, () => + it.effect.each(["veto", "same identity"] as const)( + "rejects a replacement with %s", + (invalidReplacement) => Effect.gen(function* () { const fake = yield* makeFakePi; const { runtime } = yield* openRuntime(fake); @@ -617,8 +618,7 @@ describe("PiAdapterV2", () => { yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip); assert.isFalse(fake.allRequests().some((request) => request.type === "prompt")); }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - } + ); it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () => Effect.gen(function* () { @@ -983,90 +983,85 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - for (const historical of [false, true]) { - it.effect( - `natively forks ${historical ? "a historical turn" : "the latest turn"} into an independent session`, - () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - const forkFake = yield* makeFakePi; - const forkFile = "/fake/forked.jsonl"; - const { runtime, takeEvent } = yield* openRuntime( - fake, - "default", - THREAD_ID, - SESSION_ID, - forkFake, - ); - const source = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ - id: ProviderTurnId.make(`turn-${ordinal}`), - providerThreadId: source.id, - nodeId: NodeId.make(`node-${ordinal}`), - runAttemptId: null, - nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, - ordinal, - status: "completed", - startedAt: null, - completedAt: null, - }); - forkFake.queueState({ sessionFile: forkFile }); - fake.queueState({ sessionFile: forkFile }); - const target = ThreadId.make("fork-target"); - const forked = yield* runtime.forkThread({ - sourceProviderThread: source, - sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], - providerTurnId: turn(1).id, - targetThreadId: target, - }); - assert.equal(forked.appThreadId, target); - assert.equal(forked.nativeThreadRef?.nativeId, forkFile); - assert.notEqual(forked.id, source.id); - assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); - const args = forkFake.lastSpawn().args; - assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); - assert.include(args, "--no-extensions"); - assert.include(args, "--no-tools"); - assert.notInclude(args, "--no-session"); - assert.deepEqual( - forkFake - .allRequests() - .filter((request) => request.type === "fork") - .map((request) => request.entryId), - historical ? ["u2"] : [], - ); - assert.isFalse( - fake - .allRequests() - .some((request) => request.type === "fork" || request.type === "clone"), - ); - // ProviderTurnStartService adopts the fork into its pending row. - const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; - yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); - yield* fake.emit({ type: "agent_start" }); - yield* fake.emit({ type: "agent_settled" }); - const updated = yield* takeEvent( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.appThreadId === target, - ); - assert.isTrue( - updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, - ); - yield* takeEvent((event) => event.type === "turn.terminal"); - yield* runtime.resumeThread({ providerThread: adopted }); - assert.equal( - fake.allRequests().findLast((request) => request.type === "switch_session") - ?.sessionPath, - forkFile, - ); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - } + it.effect.each([ + { historical: false, label: "the latest turn" }, + { historical: true, label: "a historical turn" }, + ])("natively forks $label into an independent session", ({ historical }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const forkFake = yield* makeFakePi; + const forkFile = "/fake/forked.jsonl"; + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + forkFake, + ); + const source = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`turn-${ordinal}`), + providerThreadId: source.id, + nodeId: NodeId.make(`node-${ordinal}`), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + forkFake.queueState({ sessionFile: forkFile }); + fake.queueState({ sessionFile: forkFile }); + const target = ThreadId.make("fork-target"); + const forked = yield* runtime.forkThread({ + sourceProviderThread: source, + sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], + providerTurnId: turn(1).id, + targetThreadId: target, + }); + assert.equal(forked.appThreadId, target); + assert.equal(forked.nativeThreadRef?.nativeId, forkFile); + assert.notEqual(forked.id, source.id); + assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); + const args = forkFake.lastSpawn().args; + assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); + assert.include(args, "--no-extensions"); + assert.include(args, "--no-tools"); + assert.notInclude(args, "--no-session"); + assert.deepEqual( + forkFake + .allRequests() + .filter((request) => request.type === "fork") + .map((request) => request.entryId), + historical ? ["u2"] : [], + ); + assert.isFalse( + fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"), + ); + // ProviderTurnStartService adopts the fork into its pending row. + const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; + yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const updated = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.appThreadId === target, + ); + assert.isTrue( + updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + yield* runtime.resumeThread({ providerThread: adopted }); + assert.equal( + fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath, + forkFile, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); it.effect("observes official subagent results without inventing child threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts index 2d448e3b7a53..9dc9fa4526e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts @@ -28,8 +28,9 @@ async function loadRequestHook(): Promise { } describe("Pi upstream output-budget workaround", () => { - for (const key of ["max_tokens", "max_completion_tokens"]) { - it(`caps ${key} without changing the conversation or tools`, async () => { + it.each(["max_tokens", "max_completion_tokens"])( + "caps %s without changing the conversation or tools", + async (key) => { const hook = await loadRequestHook(); const payload = { model: "moonshotai/kimi-k2.6", @@ -43,8 +44,8 @@ describe("Pi upstream output-budget workaround", () => { assert.strictEqual(result?.tools, payload.tools); assert.equal(result?.model, payload.model); assert.equal(payload[key], 231_969); - }); - } + }, + ); it("preserves smaller budgets and other providers' payloads", async () => { const hook = await loadRequestHook(); diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 067b851d6a14..2c6c878b25e9 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -413,55 +413,51 @@ describe("handoff budget", () => { }); describe("handoff delivery", () => { - for (const native of [true, false]) { - it.effect( - `records omitted recovery coverage separately from ${native ? "injected" : "inline"} text`, - () => - Effect.gen(function* () { - const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); - const oversized = message("item:oversized", "user", "x".repeat(20_000)); - let durable: OrchestrationV2ContextHandoff = { - ...handoff, - history: { - ...handoff.history!, - messages: [...messages, oversized], - omittedItems: 1, - omittedItemIds: [omittedBeforeDelivery], - }, - }; - const result = yield* deliverContextHandoffs({ - handoffs: [durable], - providerThread, - budget: 16_000, - alreadyDeliveredItemIds: new Set(), - inject: (value) => { - assert.include(value.context, "omitted 2 items"); - assert.notInclude( - value.messages.map((item) => item.itemId), - oversized.itemId, - ); - return Effect.succeed(native); - }, - persist: (value) => - Effect.sync(() => { - durable = value; - }), - }); - assert.equal(durable.delivery?.status, native ? "injected" : "pending"); - yield* result.delivered; - assert.equal(durable.delivery?.status, native ? "injected" : "inline"); - assert.deepEqual( - durable.delivery?.itemIds, - messages.map((item) => item.itemId), - ); - assert.deepEqual(durable.delivery?.omittedItemIds, [ - omittedBeforeDelivery, + it.effect.each([ + { native: true, label: "injected" }, + { native: false, label: "inline" }, + ])("records omitted recovery coverage separately from $label text", ({ native }) => + Effect.gen(function* () { + const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); + const oversized = message("item:oversized", "user", "x".repeat(20_000)); + let durable: OrchestrationV2ContextHandoff = { + ...handoff, + history: { + ...handoff.history!, + messages: [...messages, oversized], + omittedItems: 1, + omittedItemIds: [omittedBeforeDelivery], + }, + }; + const result = yield* deliverContextHandoffs({ + handoffs: [durable], + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + inject: (value) => { + assert.include(value.context, "omitted 2 items"); + assert.notInclude( + value.messages.map((item) => item.itemId), oversized.itemId, - ]); - assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); - }), - ); - } + ); + return Effect.succeed(native); + }, + persist: (value) => + Effect.sync(() => { + durable = value; + }), + }); + assert.equal(durable.delivery?.status, native ? "injected" : "pending"); + yield* result.delivered; + assert.equal(durable.delivery?.status, native ? "injected" : "inline"); + assert.deepEqual( + durable.delivery?.itemIds, + messages.map((item) => item.itemId), + ); + assert.deepEqual(durable.delivery?.omittedItemIds, [omittedBeforeDelivery, oversized.itemId]); + assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); + }), + ); it.effect("loads the history budget only when a handoff needs delivery", () => Effect.gen(function* () { let reads = 0; diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index b7f6e642cffe..22b93178711c 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -433,8 +433,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { ), ); - for (const phase of ["high-water", "replay"] as const) { - it.effect(`bounds live events while the V2 ${phase} query is blocked`, () => + it.effect.each(["high-water", "replay"] as const)( + "bounds live events while the V2 %s query is blocked", + (phase) => Effect.scoped( Effect.gen(function* () { const sink = yield* EventSink.EventSinkV2; @@ -486,8 +487,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { } }), ), - ); - } + ); it.effect( "keeps internal streams subscribed while replay is blocked beyond the RPC buffer cap", @@ -2661,68 +2661,66 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { }), ); - for (const replayRequest of [ + it.effect.each([ { type: "terminal.cleanup" }, { type: "provider-runtime.continue", sourceRunId: RunId.make("run:restart-replay") }, - ] as const) { - it.effect( - `retires live provider effects and requeues ${replayRequest.type} after process loss`, - () => - Effect.gen(function* () { - const outbox = yield* EffectOutbox.EffectOutboxV2; - const commandId = CommandId.make( - `command:foundation-reclaim-running:${replayRequest.type}`, - ); - yield* outbox.enqueue([ - { - id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, - commandId, - threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`), - request: { - type: "provider-turn.start", - runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`), - }, - }, - { - id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`, - commandId, - threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`), - request: replayRequest, + ] as const)( + "retires live provider effects and requeues $type after process loss", + (replayRequest) => + Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + const commandId = CommandId.make( + `command:foundation-reclaim-running:${replayRequest.type}`, + ); + yield* outbox.enqueue([ + { + id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, + commandId, + threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`), + request: { + type: "provider-turn.start", + runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`), }, - ]); - assert.isTrue( - Option.isSome( - yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), - ), - ); - assert.isTrue( - Option.isSome( - yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), - ), - ); - assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, { - cancelled: 1, - requeued: 1, - }); - const cancelled = yield* outbox.get( - `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, - ); - assert.isTrue(Option.isSome(cancelled)); - if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled"); + }, + { + id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`, + commandId, + threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`), + request: replayRequest, + }, + ]); + assert.isTrue( + Option.isSome( + yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), + ), + ); + assert.isTrue( + Option.isSome( + yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), + ), + ); + assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, { + cancelled: 1, + requeued: 1, + }); + const cancelled = yield* outbox.get( + `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, + ); + assert.isTrue(Option.isSome(cancelled)); + if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled"); - const reclaimed = yield* outbox.claimNext({ - workerId: "recovery-worker", - leaseDurationMs: 30_000, - }); - assert.isTrue(Option.isSome(reclaimed)); - if (Option.isSome(reclaimed)) { - assert.equal(reclaimed.value.request.type, replayRequest.type); - assert.equal(reclaimed.value.attemptCount, 2); - yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" }); - } - }), - ); - } + const reclaimed = yield* outbox.claimNext({ + workerId: "recovery-worker", + leaseDurationMs: 30_000, + }); + assert.isTrue(Option.isSome(reclaimed)); + if (Option.isSome(reclaimed)) { + assert.equal(reclaimed.value.request.type, replayRequest.type); + assert.equal(reclaimed.value.attemptCount, 2); + yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" }); + } + }), + ); it.effect("atomically cancels stale runs and their process-bound effects", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts b/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts index f003aea73dbf..ad50b410c906 100644 --- a/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts +++ b/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts @@ -78,8 +78,9 @@ it.effect("stops draining a slow subscriber when its unacknowledged tail fills", type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string }; describe("replayAndBufferLiveEvents", () => { - for (const phase of ["high-water", "replay"] as const) { - it.effect(`unsubscribes and cancels a blocked ${phase} read on live overflow`, () => + it.effect.each(["high-water", "replay"] as const)( + "unsubscribes and cancels a blocked %s read on live overflow", + (phase) => Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.unbounded(); @@ -121,11 +122,11 @@ describe("replayAndBufferLiveEvents", () => { expect(yield* PubSub.size(pubsub)).toBe(0); }), ), - ); - } + ); - for (const limit of ["items", "bytes"] as const) { - it.effect(`counts an unacknowledged replay batch toward the live ${limit} limit`, () => + it.effect.each(["items", "bytes"] as const)( + "counts an unacknowledged replay batch toward the live %s limit", + (limit) => Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.unbounded(); @@ -158,8 +159,7 @@ describe("replayAndBufferLiveEvents", () => { if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError"); }), ), - ); - } + ); it.effect("lets a fast reader replay a database page larger than both buffer limits", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index eb1fb203420a..024b94c24236 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -332,61 +332,59 @@ const runScenario = (input: { }); describe("OpenCode 2 through the orchestrator", () => { - for (const via of ["message", "thread settings"] as const) { - it.effect( - `switches the session's model before the next prompt when changed from the ${via}`, - () => - Effect.gen(function* () { - const name = `opencode2-model-switch-${via.replace(" ", "-")}`; - const cwd = yield* checkpointWorkspace(name); - const thread = threadCommands({ name, worktreePath: cwd }); - const projection = yield* runScenario({ - name, - threadId: thread.threadId, - entries: [ - ...createdSession(cwd, name), - ...instructionsWritten, - ...answeredPrompt("FIRST"), - // The next turn resumes the session at its new selection. - out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, t3Rules(name))), - out("session.switchModel", { - sessionID: SESSION, - model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, - }), - reply("session.switchModel", null), - // The instructions name the model, so they are written again. - ...instructionsWritten, - ...answeredPrompt("SECOND"), - ], - commands: [ - thread.create, - thread.message("first"), - ...(via === "thread settings" - ? [ - { - type: "thread.model-selection.set", - commandId: thread.command("model"), - threadId: thread.threadId, - modelSelection: mimo, - } satisfies OrchestrationV2Command, - ] - : []), - thread.message("second", mimo), - ], - }); - assert.deepEqual( - projection.runs.map((run) => [run.status, run.modelSelection.model]), - [ - ["completed", bigPickle.model], - ["completed", mimo.model], - ], - ); - // One native session carried both turns. - assert.lengthOf(projection.providerThreads, 1); - }).pipe(Effect.scoped), - ); - } + it.effect.each(["message", "thread settings"] as const)( + "switches the session's model before the next prompt when changed from the %s", + (via) => + Effect.gen(function* () { + const name = `opencode2-model-switch-${via.replace(" ", "-")}`; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [ + ...createdSession(cwd, name), + ...instructionsWritten, + ...answeredPrompt("FIRST"), + // The next turn resumes the session at its new selection. + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(cwd, t3Rules(name))), + out("session.switchModel", { + sessionID: SESSION, + model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, + }), + reply("session.switchModel", null), + // The instructions name the model, so they are written again. + ...instructionsWritten, + ...answeredPrompt("SECOND"), + ], + commands: [ + thread.create, + thread.message("first"), + ...(via === "thread settings" + ? [ + { + type: "thread.model-selection.set", + commandId: thread.command("model"), + threadId: thread.threadId, + modelSelection: mimo, + } satisfies OrchestrationV2Command, + ] + : []), + thread.message("second", mimo), + ], + }); + assert.deepEqual( + projection.runs.map((run) => [run.status, run.modelSelection.model]), + [ + ["completed", bigPickle.model], + ["completed", mimo.model], + ], + ); + // One native session carried both turns. + assert.lengthOf(projection.providerThreads, 1); + }).pipe(Effect.scoped), + ); it.effect("moves the session to the thread's new worktree before the next prompt", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 55f2218fbd01..bcbc5a889569 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -128,16 +128,17 @@ describe("planProjectCommand", () => { assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode); }); - for (const id of ["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)]) { - it(`rejects a new script ID that cannot have a shortcut: ${id}`, () => { + it.each(["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)])( + "rejects a new script ID that cannot have a shortcut: %s", + (id) => { const failure = failureOf(update({ scripts: [script("lint"), script(id)] })); assert.equal(failure._tag, "ProjectCommandInvariantError"); assert.include(failure.message, "Script ID"); assert.include(failure.message, "24"); // The detail is persisted in the rejected receipt, so it omits the raw ID. assert.notInclude(failure.message, `'${id}'`); - }); - } + }, + ); it("accepts a script ID at the shortcut length limit", () => { const scripts = [script("a".repeat(24))]; diff --git a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts index 221c773149ac..4889a48b3d83 100644 --- a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts @@ -186,12 +186,17 @@ function fixtureEvents(now: DateTime.Utc): ReadonlyArray ({ + storage, + storeLayer: storage === "sqlite" ? ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)) - : ProjectionStore.layerMemory; - it.effect(`${storage}: finds the active root turn without an attempt reverse link`, () => + : ProjectionStore.layerMemory, +})); + +it.effect.each(storageCases)( + "$storage: finds the active root turn without an attempt reverse link", + ({ storeLayer }) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; @@ -210,8 +215,10 @@ for (const storage of ["sqlite", "memory"] as const) { }); assert.isUndefined((yield* store.getRunningTurnContext(threadId)).providerTurn); }).pipe(Effect.provide(storeLayer)), - ); - it.effect(`${storage}: controls and replies read only their exact durable targets`, () => +); +it.effect.each(storageCases)( + "$storage: controls and replies read only their exact durable targets", + ({ storage, storeLayer }) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; @@ -344,5 +351,4 @@ for (const storage of ["sqlite", "memory"] as const) { ), ); }).pipe(Effect.provide(Layer.merge(storeLayer, SqlitePersistenceMemory))), - ); -} +); diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 4fb806e61e9c..eec1ad1c3f08 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -140,159 +140,157 @@ const createItem = Effect.fn(function* ( }); }); -for (const [name, testLayer] of [ +it.effect.each([ ["sql", SqlLayer], ["memory", ProjectionStore.layerMemory], -] as const) { - it.effect( - `${name}: discovers settlement work with the same activity and background semantics as the shell`, - () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const idle = yield* createThread("idle"); - const completed = yield* createThread("completed"); - yield* createRun(completed); - for (const [name, overrides] of [ - ["archived", { archivedAt: old }], - ["deleted", { deletedAt: old }], - ["settled", { settledOverride: "settled" }], - ["unsettled", { settledOverride: "active" }], - ["pinned", { pinnedAt: old }], - ["auto-settle-disabled", { autoSettleDisabledAt: old }], - ] satisfies ReadonlyArray]>) { - yield* createRun(yield* createThread(name, overrides)); - } - for (const status of ["preparing", "starting", "running", "waiting"] as const) { - yield* createRun(yield* createThread(status), status); - } - const queued = yield* createThread("queued"); - yield* createRun(queued, "queued"); - const blocked = yield* createThread("blocked"); - yield* store.apply({ - id: EventId.make("event:settlement:request"), - type: "runtime-request.updated", - threadId: blocked, - occurredAt: old, - payload: { - id: RuntimeRequestId.make("request:settlement"), - nodeId: NodeId.make("node:settlement"), - providerTurnId: null, - nativeRequestRef: null, - kind: "user_input", - status: "pending", - responseCapability: { type: "not_resumable", reason: "Process stopped" }, - createdAt: old, - resolvedAt: null, - }, - }); - const recent = yield* createThread("recent-message"); - yield* createRun(recent); - yield* store.apply({ - id: EventId.make("event:settlement:message"), - type: "message.updated", +] as const)( + "%s: discovers settlement work with the same activity and background semantics as the shell", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const idle = yield* createThread("idle"); + const completed = yield* createThread("completed"); + yield* createRun(completed); + for (const [name, overrides] of [ + ["archived", { archivedAt: old }], + ["deleted", { deletedAt: old }], + ["settled", { settledOverride: "settled" }], + ["unsettled", { settledOverride: "active" }], + ["pinned", { pinnedAt: old }], + ["auto-settle-disabled", { autoSettleDisabledAt: old }], + ] satisfies ReadonlyArray]>) { + yield* createRun(yield* createThread(name, overrides)); + } + for (const status of ["preparing", "starting", "running", "waiting"] as const) { + yield* createRun(yield* createThread(status), status); + } + const queued = yield* createThread("queued"); + yield* createRun(queued, "queued"); + const blocked = yield* createThread("blocked"); + yield* store.apply({ + id: EventId.make("event:settlement:request"), + type: "runtime-request.updated", + threadId: blocked, + occurredAt: old, + payload: { + id: RuntimeRequestId.make("request:settlement"), + nodeId: NodeId.make("node:settlement"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "not_resumable", reason: "Process stopped" }, + createdAt: old, + resolvedAt: null, + }, + }); + const recent = yield* createThread("recent-message"); + yield* createRun(recent); + yield* store.apply({ + id: EventId.make("event:settlement:message"), + type: "message.updated", + threadId: recent, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: MessageId.make("message:settlement:recent"), threadId: recent, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: MessageId.make("message:settlement:recent"), - threadId: recent, - runId: null, - nodeId: null, - role: "user", - text: "Next turn", - attachments: [], - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - yield* createRun( - yield* createThread("snoozed", { - snoozedUntil: DateTime.add(now, { days: 1 }), - snoozedAt: now, - }), - ); - const woke = yield* createThread("woke", { + runId: null, + nodeId: null, + role: "user", + text: "Next turn", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* createRun( + yield* createThread("snoozed", { snoozedUntil: DateTime.add(now, { days: 1 }), - snoozedAt: DateTime.subtract(old, { days: 1 }), - }); - yield* createRun(woke); - const background = yield* createThread("idle-background"); - yield* createItem(background, yield* createRun(background), "idle"); - const persistent = yield* createThread("persistent-monitor"); - yield* createItem(persistent, yield* createRun(persistent), "running", true); - const rolledBack = yield* createThread("rolled-back-background"); - yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); - yield* createRun(rolledBack, "completed", 2); - const roster = yield* createThread("provider-roster"); - yield* createRun(roster); - yield* store.apply({ - id: EventId.make("event:settlement:roster"), - type: "provider-thread.updated", - threadId: roster, - occurredAt: old, - payload: { - id: ProviderThreadId.make("provider-thread:settlement"), - appThreadId: roster, - ownerNodeId: null, - driver: ProviderDriverKind.make("codex"), - providerInstanceId, - providerSessionId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: old, - updatedAt: old, - pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }], - }, - }); - const candidates = yield* store.getSettlementCandidates(); - const shell = yield* store.getShellSnapshot({ location: "active" }); - const eligible = candidates.filter((thread) => - isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), - ); - assert.deepEqual( - new Set(eligible.map((thread) => thread.id)), - new Set([idle, completed, queued, woke, background, persistent, rolledBack]), - ); + snoozedAt: now, + }), + ); + const woke = yield* createThread("woke", { + snoozedUntil: DateTime.add(now, { days: 1 }), + snoozedAt: DateTime.subtract(old, { days: 1 }), + }); + yield* createRun(woke); + const background = yield* createThread("idle-background"); + yield* createItem(background, yield* createRun(background), "idle"); + const persistent = yield* createThread("persistent-monitor"); + yield* createItem(persistent, yield* createRun(persistent), "running", true); + const rolledBack = yield* createThread("rolled-back-background"); + yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); + yield* createRun(rolledBack, "completed", 2); + const roster = yield* createThread("provider-roster"); + yield* createRun(roster); + yield* store.apply({ + id: EventId.make("event:settlement:roster"), + type: "provider-thread.updated", + threadId: roster, + occurredAt: old, + payload: { + id: ProviderThreadId.make("provider-thread:settlement"), + appThreadId: roster, + ownerNodeId: null, + driver: ProviderDriverKind.make("codex"), + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: old, + updatedAt: old, + pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }], + }, + }); + const candidates = yield* store.getSettlementCandidates(); + const shell = yield* store.getShellSnapshot({ location: "active" }); + const eligible = candidates.filter((thread) => + isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), + ); + assert.deepEqual( + new Set(eligible.map((thread) => thread.id)), + new Set([idle, completed, queued, woke, background, persistent, rolledBack]), + ); + assert.deepEqual( + new Set(eligible.map((thread) => thread.id)), + new Set( + shell.threads + .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) + .map((thread) => thread.id), + ), + ); + for (const candidate of candidates) { + const expected = shell.threads.find((thread) => thread.id === candidate.id)!; + assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); + const settings = { + pullRequest: null, + nowMs: DateTime.toEpochMillis(now), + autoSettleAfterDays: 7, + autoSettleOnMerge: false, + }; assert.deepEqual( - new Set(eligible.map((thread) => thread.id)), - new Set( - shell.threads - .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) - .map((thread) => thread.id), - ), - ); - for (const candidate of candidates) { - const expected = shell.threads.find((thread) => thread.id === candidate.id)!; - assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); - const settings = { - pullRequest: null, - nowMs: DateTime.toEpochMillis(now), - autoSettleAfterDays: 7, - autoSettleOnMerge: false, - }; - assert.deepEqual( - resolveAutoSettlementAt({ ...settings, thread: candidate }), - resolveAutoSettlementAt({ ...settings, thread: expected }), - ); - } - assert.equal( - DateTime.formatIso((yield* store.getThread(completed)).createdAt), - DateTime.formatIso(old), - ); - assert.equal( - (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, - "ProjectionStoreThreadNotFoundError", + resolveAutoSettlementAt({ ...settings, thread: candidate }), + resolveAutoSettlementAt({ ...settings, thread: expected }), ); - }).pipe(Effect.provide(testLayer)), - ); -} + } + assert.equal( + DateTime.formatIso((yield* store.getThread(completed)).createdAt), + DateTime.formatIso(old), + ); + assert.equal( + (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, + "ProjectionStoreThreadNotFoundError", + ); + }).pipe(Effect.provide(testLayer)), +); const pullRequestLink = (number: number) => ({ host: "github.com", @@ -305,11 +303,12 @@ const pullRequestLink = (number: number) => ({ stack: null, }); -for (const [name, testLayer] of [ +it.effect.each([ ["sql", SqlLayer], ["memory", ProjectionStore.layerMemory], -] as const) { - it.effect(`${name}: lists only active threads with pull request links, oldest first`, () => +] as const)( + "%s: lists only active threads with pull request links, oldest first", + ([, testLayer]) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; yield* createThread("no-links"); @@ -337,33 +336,27 @@ for (const [name, testLayer] of [ ], ); }).pipe(Effect.provide(testLayer)), - ); -} +); -for (const [name, testLayer] of [ +it.effect.each([ ["sql", SqlLayer], ["memory", ProjectionStore.layerMemory], -] as const) { - it.effect(`${name}: an unsettled-only shell read skips settled threads`, () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const open = yield* createThread("unsettled-open"); - const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" }); - yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old }); - yield* createThread("unsettled-auto", { settledAt: old }); - yield* createThread("unsettled-archived", { archivedAt: old }); +] as const)("%s: an unsettled-only shell read skips settled threads", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const open = yield* createThread("unsettled-open"); + const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" }); + yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old }); + yield* createThread("unsettled-auto", { settledAt: old }); + yield* createThread("unsettled-archived", { archivedAt: old }); - const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true }); - assert.deepEqual( - new Set(shell.threads.map((thread) => thread.id)), - new Set([open, reopened]), - ); - assert.equal(shell.archivedThreads.length, 0); - const all = yield* store.getShellSnapshot({ location: "active" }); - assert.equal(all.threads.length, 4); - }).pipe(Effect.provide(testLayer)), - ); -} + const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true }); + assert.deepEqual(new Set(shell.threads.map((thread) => thread.id)), new Set([open, reopened])); + assert.equal(shell.archivedThreads.length, 0); + const all = yield* store.getShellSnapshot({ location: "active" }); + assert.equal(all.threads.length, 4); + }).pipe(Effect.provide(testLayer)), +); it.effect( "reads settlement candidates and thread metadata without loading historical or archived payloads", diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index f1470125a889..7b73b7d7f1d9 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -724,8 +724,9 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - for (const terminal of ["completed", "interrupted", "failed", "cancelled", "control"] as const) { - it.effect(`dismisses only native questions when a provider turn ends with ${terminal}`, () => + it.effect.each(["completed", "interrupted", "failed", "cancelled", "control"] as const)( + "dismisses only native questions when a provider turn ends with %s", + (terminal) => Effect.gen(function* () { const now = yield* DateTime.now; const eventSink = yield* EventSink.EventSinkV2; @@ -909,8 +910,7 @@ layer("ProviderEventIngestorV2", (it) => { const repeated = yield* ingestor.ingestNormalized(input); assert.isFalse(repeated.some((entry) => entry.event.type === "runtime-request.updated")); }), - ); - } + ); it.effect( "preserves an answer committed after terminal normalization reads a pending question", diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 63d4c57fb663..46c4695bad1d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3007,8 +3007,9 @@ it.effect( }), ); -for (const workspaceState of ["missing", "file"] as const) { - it.effect(`rejects a ${workspaceState} workspace before opening a provider session`, () => +it.effect.each(["missing", "file"] as const)( + "rejects a %s workspace before opening a provider session", + (workspaceState) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const root = yield* fileSystem.makeTempDirectoryScoped(); @@ -3053,8 +3054,7 @@ for (const workspaceState of ["missing", "file"] as const) { ); }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 }))); }).pipe(Effect.provide(NodeServices.layer)), - ); -} +); it.effect( "rejects a deleted workspace before reusing a live session without changing its state", diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index b3a9fe9d6176..d946bcd19766 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -120,32 +120,30 @@ it.effect( ), ); -for (const deadStatus of ["stopped", "error"] as const) { - it.effect( - `restarts and releases the live session when a newer ${deadStatus} session exists`, - () => - Effect.gen(function* () { - const service = yield* ProviderSwitch.ProviderSwitchServiceV2; - const thread = projection(); - const result = yield* service.plan({ - projection: { - ...thread, - providerSessions: [ - ...thread.providerSessions, - deadSessionRecord("dead_session", deadStatus), - ], - }, - targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, - }); - assert.equal(result.transition.type, "restart_and_resume"); - assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); - }).pipe( - Effect.provide( - testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), - ), +it.effect.each(["stopped", "error"] as const)( + "restarts and releases the live session when a newer %s session exists", + (deadStatus) => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const thread = projection(); + const result = yield* service.plan({ + projection: { + ...thread, + providerSessions: [ + ...thread.providerSessions, + deadSessionRecord("dead_session", deadStatus), + ], + }, + targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, + }); + assert.equal(result.transition.type, "restart_and_resume"); + assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); + }).pipe( + Effect.provide( + testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), ), - ); -} + ), +); it.effect("releases the newest live session, not the newest record overall", () => Effect.gen(function* () { @@ -275,32 +273,29 @@ it.effect("hands off from a native provider thread after its session detaches", ), ); -for (const deadStatus of ["stopped", "error"] as const) { - it.effect( - `applies a model change on next turn when a ${deadStatus} session negotiated model switching`, - () => - Effect.gen(function* () { - const service = yield* ProviderSwitch.ProviderSwitchServiceV2; - const result = yield* service.plan({ - projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2), - targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, - }); - // Static capabilities report no in-session switch, but the dead - // record's negotiated capabilities describe the provider: without - // them the ACP classification rejects the selection instead of - // reopening with the requested model on the next run. - assert.equal(result.transition.type, "switch_model_in_session"); - assert.deepEqual(result.releaseProviderSessionIds, []); - }).pipe( - Effect.provide( - testLayer( - { [currentInstanceId]: { continuationKey: "codex:account:primary" } }, - (input) => Effect.succeed(acpSelectionTransition(input)), - ), +it.effect.each(["stopped", "error"] as const)( + "applies a model change on next turn when a %s session negotiated model switching", + (deadStatus) => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const result = yield* service.plan({ + projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2), + targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, + }); + // Static capabilities report no in-session switch, but the dead + // record's negotiated capabilities describe the provider: without + // them the ACP classification rejects the selection instead of + // reopening with the requested model on the next run. + assert.equal(result.transition.type, "switch_model_in_session"); + assert.deepEqual(result.releaseProviderSessionIds, []); + }).pipe( + Effect.provide( + testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) => + Effect.succeed(acpSelectionTransition(input)), ), ), - ); -} + ), +); it.effect("rejects a model change the dead record never negotiated support for", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index f43f4cd01666..c282fa7a26d6 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -259,67 +259,65 @@ it.effect("does not continue a failed run that lost background work", () => }), ); -for (const [enabled, projectOverride] of [ +it.effect.each([ [false, undefined], [true, undefined], [false, true], [true, false], -] as const) { - it.effect( - `atomically records restart intent with cancellation when opt-in is ${enabled} and project override is ${projectOverride}`, - () => - Effect.gen(function* () { - let committed: Parameters[0] | undefined; - const recovery = yield* ProviderRuntimeRecovery.make.pipe( - Effect.provide( - Layer.mergeAll( - ServerSettings.layerTest({ - continueThreadsAfterServerUpdate: enabled, - projectSettingsOverrides: - projectOverride === undefined - ? {} - : { - [ProjectId.make("restart-project")]: { - continueThreadsAfterServerUpdate: projectOverride, - }, +] as const)( + "atomically records restart intent with cancellation when opt-in is %s and project override is %s", + ([enabled, projectOverride]) => + Effect.gen(function* () { + let committed: Parameters[0] | undefined; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + continueThreadsAfterServerUpdate: enabled, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : { + [ProjectId.make("restart-project")]: { + continueThreadsAfterServerUpdate: projectOverride, }, - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRecoveryThreadIds: () => Effect.succeed([threadId]), - getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()), - }), - Layer.mock(EventSink.EventSinkV2)({ - commitCommand: (input) => { - committed = input; - return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); - }, - }), - IdAllocator.layer, - Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ - runRecoveryOnce: Effect.succeed(false), - }), - Layer.mock(EffectOutbox.EffectOutboxV2)({ - reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), - }), - ), - ), - ); - yield* recovery.reconcile("startup"); - assert.isDefined(committed); - assert.isTrue( - committed!.events.some( - (event) => event.type === "run.updated" && event.payload.status === "cancelled", + }, + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committed = input; + return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), ), - ); - assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0); - if (projectOverride ?? enabled) - assert.deepEqual(committed!.effects[0]?.request, { - type: "provider-runtime.continue", - sourceRunId: runId, - }); - }), - ); -} + ), + ); + yield* recovery.reconcile("startup"); + assert.isDefined(committed); + assert.isTrue( + committed!.events.some( + (event) => event.type === "run.updated" && event.payload.status === "cancelled", + ), + ); + assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0); + if (projectOverride ?? enabled) + assert.deepEqual(committed!.effects[0]?.request, { + type: "provider-runtime.continue", + sourceRunId: runId, + }); + }), +); it.effect("does not duplicate delivery and yields to newer user work or opt-out", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index ec292a02b86f..370c30058346 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -862,8 +862,9 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); -for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard"] as const) { - it.effect(`handles ${scenario} before the provider turn starts`, () => +it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( + "handles %s before the provider turn starts", + (scenario) => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-settings-failure"); const runId = RunId.make("run:run-execution-settings-failure"); @@ -1030,8 +1031,7 @@ for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard assert.equal(errorItem.payload.failure.message, "Run preparation failed."); } }), - ); -} +); it.effect("keeps ingesting owned child events after the root turn terminalizes", () => Effect.gen(function* () { @@ -3149,8 +3149,9 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp }), ); -for (const status of ["completed", "interrupted", "cancelled", "failed"] as const) { - it.effect(`refreshes pull requests after the current root run ${status}`, () => +it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)( + "refreshes pull requests after the current root run %s", + (status) => Effect.gen(function* () { const { observed } = yield* captureRootRunTermination({ key: `pull-request-refresh:${status}`, @@ -3162,8 +3163,7 @@ for (const status of ["completed", "interrupted", "cancelled", "failed"] as cons "pull-requests-refreshed", ]); }), - ); -} +); it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 31b0039876c2..a2e8f50901f4 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -50,80 +50,82 @@ it.effect("refreshes workspace after checkpoint capture without reading history" }).pipe(Effect.provide(layer)); }); -for (const scenario of [ - { - label: "discovers a new PR for the completed run's branch", - branch: "feature", - checkedOut: "feature", - activeRun: null, - expected: ["/repo"], - }, - { - label: "leaves the default branch's PR cache alone", - branch: "main", - checkedOut: "main", - activeRun: null, - expected: [], - }, - { - label: "does not refresh another thread's checkout", - branch: "feature", - checkedOut: "other", - activeRun: null, - expected: [], - }, - { - label: "does not refresh during a newer active run", - branch: "feature", - checkedOut: "feature", - activeRun: "newer-run", - expected: [], - }, -] as const) { - it.effect(scenario.label, () => { - const refreshed: string[] = []; - const threadId = ThreadId.make("thread-pr-refresh"); - const runId = RunId.make("completed-run"); - const layer = RunFinalization.observerLive.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), - Layer.mock(PullRequestService.PullRequestService)({ - refreshAfterTurn: () => Effect.void, - }), - Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ - refreshLocalStatus: () => - Effect.succeed({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: scenario.checkedOut === "main", - refName: scenario.checkedOut, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - refreshStatus: () => - Effect.die("turn completion must preserve known PRs and lookup backoff"), - refreshPullRequestStatus: (cwd) => - Effect.sync(() => { - refreshed.push(cwd); - return null; - }), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThreadShell: () => - Effect.succeed({ - id: threadId, - branch: scenario.branch, - activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun), - } as OrchestrationV2ThreadShell), - }), - ), +it.effect.each( + ( + [ + { + label: "discovers a new PR for the completed run's branch", + branch: "feature", + checkedOut: "feature", + activeRun: null, + expected: ["/repo"], + }, + { + label: "leaves the default branch's PR cache alone", + branch: "main", + checkedOut: "main", + activeRun: null, + expected: [], + }, + { + label: "does not refresh another thread's checkout", + branch: "feature", + checkedOut: "other", + activeRun: null, + expected: [], + }, + { + label: "does not refresh during a newer active run", + branch: "feature", + checkedOut: "feature", + activeRun: "newer-run", + expected: [], + }, + ] as const + ).map((scenario) => [scenario.label, scenario] as const), +)("%s", ([, scenario]) => { + const refreshed: string[] = []; + const threadId = ThreadId.make("thread-pr-refresh"); + const runId = RunId.make("completed-run"); + const layer = RunFinalization.observerLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + Layer.mock(PullRequestService.PullRequestService)({ + refreshAfterTurn: () => Effect.void, + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: scenario.checkedOut === "main", + refName: scenario.checkedOut, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + refreshStatus: () => + Effect.die("turn completion must preserve known PRs and lookup backoff"), + refreshPullRequestStatus: (cwd) => + Effect.sync(() => { + refreshed.push(cwd); + return null; + }), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + branch: scenario.branch, + activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun), + } as OrchestrationV2ThreadShell), + }), ), - ); - return Effect.gen(function* () { - const observer = yield* RunFinalization.RunFinalizationObserver; - yield* observer.refresh({ cwd: "/repo", threadId, runId }); - assert.deepEqual(refreshed, [...scenario.expected]); - }).pipe(Effect.provide(layer)); - }); -} + ), + ); + return Effect.gen(function* () { + const observer = yield* RunFinalization.RunFinalizationObserver; + yield* observer.refresh({ cwd: "/repo", threadId, runId }); + assert.deepEqual(refreshed, [...scenario.expected]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 414478b39061..012fb9c3c7ae 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -537,176 +537,174 @@ it.live("restarts selection as a new attempt and retries after old-session clean ), ); -for (const deadStatus of ["stopped", "error"] as const) { - it.live( - `restarts the live session on a model change when a newer ${deadStatus} session record exists`, - () => - Effect.scoped( - Effect.gen(function* () { - const name = `selection-restart-dead-${deadStatus}`; - const cwd = yield* checkpointWorkspace(name); - const threadId = ThreadId.make(`thread:${name}`); - const state = yield* Ref.make({ - activeTurn: null, - opened: [], - started: [], - closedSessionCount: 0, - // The dead record is seeded directly, so the adapter's one-shot - // simulated replacement-open failure is skipped. - failedReplacementOpen: true, - }); - const registry = ProviderAdapterRegistry.makeSingleLayer( - makeRestartAdapter(state, exclusiveCapabilities), - ); +it.live.each(["stopped", "error"] as const)( + "restarts the live session on a model change when a newer %s session record exists", + (deadStatus) => + Effect.scoped( + Effect.gen(function* () { + const name = `selection-restart-dead-${deadStatus}`; + const cwd = yield* checkpointWorkspace(name); + const threadId = ThreadId.make(`thread:${name}`); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + // The dead record is seeded directly, so the adapter's one-shot + // simulated replacement-open failure is skipped. + failedReplacementOpen: true, + }); + const registry = ProviderAdapterRegistry.makeSingleLayer( + makeRestartAdapter(state, exclusiveCapabilities), + ); - const result = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const eventSink = yield* EventSink.EventSinkV2; - const dispatch = (step: string, modelSelection: ModelSelection) => - Effect.gen(function* () { - const terminal = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "completed", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`${name}:${step}`), - threadId, - messageId: MessageId.make(`${name}:${step}`), - text: step, - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - yield* Fiber.join(terminal); - yield* worker.drain(); - return yield* orchestrator.getThreadProjection(threadId); + const result = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + const dispatch = (step: string, modelSelection: ModelSelection) => + Effect.gen(function* () { + const terminal = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${name}:${step}`), + threadId, + messageId: MessageId.make(`${name}:${step}`), + text: step, + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`${name}:create`), - threadId, - projectId: ProjectId.make(`project:${name}`), - title: name, - modelSelection: seedSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, + yield* worker.drain(); + yield* Fiber.join(terminal); + yield* worker.drain(); + return yield* orchestrator.getThreadProjection(threadId); }); - const first = yield* dispatch("first", seedSelection); - const liveSession = first.providerSessions.find( - (session) => session.status !== "stopped" && session.status !== "error", - ); - assert.isDefined(liveSession); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${name}:create`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: name, + modelSelection: seedSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const first = yield* dispatch("first", seedSelection); + const liveSession = first.providerSessions.find( + (session) => session.status !== "stopped" && session.status !== "error", + ); + assert.isDefined(liveSession); - // Dead session records stay bound in the projection until - // detachment. A stale stopped/error record written after the - // live session attached must not hide it. - const deadAt = yield* DateTime.now; - const deadSession: OrchestrationV2ProviderSession = { - id: ProviderSessionId.make(`session:${name}:dead`), - driver, - providerInstanceId, - status: "ready", - cwd, - model: seedSelection.model, - capabilities: exclusiveCapabilities, - createdAt: deadAt, - updatedAt: deadAt, - lastError: null, - }; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`event:${name}:dead-attached`), - type: "provider-session.attached", - threadId, - driver, - providerInstanceId, - occurredAt: deadAt, - payload: deadSession, - }, - { - id: EventId.make(`event:${name}:dead-updated`), - type: "provider-session.updated", - threadId, - driver, - providerInstanceId, - occurredAt: deadAt, - payload: { - ...deadSession, - status: deadStatus, - updatedAt: deadAt, - lastError: deadStatus === "error" ? "Simulated session failure." : null, - }, + // Dead session records stay bound in the projection until + // detachment. A stale stopped/error record written after the + // live session attached must not hide it. + const deadAt = yield* DateTime.now; + const deadSession: OrchestrationV2ProviderSession = { + id: ProviderSessionId.make(`session:${name}:dead`), + driver, + providerInstanceId, + status: "ready", + cwd, + model: seedSelection.model, + capabilities: exclusiveCapabilities, + createdAt: deadAt, + updatedAt: deadAt, + lastError: null, + }; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`event:${name}:dead-attached`), + type: "provider-session.attached", + threadId, + driver, + providerInstanceId, + occurredAt: deadAt, + payload: deadSession, + }, + { + id: EventId.make(`event:${name}:dead-updated`), + type: "provider-session.updated", + threadId, + driver, + providerInstanceId, + occurredAt: deadAt, + payload: { + ...deadSession, + status: deadStatus, + updatedAt: deadAt, + lastError: deadStatus === "error" ? "Simulated session failure." : null, }, - ], - }); + }, + ], + }); - const switchCommandId = CommandId.make(`${name}:switch`); - yield* orchestrator.dispatch({ - type: "thread.model-selection.set", - commandId: switchCommandId, - threadId, - modelSelection: replacementSelection, - }); - yield* worker.drain(); - const storedSwitchEvents = yield* eventSink - .readByCommandId({ commandId: switchCommandId }) - .pipe(Stream.runCollect); - const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) => - stored.event.type === "provider-session.detached" - ? [stored.event.payload.providerSessionId] - : [], - ); + const switchCommandId = CommandId.make(`${name}:switch`); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: switchCommandId, + threadId, + modelSelection: replacementSelection, + }); + yield* worker.drain(); + const storedSwitchEvents = yield* eventSink + .readByCommandId({ commandId: switchCommandId }) + .pipe(Stream.runCollect); + const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) => + stored.event.type === "provider-session.detached" + ? [stored.event.payload.providerSessionId] + : [], + ); - const second = yield* dispatch("second", replacementSelection); - return { - projection: second, - captured: yield* Ref.get(state), - liveSessionId: liveSession.id, - detachedSessionIds, - }; - }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); + const second = yield* dispatch("second", replacementSelection); + return { + projection: second, + captured: yield* Ref.get(state), + liveSessionId: liveSession.id, + detachedSessionIds, + }; + }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); - const { projection, captured } = result; - assert.lengthOf(projection.runs, 2); - assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); - // The exact released session is the older live one, never the newer - // dead record. - assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); - assert.equal(captured.closedSessionCount, 1); - assert.deepEqual( - captured.opened.map((open) => open.model), - [seedSelection.model, replacementSelection.model], - ); - assert.deepEqual( - captured.started.map((turn) => turn.model), - [seedSelection.model, replacementSelection.model], - ); - const servingSession = projection.providerSessions.find( - (session) => - session.id === - projection.providerThreads.find( - (providerThread) => providerThread.id === projection.thread.activeProviderThreadId, - )?.providerSessionId, - ); - assert.equal(servingSession?.model, replacementSelection.model); - }), - ), - ); -} + const { projection, captured } = result; + assert.lengthOf(projection.runs, 2); + assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); + // The exact released session is the older live one, never the newer + // dead record. + assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); + assert.equal(captured.closedSessionCount, 1); + assert.deepEqual( + captured.opened.map((open) => open.model), + [seedSelection.model, replacementSelection.model], + ); + assert.deepEqual( + captured.started.map((turn) => turn.model), + [seedSelection.model, replacementSelection.model], + ); + const servingSession = projection.providerSessions.find( + (session) => + session.id === + projection.providerThreads.find( + (providerThread) => providerThread.id === projection.thread.activeProviderThreadId, + )?.providerSessionId, + ); + assert.equal(servingSession?.model, replacementSelection.model); + }), + ), +); it.live("detaches the old provider session after an active provider handoff", () => Effect.scoped( @@ -822,8 +820,9 @@ it.live("detaches the old provider session after an active provider handoff", () ), ); -for (const mode of ["active", "idle", "selection-command", "pooled", "separate-home"] as const) { - it.live(`preserves native history only for compatible account switches (${mode})`, () => +it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] as const)( + "preserves native history only for compatible account switches (%s)", + (mode) => Effect.scoped( Effect.gen(function* () { const name = `shared-home-${mode}`; @@ -994,5 +993,4 @@ for (const mode of ["active", "idle", "selection-command", "pooled", "separate-h }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); }), ), - ); -} +); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 6a360dd8b6b4..6a8dded63caa 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -37,371 +37,373 @@ const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); const modelSelection = { instanceId, model: "test-model" }; -for (const mailbox of [false, true]) { - for (const timing of [ - "before delivery", - "during delivery", - "before dispatch", - "after delivery", - "without native steering", - "settled only", - ] as const) { - if (!mailbox && (timing === "without native steering" || timing === "settled only")) continue; - it.effect( - `delivers ${mailbox ? "mailbox notification" : "steering"} when completion wins ${timing}`, - () => - Effect.scoped( +it.effect.each( + [false, true] + .flatMap((mailbox) => + ( + [ + "before delivery", + "during delivery", + "before dispatch", + "after delivery", + "without native steering", + "settled only", + ] as const + ).map((timing) => ({ + mailbox, + timing, + label: mailbox ? "mailbox notification" : "steering", + })), + ) + .filter( + ({ mailbox, timing }) => + mailbox || (timing !== "without native steering" && timing !== "settled only"), + ), +)("delivers $label when completion wins $timing", ({ mailbox, timing }) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`); + const events = yield* Queue.unbounded(); + const started: ProviderAdapterV2TurnInput[] = []; + const steerEntered = yield* Deferred.make(); + const rejectSteer = yield* Deferred.make(); + let steerCalls = 0; + const capabilities = { + ...CodexProviderCapabilitiesV2, + turns: { + ...CodexProviderCapabilitiesV2.turns, + supportsActiveSteering: timing !== "without native steering", + }, + }; + const adapter: ProviderAdapterV2Shape = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(capabilities), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => Effect.gen(function* () { - const cwd = yield* checkpointWorkspace( - `steering-completion-${timing.replaceAll(" ", "-")}`, - ); - const events = yield* Queue.unbounded(); - const started: ProviderAdapterV2TurnInput[] = []; - const steerEntered = yield* Deferred.make(); - const rejectSteer = yield* Deferred.make(); - let steerCalls = 0; - const capabilities = { - ...CodexProviderCapabilitiesV2, - turns: { - ...CodexProviderCapabilitiesV2.turns, - supportsActiveSteering: timing !== "without native steering", - }, - }; - const adapter: ProviderAdapterV2Shape = { + const now = yield* DateTime.now; + return { instanceId, driver, - getCapabilities: () => Effect.succeed(capabilities), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: (input) => + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => Effect.gen(function* () { - const now = yield* DateTime.now; - return { - instanceId, + started.push(turn); + yield* Queue.offer(events, { + type: "provider_turn.updated", driver, - providerSessionId: input.providerSessionId, - providerSession: { - id: input.providerSessionId, - driver, - providerInstanceId: instanceId, - status: "ready", - cwd, - model: modelSelection.model, - capabilities, - createdAt: now, - updatedAt: now, - lastError: null, - }, - events: Stream.fromQueue(events), - ensureThread: ({ threadId }) => - Effect.succeed({ - id: ProviderThreadId.make(`provider-thread:${threadId}`), + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { driver, - providerInstanceId: instanceId, - providerSessionId: input.providerSessionId, - appThreadId: threadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }), - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: (turn) => - Effect.gen(function* () { - started.push(turn); - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { - id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), - providerThreadId: turn.providerThread.id, - nodeId: turn.rootNodeId, - runAttemptId: turn.attemptId, - nativeTurnRef: { - driver, - nativeId: `native:${turn.attemptId}`, - strength: "strong", - }, - ordinal: turn.providerTurnOrdinal, - status: "running", - startedAt: now, - completedAt: null, - }, - }); - }), - steerTurn: (turn) => - Effect.gen(function* () { - steerCalls += 1; - if (timing === "after delivery") return; - yield* Deferred.succeed(steerEntered, undefined); - yield* Deferred.await(rejectSteer); - return yield* new ProviderAdapterSteerRunError({ - driver, - providerThreadId: turn.providerThread.id, - providerTurnId: turn.providerTurnId, - cause: "turn already completed", - }); - }), - interruptTurn: () => Effect.void, - respondToRuntimeRequest: () => Effect.void, - readThreadSnapshot: () => Effect.die("unused"), - rollbackThread: () => Effect.die("unused"), - forkThread: () => Effect.die("unused"), - }; - }), - }; - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:steering-completion"); - const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => - orchestrator.streamDomainEvents.pipe( - Stream.filter(predicate), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create"), - threadId, - projectId: ProjectId.make("project:steering-completion"), - title: "Steering race", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("first"), - threadId, - messageId: MessageId.make("message:first"), - text: "first", - attachments: [], - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - const running = yield* watch( - (event) => - event.type === "provider-turn.updated" && event.payload.status === "running", - ); - yield* worker.drain(); - yield* Fiber.join(running); - const first = started[0]!; - const messageId = MessageId.make("message:steering"); - const taskId = NodeId.make("task:mailbox"); - if (mailbox) { - const sink = yield* EventSink.EventSinkV2; - const current = yield* orchestrator.getThreadProjection(threadId); - const parentRun = current.runs.find((run) => run.id === first.runId)!; - const now = yield* DateTime.now; - yield* sink.write({ - events: [ - { - id: EventId.make("mailbox:cohort"), - type: "run.updated", - threadId, - runId: first.runId, - occurredAt: now, - payload: { - ...parentRun, - delegatedCompletion: { - disposition: "open", - nextGeneration: 2, - delivery: { generation: 1, messageId, taskIds: [taskId] }, - }, + nativeId: `native:${turn.attemptId}`, + strength: "strong", }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, }, - { - id: EventId.make("mailbox:task"), - type: "subagent.updated", - threadId, - runId: first.runId, - nodeId: taskId, - occurredAt: now, - payload: { - id: taskId, - threadId, - runId: first.runId, - parentNodeId: first.rootNodeId, - origin: "app_owned", - createdBy: "agent", - driver, - providerInstanceId: instanceId, - providerThreadId: null, - childThreadId: null, - nativeTaskRef: null, - prompt: "Do background work", - title: "Background test", - model: null, - completionWake: timing === "settled only" ? "settled_only" : "always", - completionDelivery: { state: "claimed", observedByRunId: null }, - status: "completed", - result: "done", - startedAt: now, - completedAt: now, - updatedAt: now, - }, - }, - ], - }); - } - const dispatchSteer = orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("steer"), - threadId, - messageId, - text: "fix the popover", - attachments: [ - { - type: "image", - id: "steering-screenshot", - name: "image.png", - mimeType: "image/png", - sizeBytes: 10, - }, - ], - dispatchMode: mailbox - ? { type: "queue_after_active" } - : { type: "steer_active", targetRunId: first.runId }, - createdBy: mailbox ? "agent" : "user", - creationSource: mailbox ? "server" : "web", - ...(mailbox - ? { - delegatedCompletion: { - parentRunId: first.runId, - generation: 1, - taskIds: [taskId], - }, - } - : {}), - }); - if (timing !== "before dispatch") yield* dispatchSteer; - if (timing === "after delivery") yield* worker.drain(); - const delivery = - timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; - if (delivery !== null) yield* Deferred.await(steerEntered); - const completed = yield* watch( - (event) => - event.type === "run.updated" && - event.payload.id === first.runId && - event.payload.status === "waiting", - ); - const projection = yield* orchestrator.getThreadProjection(threadId); - const turn = projection.providerTurns[0]!; - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, - }); - yield* Queue.offer(events, { - type: "turn.terminal", - driver, - providerThreadId: turn.providerThreadId, - providerTurnId: turn.id, - runOrdinal: first.runOrdinal, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* Fiber.join(completed); - if (delivery !== null) { - yield* Deferred.succeed(rejectSteer, undefined); - yield* Fiber.join(delivery); - } - if (timing === "before dispatch") yield* dispatchSteer; - yield* worker.drain(); - yield* orchestrator.resumeQueuedRuns; - yield* worker.drain(); - if (timing === "after delivery") { - assert.equal(steerCalls, 1); - assert.equal(started.length, 1); - if (mailbox) { - const delivered = yield* orchestrator.getThreadProjection(threadId); - assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered"); - assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null); - assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null); - assert.equal( - delivered.turnItems.filter((item) => item.type === "notification").length, - 1, - ); - yield* orchestrator.dispatch({ - type: "notification.delivery.accept", - commandId: CommandId.make("duplicate-acceptance"), - threadId, - messageId, }); - yield* worker.drain(); - assert.equal(steerCalls, 1); - yield* orchestrator.dispatch({ - type: "delegated_task.completion-delivery.acknowledge", - commandId: CommandId.make("read-result"), - parentThreadId: threadId, - taskId, - observedByRunId: first.runId, + }), + steerTurn: (turn) => + Effect.gen(function* () { + steerCalls += 1; + if (timing === "after delivery") return; + yield* Deferred.succeed(steerEntered, undefined); + yield* Deferred.await(rejectSteer); + return yield* new ProviderAdapterSteerRunError({ + driver, + providerThreadId: turn.providerThread.id, + providerTurnId: turn.providerTurnId, + cause: "turn already completed", }); - const acknowledged = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - acknowledged.subagents[0]?.completionDelivery?.state, - "acknowledged", - ); - } - return; - } - assert.equal(started.length, 2); - assert.equal(started[1]?.message.messageId, messageId); - if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId)); - else assert.equal(started[1]?.message.text, "fix the popover"); - assert.deepEqual(started[1]?.message.attachments, [ - { - type: "image", - id: "steering-screenshot", - name: "image.png", - mimeType: "image/png", - sizeBytes: 10, + }), + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:steering-completion"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:steering-completion"), + title: "Steering race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("message:first"), + text: "first", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* worker.drain(); + yield* Fiber.join(running); + const first = started[0]!; + const messageId = MessageId.make("message:steering"); + const taskId = NodeId.make("task:mailbox"); + if (mailbox) { + const sink = yield* EventSink.EventSinkV2; + const current = yield* orchestrator.getThreadProjection(threadId); + const parentRun = current.runs.find((run) => run.id === first.runId)!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("mailbox:cohort"), + type: "run.updated", + threadId, + runId: first.runId, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { generation: 1, messageId, taskIds: [taskId] }, + }, + }, + }, + { + id: EventId.make("mailbox:task"), + type: "subagent.updated", + threadId, + runId: first.runId, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: first.runId, + parentNodeId: first.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Do background work", + title: "Background test", + model: null, + completionWake: timing === "settled only" ? "settled_only" : "always", + completionDelivery: { state: "claimed", observedByRunId: null }, + status: "completed", + result: "done", + startedAt: now, + completedAt: now, + updatedAt: now, }, - ]); - assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); - const final = yield* orchestrator.getThreadProjection(threadId); - assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); - assert.equal( - final.messages.find((message) => message.id === messageId)?.runId, - started[1]?.runId, - ); - assert.equal( - final.turnItems.filter((item) => - mailbox - ? item.type === "notification" - : item.type === "user_message" && item.messageId === messageId, - ).length, - 1, - ); - yield* worker.drain(); - assert.equal(started.length, 2); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { name: `steering-completion-${timing}` }, - ProviderAdapterRegistry.makeSingleLayer(adapter), - { runEffectWorker: false }, - ), - ), + }, + ], + }); + } + const dispatchSteer = orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId, + text: "fix the popover", + attachments: [ + { + type: "image", + id: "steering-screenshot", + name: "image.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ], + dispatchMode: mailbox + ? { type: "queue_after_active" } + : { type: "steer_active", targetRunId: first.runId }, + createdBy: mailbox ? "agent" : "user", + creationSource: mailbox ? "server" : "web", + ...(mailbox + ? { + delegatedCompletion: { + parentRunId: first.runId, + generation: 1, + taskIds: [taskId], + }, + } + : {}), + }); + if (timing !== "before dispatch") yield* dispatchSteer; + if (timing === "after delivery") yield* worker.drain(); + const delivery = + timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; + if (delivery !== null) yield* Deferred.await(steerEntered); + const completed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ); + const projection = yield* orchestrator.getThreadProjection(threadId); + const turn = projection.providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(completed); + if (delivery !== null) { + yield* Deferred.succeed(rejectSteer, undefined); + yield* Fiber.join(delivery); + } + if (timing === "before dispatch") yield* dispatchSteer; + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + if (timing === "after delivery") { + assert.equal(steerCalls, 1); + assert.equal(started.length, 1); + if (mailbox) { + const delivered = yield* orchestrator.getThreadProjection(threadId); + assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered"); + assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null); + assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null); + assert.equal( + delivered.turnItems.filter((item) => item.type === "notification").length, + 1, ); - }), + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("duplicate-acceptance"), + threadId, + messageId, + }); + yield* worker.drain(); + assert.equal(steerCalls, 1); + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-result"), + parentThreadId: threadId, + taskId, + observedByRunId: first.runId, + }); + const acknowledged = yield* orchestrator.getThreadProjection(threadId); + assert.equal(acknowledged.subagents[0]?.completionDelivery?.state, "acknowledged"); + } + return; + } + assert.equal(started.length, 2); + assert.equal(started[1]?.message.messageId, messageId); + if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId)); + else assert.equal(started[1]?.message.text, "fix the popover"); + assert.deepEqual(started[1]?.message.attachments, [ + { + type: "image", + id: "steering-screenshot", + name: "image.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ]); + assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); + assert.equal( + final.messages.find((message) => message.id === messageId)?.runId, + started[1]?.runId, + ); + assert.equal( + final.turnItems.filter((item) => + mailbox + ? item.type === "notification" + : item.type === "user_message" && item.messageId === messageId, + ).length, + 1, + ); + yield* worker.drain(); + assert.equal(started.length, 2); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `steering-completion-${timing}` }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ), ), - ); - } -} + ); + }), + ), +); // Claude and Pi steer live but cannot interrupt-and-restart, so a changed // selection waits for the next turn as the thread's saved selection. diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 63bc1550b205..1badbe8dccd7 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -25,7 +25,7 @@ import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; -for (const driverName of ["codex", "claudeAgent"] as const) { +const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => { const driver = ProviderDriverKind.make(driverName); const instanceId = ProviderInstanceId.make(driver); const modelSelection = { instanceId, model: "test-model" }; @@ -45,199 +45,207 @@ for (const driverName of ["codex", "claudeAgent"] as const) { { runEffectWorker: false }, ); - for (const status of ["failed", "interrupted", "cancelled"] as const) { - it.effect(`bounds ${driver} context when continuing a fork of a ${status} run`, () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; - const sourceThreadId = ThreadId.make("fork-boundary-source"); - const targetThreadId = ThreadId.make("fork-boundary-target"); - const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread"); - const sourceRunId = RunId.make("fork-boundary-source-run"); - const attemptId = RunAttemptId.make("interrupted-source-attempt"); - const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); - const rootNodeId = NodeId.make("interrupted-source-root"); + return (["failed", "interrupted", "cancelled"] as const).map((status) => ({ + driver, + status, + instanceId, + modelSelection, + layer, + })); +}); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-source"), - threadId: sourceThreadId, - projectId: ProjectId.make("fork-boundary-project"), - title: "Fork boundary source", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdBy: "user", - creationSource: "web", - }); +it.effect.each(forkCases)( + "bounds $driver context when continuing a fork of a $status run", + ({ driver, status, instanceId, modelSelection, layer }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const sourceThreadId = ThreadId.make("fork-boundary-source"); + const targetThreadId = ThreadId.make("fork-boundary-target"); + const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread"); + const sourceRunId = RunId.make("fork-boundary-source-run"); + const attemptId = RunAttemptId.make("interrupted-source-attempt"); + const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); + const rootNodeId = NodeId.make("interrupted-source-root"); + + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-source"), + threadId: sourceThreadId, + projectId: ProjectId.make("fork-boundary-project"), + title: "Fork boundary source", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* eventSink.write({ + events: [ + { + id: EventId.make("source-provider-thread"), + type: "provider-thread.updated", + threadId: sourceThreadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: sourceThreadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 2, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + ], + }); + // A cancelled queue entry has no provider turn; an early interruption + // can have a turn but no native assistant cursor. + if (status === "interrupted") { yield* eventSink.write({ events: [ { - id: EventId.make("source-provider-thread"), - type: "provider-thread.updated", + id: EventId.make("source-attempt"), + type: "run-attempt.created", threadId: sourceThreadId, + runId: sourceRunId, occurredAt: now, payload: { - id: providerThreadId, - driver, + id: attemptId, + runId: sourceRunId, + attemptOrdinal: 1, + rootNodeId, providerInstanceId: instanceId, - providerSessionId: null, - appThreadId: sourceThreadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 2, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, + providerThreadId, + providerTurnId, + reason: "initial", + status, + startedAt: now, + completedAt: now, + }, + }, + { + id: EventId.make("source-provider-turn"), + type: "provider-turn.updated", + threadId: sourceThreadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId, + nodeId: rootNodeId, + runAttemptId: attemptId, + nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" }, + ordinal: 1, + status, + startedAt: now, + completedAt: now, }, }, ], }); - // A cancelled queue entry has no provider turn; an early interruption - // can have a turn but no native assistant cursor. - if (status === "interrupted") { - yield* eventSink.write({ - events: [ - { - id: EventId.make("source-attempt"), - type: "run-attempt.created", - threadId: sourceThreadId, - runId: sourceRunId, - occurredAt: now, - payload: { - id: attemptId, - runId: sourceRunId, - attemptOrdinal: 1, - rootNodeId, - providerInstanceId: instanceId, - providerThreadId, - providerTurnId, - reason: "initial", - status, - startedAt: now, - completedAt: now, - }, - }, - { - id: EventId.make("source-provider-turn"), - type: "provider-turn.updated", - threadId: sourceThreadId, - occurredAt: now, - payload: { - id: providerTurnId, - providerThreadId, - nodeId: rootNodeId, - runAttemptId: attemptId, - nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" }, - ordinal: 1, - status, - startedAt: now, - completedAt: now, - }, - }, - ], - }); - } - for (const ordinal of [1, 2]) { - const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); - const messageId = MessageId.make(`source-message-${ordinal}`); - yield* eventSink.write({ - events: [ - { - id: EventId.make(`run-${ordinal}`), - type: "run.created", + } + for (const ordinal of [1, 2]) { + const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); + const messageId = MessageId.make(`source-message-${ordinal}`); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`run-${ordinal}`), + type: "run.created", + threadId: sourceThreadId, + runId, + occurredAt: now, + payload: { + id: runId, threadId: sourceThreadId, - runId, - occurredAt: now, - payload: { - id: runId, - threadId: sourceThreadId, - ordinal, - providerInstanceId: instanceId, - modelSelection, - providerThreadId, - userMessageId: messageId, - rootNodeId: null, - activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, - status: ordinal === 1 ? status : "completed", - queuePosition: null, - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId: null, - contextHandoffId: null, - }, + ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, + status: ordinal === 1 ? status : "completed", + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, }, - { - id: EventId.make(`item-${ordinal}`), - type: "turn-item.updated", + }, + { + id: EventId.make(`item-${ordinal}`), + type: "turn-item.updated", + threadId: sourceThreadId, + runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item-${ordinal}`), threadId: sourceThreadId, runId, - occurredAt: now, - payload: { - id: TurnItemId.make(`item-${ordinal}`), - threadId: sourceThreadId, - runId, - nodeId: null, - providerThreadId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal, - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "user_message", - createdBy: "user", - creationSource: "web", - inputIntent: "turn_start", - messageId, - text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", - attachments: [], - }, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId, + text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", + attachments: [], }, - ], - }); - } - yield* orchestrator.dispatch({ - type: "thread.fork", - commandId: CommandId.make("fork-source"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "run", runId: sourceRunId }, - createdBy: "user", - creationSource: "web", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("continue-fork"), - threadId: targetThreadId, - messageId: MessageId.make("continue-fork"), - text: "Continue from the selected source run", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", + }, + ], }); - const target = yield* orchestrator.getThreadProjection(targetThreadId); - assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context"); - assert.lengthOf(target.contextHandoffs, 1); - const handoff = target.contextHandoffs[0]!; - const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; - assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); - assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); - assert.isNull(target.providerThreads[0]?.forkedFrom); - }).pipe(Effect.provide(layer)), - ); - } -} + } + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork-source"), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "run", runId: sourceRunId }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("continue-fork"), + threadId: targetThreadId, + messageId: MessageId.make("continue-fork"), + text: "Continue from the selected source run", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const target = yield* orchestrator.getThreadProjection(targetThreadId); + assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context"); + assert.lengthOf(target.contextHandoffs, 1); + const handoff = target.contextHandoffs[0]!; + const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; + assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); + assert.isNull(target.providerThreads[0]?.forkedFrom); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 7b33d6f6ec91..10eb4ac9485e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -271,64 +271,64 @@ function waitUntil(predicate: () => Effect.Effect): Effect. }); } -for (const target of ["new", "existing"] as const) { - for (const createdBy of ["user", "agent"] as const) { - it.effect( - `attributes ${createdBy}-configured automations in ${target} threads without changing their prompt`, - () => { - const harness = makeHarness(); - const scheduledTasks = ScheduledTasks.layer.pipe( - Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)), - ); - return Effect.gen(function* () { - const tasks = yield* ScheduledTasks.ScheduledTaskService; - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const existing = - target === "existing" - ? yield* launches.launch( - launchInput({ command: "command:existing", thread: "thread:existing" }), - ) - : null; - const { task } = yield* tasks.upsert({ - id: ScheduledTaskId.make("scheduled-task:attribution"), - title: "Daily audit", - prompt: "Audit performance and crashes.", - enabled: false, - schedule: { type: "interval", everyMs: 60_000 }, - projectId, - threadId: existing?.threadId ?? null, - workspaceStrategy: { type: "root" }, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - createdBy, - creationSource: createdBy === "agent" ? "mcp" : "web", - }); - const result = yield* tasks.runNow({ id: task.id }); - assert.equal(result.task.lastRunStatus, "succeeded"); - const projectThreads = yield* threads.listProjectThreads({ - projectId, - includeSubagents: false, - }); - const thread = - projectThreads.find((candidate) => candidate.id === existing?.threadId) ?? - projectThreads[0]; - assert.isDefined(thread); - const projection = yield* threads.getThreadProjection(thread!.id); - // Encoding the persisted projection exercises both message and turn-item wire schemas. - const wire = yield* encodeThreadProjection(projection); - assert.equal(wire.messages[0]?.text, task.prompt); - assert.equal(wire.messages[0]?.scheduledTaskId, task.id); - assert.equal(wire.messages[0]?.createdBy, createdBy); - const turnItem = wire.turnItems.find((item) => item.type === "user_message"); - assert.equal(turnItem?.text, task.prompt); - assert.equal(turnItem?.scheduledTaskId, task.id); - }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); - }, +it.effect.each( + (["new", "existing"] as const).flatMap((target) => + (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })), + ), +)( + "attributes $createdBy-configured automations in $target threads without changing their prompt", + ({ target, createdBy }) => { + const harness = makeHarness(); + const scheduledTasks = ScheduledTasks.layer.pipe( + Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)), ); - } -} + return Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const existing = + target === "existing" + ? yield* launches.launch( + launchInput({ command: "command:existing", thread: "thread:existing" }), + ) + : null; + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("scheduled-task:attribution"), + title: "Daily audit", + prompt: "Audit performance and crashes.", + enabled: false, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + threadId: existing?.threadId ?? null, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy, + creationSource: createdBy === "agent" ? "mcp" : "web", + }); + const result = yield* tasks.runNow({ id: task.id }); + assert.equal(result.task.lastRunStatus, "succeeded"); + const projectThreads = yield* threads.listProjectThreads({ + projectId, + includeSubagents: false, + }); + const thread = + projectThreads.find((candidate) => candidate.id === existing?.threadId) ?? + projectThreads[0]; + assert.isDefined(thread); + const projection = yield* threads.getThreadProjection(thread!.id); + // Encoding the persisted projection exercises both message and turn-item wire schemas. + const wire = yield* encodeThreadProjection(projection); + assert.equal(wire.messages[0]?.text, task.prompt); + assert.equal(wire.messages[0]?.scheduledTaskId, task.id); + assert.equal(wire.messages[0]?.createdBy, createdBy); + const turnItem = wire.turnItems.find((item) => item.type === "user_message"); + assert.equal(turnItem?.text, task.prompt); + assert.equal(turnItem?.scheduledTaskId, task.id); + }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); + }, +); it.effect("retains automation and sender attribution while a message waits in the queue", () => { const harness = makeHarness({ runSetup: () => Effect.never }); @@ -603,8 +603,9 @@ it.effect( }), ); -for (const nativeCommand of [" /COMPACT ", "/logout"]) { - it.effect(`uses the first conversation message for a title after ${nativeCommand}`, () => +it.effect.each([" /COMPACT ", "/logout"])( + "uses the first conversation message for a title after %s", + (nativeCommand) => Effect.gen(function* () { const harness = makeHarness(); yield* Effect.gen(function* () { @@ -653,8 +654,7 @@ for (const nativeCommand of [" /COMPACT ", "/logout"]) { ); }).pipe(Effect.provide(harness.layer)); }), - ); -} +); it.effect("keeps native maintenance commands out of steering and restart messages", () => Effect.gen(function* () { @@ -1293,47 +1293,45 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails }).pipe(Effect.provide(harness.layer)); }); -for (const failurePoint of ["worktree", "setup"] as const) { - it.effect( - `${failurePoint} failure keeps the thread and message visible and emits failure items`, - () => - Effect.gen(function* () { - const failure = new Error(`${failurePoint} failed`); - const harness = makeHarness( - failurePoint === "worktree" - ? { createWorktree: () => Effect.fail(failure as never) } - : { runSetup: () => Effect.fail(failure as never) }, +it.effect.each(["worktree", "setup"] as const)( + "%s failure keeps the thread and message visible and emits failure items", + (failurePoint) => + Effect.gen(function* () { + const failure = new Error(`${failurePoint} failed`); + const harness = makeHarness( + failurePoint === "worktree" + ? { createWorktree: () => Effect.fail(failure as never) } + : { runSetup: () => Effect.fail(failure as never) }, + ); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = launchInput({ + command: `command:launch:${failurePoint}-failure`, + thread: `thread:launch:${failurePoint}-failure`, + message: `Fail during ${failurePoint}`, + workspace: { type: "worktree", baseRef: "main" }, + }); + const launched = yield* launches.launch(input); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), ); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: `command:launch:${failurePoint}-failure`, - thread: `thread:launch:${failurePoint}-failure`, - message: `Fail during ${failurePoint}`, - workspace: { type: "worktree", baseRef: "main" }, - }); - const launched = yield* launches.launch(input); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); - assert.equal(projection.runs[0]?.status, "failed"); - assert.equal( - projection.turnItems.find((item) => item.type === "command_execution")?.status, - "failed", - ); - assert.match( - projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", - new RegExp(`${failurePoint} failed`, "u"), - ); - }).pipe(Effect.provide(harness.layer)); - }), - ); -} + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); + assert.equal(projection.runs[0]?.status, "failed"); + assert.equal( + projection.turnItems.find((item) => item.type === "command_execution")?.status, + "failed", + ); + assert.match( + projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", + new RegExp(`${failurePoint} failed`, "u"), + ); + }).pipe(Effect.provide(harness.layer)); + }), +); it.effect("replays a server-allocated launch", () => Effect.gen(function* () { @@ -1989,61 +1987,59 @@ it.effect("cancels tracked setup before provider work is released", () => }), ); -for (const exitCode of [0, 1]) { - it.effect(`releases an async setup before its completion with exit ${exitCode}`, () => - Effect.gen(function* () { - const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); - const harness = makeHarness({ - runSetup: () => - Effect.succeed({ - status: "started" as const, - async: true, - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "vp install", - terminalId: "setup", - cwd: "/repo-worktrees/feature", - completion: Deferred.await(completion), - }), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; - const launched = yield* launches.launch( - launchInput({ - command: `command:launch:async-${exitCode}`, - thread: `thread:launch:async-${exitCode}`, - message: "Start during setup", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* tracker.stream(launched.threadId).pipe( - Stream.filter( - (snapshot) => - snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") === - true, - ), - Stream.runHead, - ); - const running = yield* threads.getThreadProjection(launched.threadId); - assert.equal(running.runs[0]?.status, "starting"); - assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); - yield* Deferred.succeed(completion, { exitCode, durationMs: 1 }); - yield* tracker.stream(launched.threadId).pipe( - Stream.filter((snapshot) => snapshot?.phase === "done"), - Stream.runHead, - ); - const settled = yield* tracker.get(launched.threadId); - assert.equal( - settled?.stages.find((stage) => stage.id === "setup-script")?.status, - exitCode === 0 ? "done" : "failed", - ); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, - "starting", - ); - }).pipe(Effect.provide(harness.layer)); - }), - ); -} +it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) => + Effect.gen(function* () { + const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); + const harness = makeHarness({ + runSetup: () => + Effect.succeed({ + status: "started" as const, + async: true, + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "vp install", + terminalId: "setup", + cwd: "/repo-worktrees/feature", + completion: Deferred.await(completion), + }), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: `command:launch:async-${exitCode}`, + thread: `thread:launch:async-${exitCode}`, + message: "Start during setup", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* tracker.stream(launched.threadId).pipe( + Stream.filter( + (snapshot) => + snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") === + true, + ), + Stream.runHead, + ); + const running = yield* threads.getThreadProjection(launched.threadId); + assert.equal(running.runs[0]?.status, "starting"); + assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); + yield* Deferred.succeed(completion, { exitCode, durationMs: 1 }); + yield* tracker.stream(launched.threadId).pipe( + Stream.filter((snapshot) => snapshot?.phase === "done"), + Stream.runHead, + ); + const settled = yield* tracker.get(launched.threadId); + assert.equal( + settled?.stages.find((stage) => stage.id === "setup-script")?.status, + exitCode === 0 ? "done" : "failed", + ); + assert.equal( + (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, + "starting", + ); + }).pipe(Effect.provide(harness.layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 1df1c242700f..7ff557c338dd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -344,7 +344,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte }).pipe(Effect.provide(testLayer)); }); -for (const scenario of [ +it.effect.each([ { finalStatus: "completed" as const, timedOut: false }, { finalStatus: "failed" as const, timedOut: false }, { finalStatus: "cancelled" as const, timedOut: false }, @@ -352,72 +352,70 @@ for (const scenario of [ { finalStatus: "rolled_back" as const, timedOut: false }, { finalStatus: "running" as const, timedOut: true }, { finalStatus: "missing" as const }, -]) { - it.effect(`waitForThread timeout final read when selected run is ${scenario.finalStatus}`, () => - Effect.gen(function* () { - const projectId = ProjectId.make("project:thread-management:wait-timeout"); - const threadId = ThreadId.make("thread:thread-management:wait-timeout"); - const runId = RunId.make("run:thread-management:wait-timeout"); - const loopRead = yield* Deferred.make(); - let reads = 0; - const projection = (status: OrchestrationV2Run["status"] | "missing") => - ({ - thread: { id: threadId, projectId, deletedAt: null }, - runs: status === "missing" ? [] : [{ id: runId, status }], - }) as unknown as OrchestrationV2ThreadProjection; - const testLayer = ThreadManagementService.layer.pipe( - Layer.provide( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadRecords: () => - Effect.gen(function* () { - reads += 1; - if (reads === 1) { - return projection("running"); - } - if (reads === 2) { - // Park inside the wait loop so the timeout path runs while a - // final projection read can still observe a terminal run. - yield* Deferred.succeed(loopRead, undefined); - return yield* Effect.never; - } - return projection(scenario.finalStatus); - }), - }), - ), - ); - const service = yield* ThreadManagementService.ThreadManagementService.pipe( - Effect.provide(testLayer), - ); - const fiber = yield* service - .waitForThread({ - projectId, +])("waitForThread timeout final read when selected run is $finalStatus", (scenario) => + Effect.gen(function* () { + const projectId = ProjectId.make("project:thread-management:wait-timeout"); + const threadId = ThreadId.make("thread:thread-management:wait-timeout"); + const runId = RunId.make("run:thread-management:wait-timeout"); + const loopRead = yield* Deferred.make(); + let reads = 0; + const projection = (status: OrchestrationV2Run["status"] | "missing") => + ({ + thread: { id: threadId, projectId, deletedAt: null }, + runs: status === "missing" ? [] : [{ id: runId, status }], + }) as unknown as OrchestrationV2ThreadProjection; + const testLayer = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.gen(function* () { + reads += 1; + if (reads === 1) { + return projection("running"); + } + if (reads === 2) { + // Park inside the wait loop so the timeout path runs while a + // final projection read can still observe a terminal run. + yield* Deferred.succeed(loopRead, undefined); + return yield* Effect.never; + } + return projection(scenario.finalStatus); + }), + }), + ), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(testLayer), + ); + const fiber = yield* service + .waitForThread({ + projectId, + threadId, + runId, + timeoutMs: 1, + }) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(loopRead); + yield* TestClock.adjust(Duration.millis(1)); + const result = yield* Fiber.join(fiber); + + if (scenario.finalStatus === "missing") { + expect(result._tag).toBe("Failure"); + expect(result).toMatchObject({ + failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), + }); + expect(result).toMatchObject({ + failure: { threadId, runId }, + }); + } else { + expect(result._tag).toBe("Success"); + expect(result).toMatchObject({ + success: { threadId, - runId, - timeoutMs: 1, - }) - .pipe(Effect.result, Effect.forkChild); - yield* Deferred.await(loopRead); - yield* TestClock.adjust(Duration.millis(1)); - const result = yield* Fiber.join(fiber); - - if (scenario.finalStatus === "missing") { - expect(result._tag).toBe("Failure"); - expect(result).toMatchObject({ - failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), - }); - expect(result).toMatchObject({ - failure: { threadId, runId }, - }); - } else { - expect(result._tag).toBe("Success"); - expect(result).toMatchObject({ - success: { - threadId, - timedOut: scenario.timedOut, - run: { id: runId, status: scenario.finalStatus }, - }, - }); - } - }), - ); -} + timedOut: scenario.timedOut, + run: { id: runId, status: scenario.finalStatus }, + }, + }); + } + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 6b9d4a43cc27..21a22a60dd94 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -438,8 +438,9 @@ describe("ThreadTitleRegenerationService", () => { ); }); -for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) { - it.effect(`initial title retry: ${outcome}`, () => +it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)( + "initial title retry: %s", + (outcome) => Effect.gen(function* () { const attempted = yield* Deferred.make(); let attempts = 0; @@ -502,5 +503,4 @@ for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) else assert.isNotOk(projection.thread.titleRegeneration); }).pipe(Effect.provide(harness.layer)); }), - ); -} +); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c71f84709b27..7f66cebd27d1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2599,210 +2599,206 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - for (const automatic of [false, true]) { - it.effect( - `promotes only one queued run after each terminal run (notification: ${automatic})`, - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`); + it.effect.each([false, true])( + "promotes only one queued run after each terminal run (notification: %s)", + (automatic) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`), - threadId, - projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`), - title: "Serialized queue", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`), - threadId, - messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`), - text: "Active", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: automatic ? "agent" : "user", - creationSource: automatic ? "provider" : "web", - ...(automatic - ? { - notification: { - source: { kind: "monitor" as const }, - outcome: "updated" as const, - summary: "Monitor updated", - detail: "Build is green", - }, - } - : {}), - commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`), - threadId, - messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`), - text: "First queued", - attachments: [], - modelSelection, - dispatchMode: { type: "queue_after_active" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`), - threadId, - messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`), - text: "Second queued", - attachments: [], - modelSelection, - dispatchMode: { type: "queue_after_active" }, - }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`), + threadId, + projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`), + title: "Serialized queue", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`), + threadId, + messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`), + text: "Active", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: automatic ? "agent" : "user", + creationSource: automatic ? "provider" : "web", + ...(automatic + ? { + notification: { + source: { kind: "monitor" as const }, + outcome: "updated" as const, + summary: "Monitor updated", + detail: "Build is green", + }, + } + : {}), + commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`), + threadId, + messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`), + text: "First queued", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`), + threadId, + messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`), + text: "Second queued", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); - const before = yield* orchestrator.getThreadProjection(threadId); - const activeRun = before.runs.find((run) => run.status === "starting"); - const queuedRuns = before.runs - .filter((run) => run.status === "queued") - .toSorted((left, right) => left.ordinal - right.ordinal); - const firstQueuedRun = queuedRuns[0]; - const secondQueuedRun = queuedRuns[1]; - assert.isDefined(activeRun); - assert.isDefined(firstQueuedRun); - assert.isDefined(secondQueuedRun); - assert.isFalse( - before.turnItems.some( - (item) => - item.type === "user_message" && - (item.messageId === firstQueuedRun.userMessageId || - item.messageId === secondQueuedRun.userMessageId), - ), - "queued messages must not exist as turn items before dispatch", - ); + const before = yield* orchestrator.getThreadProjection(threadId); + const activeRun = before.runs.find((run) => run.status === "starting"); + const queuedRuns = before.runs + .filter((run) => run.status === "queued") + .toSorted((left, right) => left.ordinal - right.ordinal); + const firstQueuedRun = queuedRuns[0]; + const secondQueuedRun = queuedRuns[1]; + assert.isDefined(activeRun); + assert.isDefined(firstQueuedRun); + assert.isDefined(secondQueuedRun); + assert.isFalse( + before.turnItems.some( + (item) => + item.type === "user_message" && + (item.messageId === firstQueuedRun.userMessageId || + item.messageId === secondQueuedRun.userMessageId), + ), + "queued messages must not exist as turn items before dispatch", + ); - const promotedRunIds = yield* Queue.unbounded(); - const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); - yield* eventSink.stream({ threadId, afterSequence }).pipe( - Stream.runForEach((stored) => - stored.event.type === "run.updated" && stored.event.payload.status === "starting" - ? Queue.offer(promotedRunIds, stored.event.payload.id) - : Effect.void, - ), - Effect.forkScoped, - ); - yield* Effect.yieldNow; + const promotedRunIds = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting" + ? Queue.offer(promotedRunIds, stored.event.payload.id) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; - const activeCompletedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`), - type: "run.updated", - threadId, - runId: activeRun.id, - ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), - providerInstanceId: activeRun.providerInstanceId, - occurredAt: activeCompletedAt, - payload: { - ...activeRun, - status: "completed", - completedAt: activeCompletedAt, - }, + const activeCompletedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`), + type: "run.updated", + threadId, + runId: activeRun.id, + ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), + providerInstanceId: activeRun.providerInstanceId, + occurredAt: activeCompletedAt, + payload: { + ...activeRun, + status: "completed", + completedAt: activeCompletedAt, }, - ], - }); + }, + ], + }); - assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id); - const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId); + assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id); + const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, + "starting", + ); + assert.equal( + afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, + "queued", + ); + const promotedMessageItem = afterFirstPromotion.turnItems.find( + (item) => + item.runId === firstQueuedRun.id && + (item.type === "user_message" || item.type === "notification"), + ); + assert.isDefined(promotedMessageItem); + if (automatic) { + assert.equal(promotedMessageItem.type, "notification"); assert.equal( - afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, - "starting", + afterFirstPromotion.messages.find( + (message) => message.id === firstQueuedRun.userMessageId, + )?.text, + "First queued", ); assert.equal( - afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, - "queued", + afterFirstPromotion.messages.find( + (message) => message.id === firstQueuedRun.userMessageId, + )?.notification?.summary, + "Monitor updated", ); - const promotedMessageItem = afterFirstPromotion.turnItems.find( - (item) => - item.runId === firstQueuedRun.id && - (item.type === "user_message" || item.type === "notification"), - ); - assert.isDefined(promotedMessageItem); - if (automatic) { - assert.equal(promotedMessageItem.type, "notification"); - assert.equal( - afterFirstPromotion.messages.find( - (message) => message.id === firstQueuedRun.userMessageId, - )?.text, - "First queued", - ); - assert.equal( - afterFirstPromotion.messages.find( - (message) => message.id === firstQueuedRun.userMessageId, - )?.notification?.summary, - "Monitor updated", - ); - assert.isFalse( - afterFirstPromotion.turnItems.some( - (item) => - item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId, - ), - ); - } else { - assert.equal(promotedMessageItem.type, "user_message"); - } - assert.isTrue( - promotedMessageItem.startedAt !== null && - DateTime.toEpochMillis(promotedMessageItem.startedAt) >= - DateTime.toEpochMillis(activeCompletedAt), + assert.isFalse( + afterFirstPromotion.turnItems.some( + (item) => + item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId, + ), ); + } else { + assert.equal(promotedMessageItem.type, "user_message"); + } + assert.isTrue( + promotedMessageItem.startedAt !== null && + DateTime.toEpochMillis(promotedMessageItem.startedAt) >= + DateTime.toEpochMillis(activeCompletedAt), + ); - const promotedFirst = afterFirstPromotion.runs.find( - (run) => run.id === firstQueuedRun.id, - ); - assert.isDefined(promotedFirst); - const firstCompletedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`), - type: "run.updated", - threadId, - runId: promotedFirst.id, - ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }), - providerInstanceId: promotedFirst.providerInstanceId, - occurredAt: firstCompletedAt, - payload: { - ...promotedFirst, - status: "completed", - completedAt: firstCompletedAt, - }, + const promotedFirst = afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id); + assert.isDefined(promotedFirst); + const firstCompletedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`), + type: "run.updated", + threadId, + runId: promotedFirst.id, + ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }), + providerInstanceId: promotedFirst.providerInstanceId, + occurredAt: firstCompletedAt, + payload: { + ...promotedFirst, + status: "completed", + completedAt: firstCompletedAt, }, - ], - }); + }, + ], + }); - assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id); - const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, - "completed", - ); - assert.equal( - afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, - "starting", - ); - }), - ); - } + assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id); + const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, + "completed", + ); + assert.equal( + afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, + "starting", + ); + }), + ); it.effect.each(["usage_limit", "provider_error"] as const)( "handles a queued message after a %s failure", @@ -2993,112 +2989,106 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - for (const trigger of ["startup", "shutdown"] as const) { - it.effect( - `preserves and holds queued messages across ${trigger} until explicitly resumed`, - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; - const threadId = ThreadId.make(`queue-hold-${trigger}`); + it.effect.each(["startup", "shutdown"] as const)( + "preserves and holds queued messages across %s until explicitly resumed", + (trigger) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; + const threadId = ThreadId.make(`queue-hold-${trigger}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Recover queue", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) { yield* orchestrator.dispatch({ - type: "thread.create", + type: "message.dispatch", createdBy: "user", creationSource: "web", - commandId: CommandId.make(`${threadId}:create`), + commandId: CommandId.make(`${threadId}:message:${index}`), threadId, - projectId: ProjectId.make(`${threadId}:project`), - title: "Recover queue", + messageId: MessageId.make(`${threadId}:message:${index}`), + text, + attachments: [], modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), + dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, }); - for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) { - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`${threadId}:message:${index}`), - threadId, - messageId: MessageId.make(`${threadId}:message:${index}`), - text, - attachments: [], - modelSelection, - dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, - }); - } - const before = yield* orchestrator.getThreadProjection(threadId); - const queued = before.runs.filter((run) => run.status === "queued"); - assert.equal(queued.length, 2); - yield* recovery.reconcile(trigger); - // A second boot must preserve the hold, even when only queued work remains. - yield* recovery.reconcile("startup"); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - assert.isTrue((yield* maintenance.rebuild).valid); - assert.equal(yield* orchestrator.resumeQueuedRuns, 0); - const held = yield* orchestrator.getThreadProjection(threadId); + } + const before = yield* orchestrator.getThreadProjection(threadId); + const queued = before.runs.filter((run) => run.status === "queued"); + assert.equal(queued.length, 2); + yield* recovery.reconcile(trigger); + // A second boot must preserve the hold, even when only queued work remains. + yield* recovery.reconcile("startup"); + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + assert.isTrue((yield* maintenance.rebuild).valid); + assert.equal(yield* orchestrator.resumeQueuedRuns, 0); + const held = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + held.runs.map((run) => run.status), + ["cancelled", "queued", "queued"], + ); + for (const run of queued) { assert.deepEqual( - held.runs.map((run) => run.status), - ["cancelled", "queued", "queued"], + held.runs.find((row) => row.id === run.id), + { ...run, queueHeld: true }, ); - for (const run of queued) { - assert.deepEqual( - held.runs.find((row) => row.id === run.id), - { ...run, queueHeld: true }, - ); - assert.deepEqual( - held.messages.find((row) => row.id === run.userMessageId), - before.messages.find((row) => row.id === run.userMessageId), - ); - assert.equal( - held.attempts.find((row) => row.id === run.activeAttemptId)?.status, - "pending", - ); - assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); - } - // Editing and reordering are allowed without releasing the hold. - const first = queued[0]!; - const second = queued[1]!; - yield* orchestrator.dispatch({ - type: "queued-run.edit", - commandId: CommandId.make(`${threadId}:edit`), - threadId, - runId: second.id, - text: "Edited second message", - }); - yield* orchestrator.dispatch({ - type: "queued-run.reorder", - commandId: CommandId.make(`${threadId}:reorder`), - threadId, - runId: second.id, - beforeRunId: first.id, - }); - assert.equal(yield* orchestrator.resumeQueuedRuns, 0); - const resume = { - type: "queue.resume" as const, - commandId: CommandId.make(`${threadId}:resume`), - threadId, - }; - yield* orchestrator.dispatch(resume); - yield* orchestrator.dispatch(resume); - const resumed = yield* orchestrator.getThreadProjection(threadId); - assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting"); - assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued"); - assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); - assert.equal( - resumed.messages.find((row) => row.id === second.userMessageId)?.text, - "Edited second message", + assert.deepEqual( + held.messages.find((row) => row.id === run.userMessageId), + before.messages.find((row) => row.id === run.userMessageId), ); assert.equal( - resumed.runs.length, - 3, - "resume retries must not duplicate messages or runs", + held.attempts.find((row) => row.id === run.activeAttemptId)?.status, + "pending", ); - }), - ); - } + assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); + } + // Editing and reordering are allowed without releasing the hold. + const first = queued[0]!; + const second = queued[1]!; + yield* orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make(`${threadId}:edit`), + threadId, + runId: second.id, + text: "Edited second message", + }); + yield* orchestrator.dispatch({ + type: "queued-run.reorder", + commandId: CommandId.make(`${threadId}:reorder`), + threadId, + runId: second.id, + beforeRunId: first.id, + }); + assert.equal(yield* orchestrator.resumeQueuedRuns, 0); + const resume = { + type: "queue.resume" as const, + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }; + yield* orchestrator.dispatch(resume); + yield* orchestrator.dispatch(resume); + const resumed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting"); + assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued"); + assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); + assert.equal( + resumed.messages.find((row) => row.id === second.userMessageId)?.text, + "Edited second message", + ); + assert.equal(resumed.runs.length, 3, "resume retries must not duplicate messages or runs"); + }), + ); it.effect("edits and removes queued runs", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index b60a5b553cbb..dd417ef162e0 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -221,19 +221,19 @@ function runFixtureProviderWithRegisteredHarness(input: { } describe("orchestrator replay fixtures", () => { - for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { - for (const provider of fixture.providers) { - it.effect( - `runs ${fixture.name}/${provider.driver} through OrchestratorV2 using deterministic replay`, - () => - runFixtureProviderWithRegisteredHarness({ - fixtureName: fixture.name, - buildInput: fixture.buildInput, - driver: provider, - }), - ); - } - } + it.effect.each( + ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => + fixture.providers.map( + (provider) => [fixture.name, provider.driver, fixture, provider] as const, + ), + ), + )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => + runFixtureProviderWithRegisteredHarness({ + fixtureName: fixture.name, + buildInput: fixture.buildInput, + driver: provider, + }), + ); const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( (fixture) => fixture.name === "message_steering", @@ -254,33 +254,38 @@ describe("orchestrator replay fixtures", () => { // A later OpenCode may change an execution start's shape; the client then // reads it as `unreadable.execution.started`. A subagent's turn and a // background follow-up must still start from it. - for (const [fixtureName, label] of [ - ["opencode2_subagent", "session.execution.started.2"], - ["opencode2_background", "session.execution.started.3"], - ] as const) { - const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (candidate) => candidate.name === fixtureName, - ); - const provider = fixture?.providers[0]; - if (fixture === undefined || provider === undefined) continue; - it.effect( - `runs ${fixtureName} when ${label} is an execution start this build cannot decode`, - () => - runFixtureProviderWithRegisteredHarness({ - fixtureName, - buildInput: fixture.buildInput, - driver: provider, - transformTranscript: (transcript) => ({ - ...transcript, - entries: transcript.entries.map((entry) => - entry.type === "emit_inbound" && entry.label === label - ? undecodableEvent(entry) - : entry, - ), - }), + it.effect.each( + ( + [ + ["opencode2_subagent", "session.execution.started.2"], + ["opencode2_background", "session.execution.started.3"], + ] as const + ).flatMap(([fixtureName, label]) => { + const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( + (candidate) => candidate.name === fixtureName, + ); + const provider = fixture?.providers[0]; + return fixture === undefined || provider === undefined + ? [] + : [[fixtureName, label, fixture, provider] as const]; + }), + )( + "runs %s when %s is an execution start this build cannot decode", + ([fixtureName, label, fixture, provider]) => + runFixtureProviderWithRegisteredHarness({ + fixtureName, + buildInput: fixture.buildInput, + driver: provider, + transformTranscript: (transcript) => ({ + ...transcript, + entries: transcript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === label + ? undecodableEvent(entry) + : entry, + ), }), - ); - } + }), + ); }); /** The same event with an envelope this build cannot decode, as a newer OpenCode may send. */ 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 cba4cc6fbf19..9d01f22fe6a0 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -374,7 +374,7 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( }); describe("orchestration v2 provider switching", () => { - for (const scenario of [ + it.live.each([ "compact-native", "compact-fallback", "compact-legacy", @@ -408,35 +408,496 @@ describe("orchestration v2 provider switching", () => { "prior-images-replacement-native", "prior-images-legacy-replacement-native", "delivery-write-failure", - ] as const) { - it.live(`preserves handoffs through ${scenario}`, () => + ] as const)("preserves handoffs through %s", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const reasoningScenario = scenario.includes("reasoning-change"); + const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); + const modelScenario = + scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; + const failStartOnce = yield* Ref.make(false); + const failResumeOnce = yield* Ref.make(false); + const generation = yield* Ref.make(0); + const priorImages = scenario.includes("prior-images"); + const replaceNative = scenario.includes("replacement"); + const capacityScenario = modelScenario || scenario.includes("reported-capacity"); + const returning = + scenario === "large-current-input" || scenario.includes("-change-") || priorImages; + const targetSelection: ModelSelection = !modelScenario + ? CLAUDE_MODEL_SELECTION + : { + ...CLAUDE_MODEL_SELECTION, + ...(reasoningScenario + ? { options: [{ id: "reasoningEffort", value: "low" }] } + : scenario.includes("option-change") + ? { options: [{ id: "contextWindow", value: "1m" }] } + : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), + }; + const registry = ProviderAdapterRegistry.makeLayer([ + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: { 1: "Original partial work" }, + capturedTurns, + }), + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + ...(scenario.includes("retry") || scenario.includes("unsent") ? { failStartOnce } : {}), + ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), + ...(scenario.includes("reported-capacity") + ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } + : {}), + ...(turnUsageScenario + ? { + canReuseContextUsage: canReuseCodexContextUsage, + tokenUsageByRunOrdinal: { + 2: { + usedTokens: replaceNative ? 30_000 : 37_321, + maxTokens: replaceNative + ? scenario.includes("small") + ? 32_000 + : 64_000 + : 258_400, + }, + }, + } + : modelScenario + ? { + getModelContextWindow: (selection: ModelSelection) => + selection.model.endsWith("-large") || + selection.options?.some( + (option) => option.id === "contextWindow" && option.value === "1m", + ) + ? 1_000_000 + : 32_000, + } + : scenario === "large-current-input" || priorImages + ? { getModelContextWindow: () => 32_000 } + : {}), + ...(scenario.endsWith("-native") || scenario === "large-current-input" + ? { injectedHistory } + : {}), + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + const screenshot: ChatAttachment = { + type: "image", + id: "screenshot", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 100_000, + }; + const screenshots = Array.from( + { + length: + scenario.includes("eight") || capacityScenario + ? 8 + : scenario.includes("pair") + ? 2 + : 1, + }, + (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), + ); + const targetOrdinal = returning ? 4 : 2; + const dispatch = (ordinal: number, text: string, selection: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`regression:${ordinal}`), + threadId, + messageId: MessageId.make(`regression:${ordinal}`), + createdBy: "user", + creationSource: "web", + text, + attachments: + turnUsageScenario && ordinal === 2 + ? Array.from({ length: 8 }, (_, index) => ({ + ...screenshot, + id: `prior-${index}`, + })) + : turnUsageScenario && ordinal >= targetOrdinal + ? [screenshot, { ...screenshot, id: "current-2" }] + : scenario.startsWith("screenshot") && ordinal >= targetOrdinal + ? screenshots + : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) + ? [screenshot] + : [], + modelSelection: selection, + dispatchMode: { type: "start_immediately" }, + }); + const wait = (ordinal: number) => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + (event.payload.status === "completed" || event.payload.status === "failed"), + ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("regression:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Handoff regression", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); + yield* wait(1); + const write = eventSink.write; + const spy = + scenario === "delivery-write-failure" + ? vi.spyOn(eventSink, "write").mockImplementation((input) => + input.events.some( + (event) => + event.type === "context-handoff.updated" && + event.payload.delivery?.status === "inline", + ) + ? Effect.fail( + new EventSink.EventSinkWriteError({ + eventCount: input.events.length, + cause: "bookkeeping unavailable", + }), + ) + : write(input), + ) + : undefined; + yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); + const current = scenario.startsWith("compact") + ? "/compact" + : capacityScenario && !turnUsageScenario + ? "x".repeat(70_000) + : scenario === "large-current-input" + ? "x".repeat(9_000) + : "Continue work"; + // First establish the returning native thread: the current request must + // not be charged as existing context on the subsequent handoff. + if (returning) { + if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); + yield* dispatch( + 2, + turnUsageScenario ? "x".repeat(27_460) : "Establish target", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(2); + if (modelScenario && !turnUsageScenario) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("old-model-usage"), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...target, + contextUsage: { + usedTokens: 30_000, + maxTokens: 32_000, + autoCompactThreshold: 31_000, + }, + }, + }, + ], + }); + } + if (scenario.includes("legacy-replacement")) { + const existing = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: existing.attempts.map( + ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ + id: EventId.make(`legacy-attempt:${index}`), + type: "run-attempt.updated" as const, + threadId, + occurredAt: existing.thread.createdAt, + payload: legacy, + }), + ), + }); + } + if (scenario.includes("telemetry")) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("compacted-context-usage"), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, + }, + ], + }); + } + yield* dispatch( + 3, + priorImages && !replaceNative + ? "New source constraint " + "q".repeat(9_000) + : "New source constraint", + CODEX_MODEL_SELECTION, + ); + yield* wait(3); + } + if (replaceNative) { + const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("detach-for-native-replacement"), + threadId, + providerSessionId: target.providerSessionId!, + }); + yield* worker.drain(); + yield* Ref.set(failResumeOnce, true); + } + if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); + yield* dispatch(targetOrdinal, current, targetSelection); + yield* wait(targetOrdinal); + if (scenario.includes("retry")) { + const failed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(failed.runs.at(-1)?.status, "failed"); + const failedUsage = failed.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!.contextUsage; + if (reasoningScenario || scenario.includes("turn-usage")) { + assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + } else { + assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 }); + } + if (reasoningScenario) { + const target = failed.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("detach-before-reasoning-retry"), + threadId, + providerSessionId: target.providerSessionId!, + }); + yield* worker.drain(); + } + yield* dispatch(targetOrdinal + 1, current, targetSelection); + yield* wait(targetOrdinal + 1); + } + if (reasoningScenario && replaceNative) { + const replaced = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + replaced.runs.at(-1)?.status, + scenario.includes("small") ? "failed" : "completed", + ); + const target = replaced.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + assert.isNull(target.contextUsage); + assert.equal(yield* Ref.get(generation), 2); + assert.equal( + (yield* Ref.get(capturedTurns)).at(-1)!.driver, + scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, + ); + return; + } + if (replaceNative) { + if (scenario.includes("legacy-replacement")) { + const recovered = yield* orchestrator.getThreadProjection(threadId); + const handoff = recovered.contextHandoffs.at(-1)!; + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-replacement-handoff"), + type: "context-handoff.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...legacyHandoff, + delivery: { + nativeThreadId: handoff.delivery!.nativeThreadId, + status: handoff.delivery!.status, + itemIds: [], + }, + }, + }, + ], + }); + } + yield* dispatch( + targetOrdinal + 1, + "Continue after native replacement", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(targetOrdinal + 1); + yield* dispatch( + targetOrdinal + 2, + "Another source constraint " + "q".repeat(9_000), + CODEX_MODEL_SELECTION, + ); + yield* wait(targetOrdinal + 2); + yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); + yield* wait(targetOrdinal + 3); + } + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.runs.at(-1)?.status, "completed"); + if (priorImages) { + const handoff = projection.contextHandoffs.at(-1)!; + const context = scenario.endsWith("-native") + ? yield* encodeJson(yield* Ref.get(injectedHistory)) + : (yield* Ref.get(capturedTurns)).at(-1)!.text; + const shouldFit = + turnUsageScenario || + scenario.startsWith("imported") || + scenario.includes("telemetry") || + scenario.includes("unsent") || + replaceNative; + const sourceText = + `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); + const sourceItem = projection.turnItems.find( + (item) => item.type === "user_message" && item.text === sourceText, + )!; + assert.isDefined(sourceItem); + if (shouldFit) { + assert.include(context, sourceText); + assert.include( + projection.contextHandoffs + .filter( + (record) => + record.targetRunId === + (scenario.includes("retry") + ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id + : projection.runs.at(-1)!.id), + ) + .flatMap((record) => record.delivery?.itemIds ?? []), + sourceItem.id, + ); + } else { + assert.notInclude(context, "q".repeat(9_000)); + assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); + } + const latestRun = projection.runs.at(-1)!; + const latestAttempt = projection.attempts.find( + (attempt) => attempt.id === latestRun.activeAttemptId, + )!; + const target = projection.providerThreads.find( + (thread) => thread.id === latestAttempt.providerThreadId, + )!; + assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); + if (turnUsageScenario) { + if (replaceNative) assert.isNull(target.contextUsage); + else + assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); + } + if (replaceNative) assert.equal(yield* Ref.get(generation), 2); + return; + } + const handoff = projection.contextHandoffs.at(-1)!; + if (scenario.startsWith("screenshot")) { + assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); + } + if (scenario === "compact-fallback" || scenario === "compact-legacy") { + if (scenario === "compact-legacy") { + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-handoff-shape"), + type: "context-handoff.updated", + threadId, + runId: handoff.targetRunId, + occurredAt: yield* DateTime.now, + payload: legacyHandoff, + }, + ], + }); + } + assert.isUndefined(handoff.delivery); + yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); + yield* wait(3); + assert.include( + (yield* Ref.get(capturedTurns)).at(-1)!.text, + "Original request with constraints", + ); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery + ?.status, + "inline", + ); + } else if ( + scenario === "delivery-write-failure" || + (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) + ) { + assert.equal( + handoff.delivery?.status, + scenario.startsWith("screenshot") ? "inline" : "pending", + ); + assert.include( + (yield* Ref.get(capturedTurns)).at(-1)!.text, + "Original request with constraints", + ); + } else { + assert.equal(handoff.delivery?.status, "injected"); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); + assert.include( + yield* encodeJson(yield* Ref.get(injectedHistory)), + "Original request with constraints", + ); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-${scenario}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }), + ), + ); + it.live.each(["turn-start", "injection", "large-missed-request"] as const)( + "recovers %s failure without duplicating history in the same native thread", + (failure) => Effect.scoped( Effect.gen(function* () { - const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); + const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); const capturedTurns = yield* Ref.make>([]); const injectedHistory = yield* Ref.make>([]); - const reasoningScenario = scenario.includes("reasoning-change"); - const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); - const modelScenario = - scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; - const failStartOnce = yield* Ref.make(false); - const failResumeOnce = yield* Ref.make(false); + const failOnce = yield* Ref.make(true); const generation = yield* Ref.make(0); - const priorImages = scenario.includes("prior-images"); - const replaceNative = scenario.includes("replacement"); - const capacityScenario = modelScenario || scenario.includes("reported-capacity"); - const returning = - scenario === "large-current-input" || scenario.includes("-change-") || priorImages; - const targetSelection: ModelSelection = !modelScenario - ? CLAUDE_MODEL_SELECTION - : { - ...CLAUDE_MODEL_SELECTION, - ...(reasoningScenario - ? { options: [{ id: "reasoningEffort", value: "low" }] } - : scenario.includes("option-change") - ? { options: [{ id: "contextWindow", value: "1m" }] } - : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), - }; const registry = ProviderAdapterRegistry.makeLayer([ makeTestAdapter({ instanceId: CODEX_MODEL_SELECTION.instanceId, @@ -453,94 +914,31 @@ describe("orchestration v2 provider switching", () => { modelSelection: CLAUDE_MODEL_SELECTION, responseByRunOrdinal: {}, capturedTurns, - ...(scenario.includes("retry") || scenario.includes("unsent") - ? { failStartOnce } - : {}), - ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), - ...(scenario.includes("reported-capacity") - ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } - : {}), - ...(turnUsageScenario - ? { - canReuseContextUsage: canReuseCodexContextUsage, - tokenUsageByRunOrdinal: { - 2: { - usedTokens: replaceNative ? 30_000 : 37_321, - maxTokens: replaceNative - ? scenario.includes("small") - ? 32_000 - : 64_000 - : 258_400, - }, - }, - } - : modelScenario - ? { - getModelContextWindow: (selection: ModelSelection) => - selection.model.endsWith("-large") || - selection.options?.some( - (option) => option.id === "contextWindow" && option.value === "1m", - ) - ? 1_000_000 - : 32_000, - } - : scenario === "large-current-input" || priorImages - ? { getModelContextWindow: () => 32_000 } - : {}), - ...(scenario.endsWith("-native") || scenario === "large-current-input" - ? { injectedHistory } - : {}), + injectedHistory, + nativeThreadGeneration: generation, + getModelContextWindow: () => 32_000, + ...(failure !== "injection" + ? { failStartOnce: failOnce } + : { failInjectionOnce: failOnce }), }), ]); yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const eventSink = yield* EventSink.EventSinkV2; - const screenshot: ChatAttachment = { - type: "image", - id: "screenshot", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 100_000, - }; - const screenshots = Array.from( - { - length: - scenario.includes("eight") || capacityScenario - ? 8 - : scenario.includes("pair") - ? 2 - : 1, - }, - (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), - ); - const targetOrdinal = returning ? 4 : 2; const dispatch = (ordinal: number, text: string, selection: ModelSelection) => orchestrator.dispatch({ type: "message.dispatch", - commandId: CommandId.make(`regression:${ordinal}`), + commandId: CommandId.make(`retry:${ordinal}`), threadId, - messageId: MessageId.make(`regression:${ordinal}`), + messageId: MessageId.make(`retry:${ordinal}`), createdBy: "user", creationSource: "web", text, - attachments: - turnUsageScenario && ordinal === 2 - ? Array.from({ length: 8 }, (_, index) => ({ - ...screenshot, - id: `prior-${index}`, - })) - : turnUsageScenario && ordinal >= targetOrdinal - ? [screenshot, { ...screenshot, id: "current-2" }] - : scenario.startsWith("screenshot") && ordinal >= targetOrdinal - ? screenshots - : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) - ? [screenshot] - : [], + attachments: [], modelSelection: selection, dispatchMode: { type: "start_immediately" }, }); - const wait = (ordinal: number) => + const wait = (ordinal: number, status: "failed" | "completed") => orchestrator.streamStoredEvents.pipe( Stream.filter( ({ event }) => @@ -550,15 +948,23 @@ describe("orchestration v2 provider switching", () => { ), Stream.runHead, Effect.andThen(worker.drain()), + Effect.andThen( + Effect.gen(function* () { + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + status, + ); + }), + ), ); yield* orchestrator.dispatch({ type: "thread.create", - commandId: CommandId.make("regression:create"), + commandId: CommandId.make("retry:create"), threadId, projectId, createdBy: "user", creationSource: "web", - title: "Handoff regression", + title: "Handoff retry", modelSelection: CODEX_MODEL_SELECTION, runtimeMode: "full-access", interactionMode: "default", @@ -566,320 +972,71 @@ describe("orchestration v2 provider switching", () => { worktreePath: null, }); yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); - yield* wait(1); - const write = eventSink.write; - const spy = - scenario === "delivery-write-failure" - ? vi.spyOn(eventSink, "write").mockImplementation((input) => - input.events.some( - (event) => - event.type === "context-handoff.updated" && - event.payload.delivery?.status === "inline", - ) - ? Effect.fail( - new EventSink.EventSinkWriteError({ - eventCount: input.events.length, - cause: "bookkeeping unavailable", - }), - ) - : write(input), - ) - : undefined; - yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); - const current = scenario.startsWith("compact") - ? "/compact" - : capacityScenario && !turnUsageScenario - ? "x".repeat(70_000) - : scenario === "large-current-input" - ? "x".repeat(9_000) - : "Continue work"; - // First establish the returning native thread: the current request must - // not be charged as existing context on the subsequent handoff. - if (returning) { - if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); - yield* dispatch( - 2, - turnUsageScenario ? "x".repeat(27_460) : "Establish target", - CLAUDE_MODEL_SELECTION, + yield* wait(1, "completed"); + yield* dispatch( + 2, + failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(2, "failed"); + const failed = yield* orchestrator.getThreadProjection(threadId); + const failedHandoff = failed.contextHandoffs.at(-1)!; + assert.equal( + failedHandoff.delivery?.status, + failure !== "injection" ? "injected" : "pending", + ); + const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; + const retryText = + failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; + yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); + yield* wait(3, "completed"); + const retried = yield* orchestrator.getThreadProjection(threadId); + const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; + assert.equal(lastTurn.text, retryText); + if (failure !== "injection") { + const delta = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), ); - yield* wait(2); - if (modelScenario && !turnUsageScenario) { - const target = (yield* orchestrator.getThreadProjection( - threadId, - )).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + if (failure === "large-missed-request") { + assert.include(delta, "omitted 1 items"); + const missedRequest = retried.turnItems.find( + (item) => item.type === "user_message" && item.text === "x".repeat(7_000), )!; - yield* eventSink.write({ - events: [ - { - id: EventId.make("old-model-usage"), - type: "provider-thread.updated", - threadId, - occurredAt: yield* DateTime.now, - payload: { - ...target, - contextUsage: { - usedTokens: 30_000, - maxTokens: 32_000, - autoCompactThreshold: 31_000, - }, - }, - }, - ], - }); - } - if (scenario.includes("legacy-replacement")) { - const existing = yield* orchestrator.getThreadProjection(threadId); - yield* eventSink.write({ - events: existing.attempts.map( - ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ - id: EventId.make(`legacy-attempt:${index}`), - type: "run-attempt.updated" as const, - threadId, - occurredAt: existing.thread.createdAt, - payload: legacy, - }), - ), - }); - } - if (scenario.includes("telemetry")) { - const target = (yield* orchestrator.getThreadProjection( - threadId, - )).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* eventSink.write({ - events: [ - { - id: EventId.make("compacted-context-usage"), - type: "provider-thread.updated", - threadId, - occurredAt: yield* DateTime.now, - payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, - }, - ], - }); - } - yield* dispatch( - 3, - priorImages && !replaceNative - ? "New source constraint " + "q".repeat(9_000) - : "New source constraint", - CODEX_MODEL_SELECTION, - ); - yield* wait(3); - } - if (replaceNative) { - const target = (yield* orchestrator.getThreadProjection( - threadId, - )).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("detach-for-native-replacement"), - threadId, - providerSessionId: target.providerSessionId!, - }); - yield* worker.drain(); - yield* Ref.set(failResumeOnce, true); - } - if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); - yield* dispatch(targetOrdinal, current, targetSelection); - yield* wait(targetOrdinal); - if (scenario.includes("retry")) { - const failed = yield* orchestrator.getThreadProjection(threadId); - assert.equal(failed.runs.at(-1)?.status, "failed"); - const failedUsage = failed.providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!.contextUsage; - if (reasoningScenario || scenario.includes("turn-usage")) { - assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); - } else { - assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 }); - } - if (reasoningScenario) { - const target = failed.providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("detach-before-reasoning-retry"), - threadId, - providerSessionId: target.providerSessionId!, - }); - yield* worker.drain(); - } - yield* dispatch(targetOrdinal + 1, current, targetSelection); - yield* wait(targetOrdinal + 1); - } - if (reasoningScenario && replaceNative) { - const replaced = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - replaced.runs.at(-1)?.status, - scenario.includes("small") ? "failed" : "completed", - ); - const target = replaced.providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - assert.isNull(target.contextUsage); + const delivery = retried.contextHandoffs.at(-1)!.delivery!; + assert.include(delivery.omittedItemIds ?? [], missedRequest.id); + assert.notInclude(delivery.itemIds, missedRequest.id); + } else assert.include(delta, "First target request"); + assert.notInclude(delta, "Original request with constraints"); + assert.notInclude(delta, "Original partial work"); + assert.equal(yield* Ref.get(generation), 1); + } else { assert.equal(yield* Ref.get(generation), 2); - assert.equal( - (yield* Ref.get(capturedTurns)).at(-1)!.driver, - scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, - ); - return; - } - if (replaceNative) { - if (scenario.includes("legacy-replacement")) { - const recovered = yield* orchestrator.getThreadProjection(threadId); - const handoff = recovered.contextHandoffs.at(-1)!; - const { history: _history, ...legacyHandoff } = handoff; - yield* eventSink.write({ - events: [ - { - id: EventId.make("legacy-replacement-handoff"), - type: "context-handoff.updated", - threadId, - occurredAt: yield* DateTime.now, - payload: { - ...legacyHandoff, - delivery: { - nativeThreadId: handoff.delivery!.nativeThreadId, - status: handoff.delivery!.status, - itemIds: [], - }, - }, - }, - ], - }); - } - yield* dispatch( - targetOrdinal + 1, - "Continue after native replacement", - CLAUDE_MODEL_SELECTION, - ); - yield* wait(targetOrdinal + 1); - yield* dispatch( - targetOrdinal + 2, - "Another source constraint " + "q".repeat(9_000), - CODEX_MODEL_SELECTION, - ); - yield* wait(targetOrdinal + 2); - yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); - yield* wait(targetOrdinal + 3); - } - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.equal(projection.runs.at(-1)?.status, "completed"); - if (priorImages) { - const handoff = projection.contextHandoffs.at(-1)!; - const context = scenario.endsWith("-native") - ? yield* encodeJson(yield* Ref.get(injectedHistory)) - : (yield* Ref.get(capturedTurns)).at(-1)!.text; - const shouldFit = - turnUsageScenario || - scenario.startsWith("imported") || - scenario.includes("telemetry") || - scenario.includes("unsent") || - replaceNative; - const sourceText = - `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); - const sourceItem = projection.turnItems.find( - (item) => item.type === "user_message" && item.text === sourceText, - )!; - assert.isDefined(sourceItem); - if (shouldFit) { - assert.include(context, sourceText); - assert.include( - projection.contextHandoffs - .filter( - (record) => - record.targetRunId === - (scenario.includes("retry") - ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id - : projection.runs.at(-1)!.id), - ) - .flatMap((record) => record.delivery?.itemIds ?? []), - sourceItem.id, - ); - } else { - assert.notInclude(context, "q".repeat(9_000)); - assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); - } - const latestRun = projection.runs.at(-1)!; - const latestAttempt = projection.attempts.find( - (attempt) => attempt.id === latestRun.activeAttemptId, - )!; - const target = projection.providerThreads.find( - (thread) => thread.id === latestAttempt.providerThreadId, - )!; - assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); - if (turnUsageScenario) { - if (replaceNative) assert.isNull(target.contextUsage); - else - assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); - assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); - } - if (replaceNative) assert.equal(yield* Ref.get(generation), 2); - return; - } - const handoff = projection.contextHandoffs.at(-1)!; - if (scenario.startsWith("screenshot")) { - assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); - } - if (scenario === "compact-fallback" || scenario === "compact-legacy") { - if (scenario === "compact-legacy") { - const { history: _history, ...legacyHandoff } = handoff; - yield* eventSink.write({ - events: [ - { - id: EventId.make("legacy-handoff-shape"), - type: "context-handoff.updated", - threadId, - runId: handoff.targetRunId, - occurredAt: yield* DateTime.now, - payload: legacyHandoff, - }, - ], - }); - } - assert.isUndefined(handoff.delivery); - yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); - yield* wait(3); - assert.include( - (yield* Ref.get(capturedTurns)).at(-1)!.text, - "Original request with constraints", - ); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery - ?.status, - "inline", - ); - } else if ( - scenario === "delivery-write-failure" || - (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) - ) { - assert.equal( - handoff.delivery?.status, - scenario.startsWith("screenshot") ? "inline" : "pending", - ); - assert.include( - (yield* Ref.get(capturedTurns)).at(-1)!.text, - "Original request with constraints", + assert.notEqual( + retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, + failedHandoff.delivery?.nativeThreadId, ); - } else { - assert.equal(handoff.delivery?.status, "injected"); - assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); - assert.include( - yield* encodeJson(yield* Ref.get(injectedHistory)), - "Original request with constraints", + const newHistory = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), ); + assert.include(newHistory, "Original request with constraints"); + assert.include(newHistory, "Original partial work"); + assert.notInclude(newHistory, "Retry target request"); } + const beforeFollowup = yield* Ref.get(injectedHistory); + const followup = "z".repeat(6_000); + yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); + yield* wait(4, "completed"); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); + assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, + retried.contextHandoffs.length, + ); }).pipe( Effect.provide( makeOrchestratorV2ReplayLayerWithRegistry( { - name: `handoff-${scenario}`, + name: `handoff-retry-${failure}`, runtimePolicyOverride: { cwd, approvalPolicy: "never", @@ -892,368 +1049,194 @@ describe("orchestration v2 provider switching", () => { ); }), ), - ); - } - for (const failure of ["turn-start", "injection", "large-missed-request"] as const) { - it.live( - `recovers ${failure} failure without duplicating history in the same native thread`, - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); - const capturedTurns = yield* Ref.make>([]); - const injectedHistory = yield* Ref.make>([]); - const failOnce = yield* Ref.make(true); - const generation = yield* Ref.make(0); - const registry = ProviderAdapterRegistry.makeLayer([ - makeTestAdapter({ - instanceId: CODEX_MODEL_SELECTION.instanceId, - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: { 1: "Original partial work" }, - capturedTurns, - }), - makeTestAdapter({ - instanceId: CLAUDE_MODEL_SELECTION.instanceId, - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: {}, - capturedTurns, - injectedHistory, - nativeThreadGeneration: generation, - getModelContextWindow: () => 32_000, - ...(failure !== "injection" - ? { failStartOnce: failOnce } - : { failInjectionOnce: failOnce }), - }), - ]); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const dispatch = (ordinal: number, text: string, selection: ModelSelection) => - orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`retry:${ordinal}`), - threadId, - messageId: MessageId.make(`retry:${ordinal}`), - createdBy: "user", - creationSource: "web", - text, - attachments: [], - modelSelection: selection, - dispatchMode: { type: "start_immediately" }, - }); - const wait = (ordinal: number, status: "failed" | "completed") => - orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => - event.type === "run.updated" && - event.payload.ordinal === ordinal && - (event.payload.status === "completed" || event.payload.status === "failed"), - ), - Stream.runHead, - Effect.andThen(worker.drain()), - Effect.andThen( - Effect.gen(function* () { - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - status, - ); - }), - ), - ); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("retry:create"), - threadId, - projectId, - createdBy: "user", - creationSource: "web", - title: "Handoff retry", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); - yield* wait(1, "completed"); - yield* dispatch( - 2, - failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", - CLAUDE_MODEL_SELECTION, - ); - yield* wait(2, "failed"); - const failed = yield* orchestrator.getThreadProjection(threadId); - const failedHandoff = failed.contextHandoffs.at(-1)!; - assert.equal( - failedHandoff.delivery?.status, - failure !== "injection" ? "injected" : "pending", - ); - const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; - const retryText = - failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; - yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); - yield* wait(3, "completed"); - const retried = yield* orchestrator.getThreadProjection(threadId); - const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; - assert.equal(lastTurn.text, retryText); - if (failure !== "injection") { - const delta = yield* encodeJson( - (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), - ); - if (failure === "large-missed-request") { - assert.include(delta, "omitted 1 items"); - const missedRequest = retried.turnItems.find( - (item) => item.type === "user_message" && item.text === "x".repeat(7_000), - )!; - const delivery = retried.contextHandoffs.at(-1)!.delivery!; - assert.include(delivery.omittedItemIds ?? [], missedRequest.id); - assert.notInclude(delivery.itemIds, missedRequest.id); - } else assert.include(delta, "First target request"); - assert.notInclude(delta, "Original request with constraints"); - assert.notInclude(delta, "Original partial work"); - assert.equal(yield* Ref.get(generation), 1); - } else { - assert.equal(yield* Ref.get(generation), 2); - assert.notEqual( - retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, - failedHandoff.delivery?.nativeThreadId, - ); - const newHistory = yield* encodeJson( - (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), - ); - assert.include(newHistory, "Original request with constraints"); - assert.include(newHistory, "Original partial work"); - assert.notInclude(newHistory, "Retry target request"); - } - const beforeFollowup = yield* Ref.get(injectedHistory); - const followup = "z".repeat(6_000); - yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); - yield* wait(4, "completed"); - assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); - assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, - retried.contextHandoffs.length, - ); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { - name: `handoff-retry-${failure}`, - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly" }, - }, - }, - registry, - ), - ), - ); - }), - ), - ); - } + ); - for (const status of ["failed", "interrupted"] as const) { - for (const queued of [false, true]) { - for (const returning of [false, true]) { - for (const native of [false, true]) { - it.live( - `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace( - `handoff-${status}-${queued}-${returning}`, - ); - const capturedTurns = yield* Ref.make>([]); - const injectedHistory = yield* Ref.make>([]); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const sourceOrdinal = returning ? 2 : 1; - const sourceSelection = returning - ? CLAUDE_MODEL_SELECTION - : CODEX_MODEL_SELECTION; - const targetSelection = returning - ? CODEX_MODEL_SELECTION - : CLAUDE_MODEL_SELECTION; - const originalPrompt = - "Keep the release marker violet and preserve the existing API."; - const partialResponse = "I checked the API and found the release configuration."; - const registryLayer = ProviderAdapterRegistry.makeLayer( - ( - [ - [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], - [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], - ] as const - ).map(([modelSelection, driver, capabilities]) => - makeTestAdapter({ - instanceId: modelSelection.instanceId, - driver, - capabilities, - modelSelection, - responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, - capturedTurns, - ...(native && modelSelection.instanceId === targetSelection.instanceId - ? { injectedHistory } - : {}), - ...(modelSelection.instanceId === sourceSelection.instanceId - ? { - failedRunOrdinals: new Set( - status === "failed" ? [sourceOrdinal] : [], - ), - interruptedRunOrdinals: new Set( - status === "interrupted" ? [sourceOrdinal] : [], - ), - holdRunOrdinal: sourceOrdinal, - holdFirstTurn: started, - releaseFirstTurn: release, - } - : {}), - }), - ), - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const waitForRun = ( - ordinal: number, - expectedStatus: "completed" | "failed" | "interrupted", - ) => - orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => - event.type === "run.updated" && - event.threadId === threadId && - event.payload.ordinal === ordinal && - event.payload.status === expectedStatus, - ), - Stream.runHead, - Effect.andThen(worker.drain()), - ); - const dispatch = ( - key: string, - text: string, - modelSelection: ModelSelection, - queue = false, - ) => - orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`command:handoff:${key}`), - threadId, - messageId: MessageId.make(`message:handoff:${key}`), - createdBy: "user", - creationSource: "web", - text, - attachments: [], - modelSelection, - dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, - }); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("command:handoff:create"), - threadId, - projectId, - createdBy: "user", - creationSource: "web", - title: "Interrupted provider handoff", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - if (returning) { - yield* dispatch( - "initial", - "Earlier successful request.", - CODEX_MODEL_SELECTION, - ); - yield* waitForRun(1, "completed"); - } - yield* dispatch("source", originalPrompt, sourceSelection); - yield* Deferred.await(started); - if (queued) { - yield* dispatch("target", "Continue", targetSelection, true); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "queued", - ); - } - yield* Deferred.succeed(release, undefined); - yield* waitForRun(sourceOrdinal, status); - if (!queued) { - yield* dispatch("target", "Continue", targetSelection); - } - yield* waitForRun(sourceOrdinal + 1, "completed"); - const projection = yield* orchestrator.getThreadProjection(threadId); - const targetRun = projection.runs.at(-1)!; - const handoff = projection.contextHandoffs.find( - (candidate) => candidate.id === targetRun.contextHandoffId, - ); - assert.isDefined(handoff); - assert.equal( - handoff?.strategy, - returning ? "delta_since_target_last_seen" : "full_thread_summary", - ); - assert.deepEqual(handoff?.coveredRunOrdinals, { - from: sourceOrdinal, - to: sourceOrdinal, - }); - const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; - const history = yield* Ref.get(injectedHistory); - const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; - assert.include(deliveredHistory, originalPrompt); - assert.include(deliveredHistory, partialResponse); - assert.include(deliveredHistory, `run-status=${status}`); - if (native) { - assert.equal(delivered.text, "Continue"); - assert.notInclude(deliveredHistory, '"text":"Continue"'); - assert.include(deliveredHistory, '"role":"assistant"'); - assert.include(deliveredHistory, '"role":"user"'); - assert.equal(handoff?.delivery?.status, "injected"); - } else { - assert.include(delivered.text, "User message:\nContinue"); - assert.equal(handoff?.delivery?.status, "inline"); - } - assert.notInclude(deliveredHistory, "Earlier successful request."); - // The returning source already has its own failed/interrupted native turn. - yield* dispatch("back", "Finish the remaining work", sourceSelection); - yield* waitForRun(sourceOrdinal + 2, "completed"); - const back = (yield* Ref.get(capturedTurns)).at(-1)!; - assert.notInclude(back.text, originalPrompt); - assert.notInclude(back.text, partialResponse); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { - name: `handoff-${status}-${queued}-${returning}`, - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - registryLayer, - ), + it.live.each( + (["failed", "interrupted"] as const).flatMap((status) => + [false, true].flatMap((queued) => + [false, true].flatMap((returning) => + [false, true].map( + (native) => + [ + `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, + { status, queued, returning, native }, + ] as const, + ), + ), + ), + ), + )("hands off %s", ([, { status, queued, returning, native }]) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const sourceOrdinal = returning ? 2 : 1; + const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION; + const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION; + const originalPrompt = "Keep the release marker violet and preserve the existing API."; + const partialResponse = "I checked the API and found the release configuration."; + const registryLayer = ProviderAdapterRegistry.makeLayer( + ( + [ + [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], + [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], + ] as const + ).map(([modelSelection, driver, capabilities]) => + makeTestAdapter({ + instanceId: modelSelection.instanceId, + driver, + capabilities, + modelSelection, + responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, + capturedTurns, + ...(native && modelSelection.instanceId === targetSelection.instanceId + ? { injectedHistory } + : {}), + ...(modelSelection.instanceId === sourceSelection.instanceId + ? { + failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []), + interruptedRunOrdinals: new Set( + status === "interrupted" ? [sourceOrdinal] : [], ), - ); - }), + holdRunOrdinal: sourceOrdinal, + holdFirstTurn: started, + releaseFirstTurn: release, + } + : {}), + }), + ), + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const waitForRun = ( + ordinal: number, + expectedStatus: "completed" | "failed" | "interrupted", + ) => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.threadId === threadId && + event.payload.ordinal === ordinal && + event.payload.status === expectedStatus, ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + const dispatch = ( + key: string, + text: string, + modelSelection: ModelSelection, + queue = false, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:handoff:${key}`), + threadId, + messageId: MessageId.make(`message:handoff:${key}`), + createdBy: "user", + creationSource: "web", + text, + attachments: [], + modelSelection, + dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:handoff:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Interrupted provider handoff", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + if (returning) { + yield* dispatch("initial", "Earlier successful request.", CODEX_MODEL_SELECTION); + yield* waitForRun(1, "completed"); + } + yield* dispatch("source", originalPrompt, sourceSelection); + yield* Deferred.await(started); + if (queued) { + yield* dispatch("target", "Continue", targetSelection, true); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "queued", + ); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRun(sourceOrdinal, status); + if (!queued) { + yield* dispatch("target", "Continue", targetSelection); + } + yield* waitForRun(sourceOrdinal + 1, "completed"); + const projection = yield* orchestrator.getThreadProjection(threadId); + const targetRun = projection.runs.at(-1)!; + const handoff = projection.contextHandoffs.find( + (candidate) => candidate.id === targetRun.contextHandoffId, ); - } - } - } - } + assert.isDefined(handoff); + assert.equal( + handoff?.strategy, + returning ? "delta_since_target_last_seen" : "full_thread_summary", + ); + assert.deepEqual(handoff?.coveredRunOrdinals, { + from: sourceOrdinal, + to: sourceOrdinal, + }); + const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; + const history = yield* Ref.get(injectedHistory); + const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; + assert.include(deliveredHistory, originalPrompt); + assert.include(deliveredHistory, partialResponse); + assert.include(deliveredHistory, `run-status=${status}`); + if (native) { + assert.equal(delivered.text, "Continue"); + assert.notInclude(deliveredHistory, '"text":"Continue"'); + assert.include(deliveredHistory, '"role":"assistant"'); + assert.include(deliveredHistory, '"role":"user"'); + assert.equal(handoff?.delivery?.status, "injected"); + } else { + assert.include(delivered.text, "User message:\nContinue"); + assert.equal(handoff?.delivery?.status, "inline"); + } + assert.notInclude(deliveredHistory, "Earlier successful request."); + // The returning source already has its own failed/interrupted native turn. + yield* dispatch("back", "Finish the remaining work", sourceSelection); + yield* waitForRun(sourceOrdinal + 2, "completed"); + const back = (yield* Ref.get(capturedTurns)).at(-1)!; + assert.notInclude(back.text, originalPrompt); + assert.notInclude(back.text, partialResponse); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-${status}-${queued}-${returning}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + registryLayer, + ), + ), + ); + }), + ), + ); it.live("checks the queued provider's capability while the current provider stays running", () => Effect.scoped( @@ -2998,229 +2981,226 @@ describe("orchestration v2 provider switching", () => { ), ); - for (const failResume of [false, true]) { - it.live( - `switches providers while consuming a pending cross-provider merge-back (resume failure: ${failResume})`, - () => - Effect.scoped( - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); - const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); - const firstSourcePrompt = "Remember that the first source marker is amber."; - const secondSourcePrompt = "Remember that the second source marker is violet."; - const forkPrompt = "Remember that the fork marker is cobalt."; - const mergePrompt = "Report all three remembered markers."; - const cwd = yield* checkpointWorkspace("cross-provider-merge"); - const capturedTurns = yield* Ref.make>([]); - const registryLayer = ProviderAdapterRegistry.makeLayer([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("codex"), - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: {}, - responseByThreadId: { - [sourceThreadId]: { - 1: "I will remember amber.", - 3: "The markers are amber, violet, and cobalt.", - }, - [forkThreadId]: { - 1: "I will remember cobalt.", - }, + it.live.each([false, true])( + "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)", + (failResume) => + Effect.scoped( + Effect.gen(function* () { + const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); + const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); + const firstSourcePrompt = "Remember that the first source marker is amber."; + const secondSourcePrompt = "Remember that the second source marker is violet."; + const forkPrompt = "Remember that the fork marker is cobalt."; + const mergePrompt = "Report all three remembered markers."; + const cwd = yield* checkpointWorkspace("cross-provider-merge"); + const capturedTurns = yield* Ref.make>([]); + const registryLayer = ProviderAdapterRegistry.makeLayer([ + makeTestAdapter({ + instanceId: ProviderInstanceId.make("codex"), + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + responseByThreadId: { + [sourceThreadId]: { + 1: "I will remember amber.", + 3: "The markers are amber, violet, and cobalt.", + }, + [forkThreadId]: { + 1: "I will remember cobalt.", }, - capturedTurns, - failResume, - }), - makeTestAdapter({ - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 2: "I will remember violet." }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:create"), - threadId: sourceThreadId, - projectId, - title: "Cross-provider merge source", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:first-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:first-source"), - text: firstSourcePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:second-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:second-source"), - text: secondSourcePrompt, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork"), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cross-provider merge fork", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork-turn"), - threadId: forkThreadId, - messageId: MessageId.make("message:cross-provider-merge:fork-turn"), - text: forkPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.merge_back", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:merge"), - sourceThreadId: forkThreadId, - targetThreadId: sourceThreadId, - sourcePoint: { type: "latest_stable" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:consume"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:consume"), - text: mergePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, }, - ] satisfies ReadonlyArray; + capturedTurns, + failResume, + }), + makeTestAdapter({ + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 2: "I will remember violet." }, + capturedTurns, + }), + ]); + const commands = [ + { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:create"), + threadId: sourceThreadId, + projectId, + title: "Cross-provider merge source", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:first-source"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:first-source"), + text: firstSourcePrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:second-source"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:second-source"), + text: secondSourcePrompt, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:fork"), + sourceThreadId, + targetThreadId: forkThreadId, + sourcePoint: { type: "latest_stable" }, + title: "Cross-provider merge fork", + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:fork-turn"), + threadId: forkThreadId, + messageId: MessageId.make("message:cross-provider-merge:fork-turn"), + text: forkPrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "thread.merge_back", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:merge"), + sourceThreadId: forkThreadId, + targetThreadId: sourceThreadId, + sourcePoint: { type: "latest_stable" }, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:consume"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:consume"), + text: mergePrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + ] satisfies ReadonlyArray; - const projection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[2]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[3]!); - yield* orchestrator.dispatch(commands[4]!); - yield* waitForIdle(forkThreadId); - yield* orchestrator.dispatch(commands[5]!); - if (failResume) { - // A persisted native ref that is not loaded in this process must - // exercise resume rather than the session manager's warm cache. - const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); - const codexThread = beforeResume.providerThreads.find( - (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, - )!; - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("unloaded-merge-target"), - type: "provider-thread.updated", - threadId: sourceThreadId, - occurredAt: yield* DateTime.now, - payload: { - ...codexThread, - nativeThreadRef: { - ...codexThread.nativeThreadRef!, - nativeId: "unloaded-native-merge-target", - }, - }, - }, - ], - }); - } - yield* orchestrator.dispatch(commands[6]!); - return yield* waitForIdle(sourceThreadId); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( + const projection = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch(commands[0]!); + yield* orchestrator.dispatch(commands[1]!); + yield* waitForIdle(sourceThreadId); + yield* orchestrator.dispatch(commands[2]!); + yield* waitForIdle(sourceThreadId); + yield* orchestrator.dispatch(commands[3]!); + yield* orchestrator.dispatch(commands[4]!); + yield* waitForIdle(forkThreadId); + yield* orchestrator.dispatch(commands[5]!); + if (failResume) { + // A persisted native ref that is not loaded in this process must + // exercise resume rather than the session manager's warm cache. + const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); + const codexThread = beforeResume.providerThreads.find( + (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, + )!; + yield* (yield* EventSink.EventSinkV2).write({ + events: [ { - name: "cross-provider-merge", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, + id: EventId.make("unloaded-merge-target"), + type: "provider-thread.updated", + threadId: sourceThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...codexThread, + nativeThreadRef: { + ...codexThread.nativeThreadRef!, + nativeId: "unloaded-native-merge-target", }, }, }, - registryLayer, - ), + ], + }); + } + yield* orchestrator.dispatch(commands[6]!); + return yield* waitForIdle(sourceThreadId); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: "cross-provider-merge", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + registryLayer, ), + ), + ); + const turns = yield* Ref.get(capturedTurns); + const mergedTurn = turns.findLast( + (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", + ); + const mergeTransfer = projection.contextTransfers.find( + (transfer) => transfer.type === "merge_back", + ); + + if (failResume) + assert.isAtLeast( + projection.contextHandoffs.filter( + (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, + ).length, + 2, ); - const turns = yield* Ref.get(capturedTurns); - const mergedTurn = turns.findLast( - (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", - ); - const mergeTransfer = projection.contextTransfers.find( - (transfer) => transfer.type === "merge_back", + assert.isDefined(mergedTurn); + if (failResume) + assert.notEqual( + projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId) + ?.nativeThreadRef?.nativeId, + "unloaded-native-merge-target", ); - - if (failResume) - assert.isAtLeast( - projection.contextHandoffs.filter( - (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, - ).length, - 2, - ); - assert.isDefined(mergedTurn); - if (failResume) - assert.notEqual( - projection.providerThreads.find( - (thread) => thread.id === mergedTurn.providerThreadId, - )?.nativeThreadRef?.nativeId, - "unloaded-native-merge-target", - ); - assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); - assert.include(mergedTurn.text, firstSourcePrompt); - assert.include(mergedTurn.text, "I will remember amber."); - assert.include(mergedTurn.text, secondSourcePrompt); - assert.include(mergedTurn.text, "I will remember violet."); - assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); - assert.include(mergedTurn.text, forkPrompt); - assert.include(mergedTurn.text, "I will remember cobalt."); - assert.include(mergedTurn.text, mergePrompt); - assert.isDefined(mergeTransfer); - assert.equal(mergeTransfer.status, "consumed"); - assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); - assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); - }), - ), - ); - } + assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); + assert.include(mergedTurn.text, firstSourcePrompt); + assert.include(mergedTurn.text, "I will remember amber."); + assert.include(mergedTurn.text, secondSourcePrompt); + assert.include(mergedTurn.text, "I will remember violet."); + assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); + assert.include(mergedTurn.text, forkPrompt); + assert.include(mergedTurn.text, "I will remember cobalt."); + assert.include(mergedTurn.text, mergePrompt); + assert.isDefined(mergeTransfer); + assert.equal(mergeTransfer.status, "consumed"); + assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); + assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); + }), + ), + ); it.live("routes two custom instances of the same driver independently", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts index 92837e4974a7..e22d847037ab 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts @@ -257,8 +257,9 @@ function makeCreateCommand(input: { } describe("orchestration V2 merge-back provider replay", () => { - for (const variant of PROVIDERS) { - it.effect(`merges one fork delta back into the original ${variant.driver} thread`, () => + it.effect.each(PROVIDERS)( + "merges one fork delta back into the original $driver thread", + (variant) => Effect.gen(function* () { const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver); const materialized = yield* Effect.gen(function* () { @@ -452,9 +453,11 @@ describe("orchestration V2 merge-back provider replay", () => { assert.notInclude(visibleConversationText(source), "Context handoff ("); assert.include(visibleConversationText(fork), "merge fork stored"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + ); - it.effect(`merges two sibling fork deltas into the original ${variant.driver} thread`, () => + it.effect.each(PROVIDERS)( + "merges two sibling fork deltas into the original $driver thread", + (variant) => Effect.gen(function* () { const rawTranscript = yield* readTranscript("thread_merge_back_siblings", variant.driver); const materialized = yield* Effect.gen(function* () { @@ -725,6 +728,5 @@ describe("orchestration V2 merge-back provider replay", () => { assert.include(visibleConversationText(secondFork), "second merge sibling stored"); assert.notInclude(visibleConversationText(secondFork), "first merge sibling stored"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - } + ); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index b053c2403401..fc43700b06a4 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -394,8 +394,9 @@ layer("OrchestrationEventStore", (it) => { ); }); -for (const phase of ["high-water", "replay"] as const) { - it.effect(`bounds application live events while the ${phase} query is blocked`, () => +it.effect.each(["high-water", "replay"] as const)( + "bounds application live events while the %s query is blocked", + (phase) => Effect.scoped( Effect.gen(function* () { const store = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -464,8 +465,7 @@ for (const phase of ["high-water", "replay"] as const) { } }), ).pipe(Effect.provide(Layer.fresh(TestLayer))), - ); -} +); it.effect("releases consumed application replay pages", () => Effect.gen(function* () { diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 88a393f8989e..ecaec10e5b4c 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -60,8 +60,9 @@ describe("V2 preview upgrade", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); - for (const withIndexes of [false, true]) { - it.effect(`upgrades preview migration 54 with index cleanup ${withIndexes}`, () => + it.effect.each([false, true])( + "upgrades preview migration 54 with index cleanup %s", + (withIndexes) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 52 }); @@ -89,8 +90,7 @@ describe("V2 preview upgrade", () => { }>`PRAGMA table_info(projection_threads)`; assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at")); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); - } + ); it.effect("rolls back schema and ledger together on failure and can retry", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 725314b27e1a..1bad9db0df10 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -426,36 +426,34 @@ it.effect( }), ), ); -for (const failure of ["identity", "sharing"] as const) { - it.effect( - `failed account change preserves the original credentials and registration: ${failure}`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - const before = h.storedRecords(); - h.setCallbackClientId("oaiapp_other_account"); - if (failure === "identity") h.setInvalidNonce(); - else h.declineSharing(); - yield* h.changeAccount; - yield* h.phase("failed"); - assert.strictEqual( - h.authorizationRequests[1]!.searchParams.get("client_id"), - "dynamic_agent_client", +it.effect.each(["identity", "sharing"] as const)( + "failed account change preserves the original credentials and registration: %s", + (failure) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_other_account"); + if (failure === "identity") h.setInvalidNonce(); + else h.declineSharing(); + yield* h.changeAccount; + yield* h.phase("failed"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + if (failure === "sharing") { + assert.deepEqual( + Option.getOrThrow(yield* h.auth.read), + before.find((record) => record.accessToken), ); - if (failure === "sharing") { - assert.deepEqual( - Option.getOrThrow(yield* h.auth.read), - before.find((record) => record.accessToken), - ); - assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); - } else assert.deepEqual(h.storedRecords(), before); - }), - ), - ); -} + assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); + } else assert.deepEqual(h.storedRecords(), before); + }), + ), +); it.effect("retains the callback host and path after controller recreation and token removal", () => provision( Effect.gen(function* () { @@ -961,48 +959,42 @@ it.effect("rejects mismatched callback state before any token exchange or creden }), ), ); -for (const invalidClaim of ["issuer", "audience", "signature"] as const) { - it.effect( - `rejects ID token ${invalidClaim} verification before saving tokens or registration`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - h.setIdentityFailure(invalidClaim); - yield* h.signIn; - assert.include((yield* h.phase("failed")).message!, "could not be verified"); - assert.strictEqual(h.exchanges.length, 1); - assert.deepEqual(h.storedRecords(), []); - assert.isTrue(Option.isNone(yield* h.auth.read)); - }), - ), - ); -} -for (const revoked of [false, true]) { - it.effect( - `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - if (revoked) yield* h.auth.revoke; - const before = h.storedRecords(); - h.setCallbackClientId("oaiapp_untrusted_callback"); - yield* h.signIn; - assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); - assert.strictEqual( - h.authorizationRequests[1]?.searchParams.get("client_id"), - "oaiapp_test", - ); - assert.strictEqual(h.exchanges.length, 1); - assert.deepEqual(h.storedRecords(), before); - assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); - }), - ), - ); -} +it.effect.each(["issuer", "audience", "signature"] as const)( + "rejects ID token %s verification before saving tokens or registration", + (invalidClaim) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setIdentityFailure(invalidClaim); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), []); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect.each([ + { revoked: false, label: "without changing the current account" }, + { revoked: true, label: "after token removal" }, +])("rejects conflicting callback client ID on reauthorization $label", ({ revoked }) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (revoked) yield* h.auth.revoke; + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_untrusted_callback"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); + assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); + }), + ), +); it.effect("returns successful desktop sign-in to the original Welcome step", () => provision( @@ -1157,26 +1149,25 @@ it.effect( ), ); -for (const disconnected of [false, true]) { - it.effect( - `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - if (disconnected) yield* h.auth.controller.logout(Effect.void); - const before = h.storedRecords(); - h.setIdentity("another-user", "another@example.test"); - yield* h.signIn; - assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); - assert.deepEqual(h.storedRecords(), before); - assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); - }), - ), - ); -} +it.effect.each([ + { disconnected: false, label: "while connected" }, + { disconnected: true, label: "after Disconnect" }, +])("rejects a different verified identity during saved-profile reauth $label", ({ disconnected }) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (disconnected) yield* h.auth.controller.logout(Effect.void); + const before = h.storedRecords(); + h.setIdentity("another-user", "another@example.test"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); + }), + ), +); it.effect( "retains both profiles and reuses the original account's client and callback when returning from another account", @@ -1427,7 +1418,7 @@ it.effect( ), ); -for (const code of [ +it.effect.each([ "invalid_grant", "invalid_refresh_token", "token_expired", @@ -1436,33 +1427,31 @@ for (const code of [ "refresh_token_reused", "invalid_client", "invalid_token", -]) { - it.effect(`refresh recovery follows the machine-readable code: ${code}`, () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.seedExpired; - h.setRefreshError(code); - yield* Effect.flip(h.auth.access); - assert.strictEqual( - Option.isNone(yield* h.auth.read), - !["invalid_client", "invalid_token"].includes(code), - ); - assert.isTrue( - h - .storedRecords() - .some((record) => - record.profiles?.some( - (profile: { clientId: string }) => profile.clientId === "oaiapp_test", - ), +])("refresh recovery follows the machine-readable code: %s", (code) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRefreshError(code); + yield* Effect.flip(h.auth.access); + assert.strictEqual( + Option.isNone(yield* h.auth.read), + !["invalid_client", "invalid_token"].includes(code), + ); + assert.isTrue( + h + .storedRecords() + .some((record) => + record.profiles?.some( + (profile: { clientId: string }) => profile.clientId === "oaiapp_test", ), - ); - }), - ), - ); -} + ), + ); + }), + ), +); it.effect( "logout revokes the latest refresh token with the selected client and clears its ID hint", diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index a0d22decc0c2..2cc54cf83285 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -92,8 +92,9 @@ const terminalState = (installation: CodexInstallation.CodexInstallation["Servic Effect.map(Option.getOrThrow), ); -for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { - it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () => +it.effect.each(["0.156.0", "0.156.1", "0.156.2", "0.157.0"])( + "reuses installed Codex %s without downloading or taking ownership of it", + (version) => Effect.gen(function* () { const h = yield* makeHarness({ local: { version } }); expect(yield* h.installation.start).toMatchObject({ @@ -120,9 +121,8 @@ for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); expect((yield* h.installation.state).source).toBe("local"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} -for (const local of [ +); +it.effect.each([ { version: "0.128.9" }, { version: "0.145.0" }, { version: "0.155.1" }, @@ -131,24 +131,20 @@ for (const local of [ { version: "unknown" }, { version: "0.156.1", appServerFails: true }, { version: "0.156.1", versionFails: true }, -]) { - it.effect( - `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`, - () => - Effect.gen(function* () { - const h = yield* makeHarness({ local }); - expect((yield* h.installation.state).installedVersion).toBeNull(); - yield* h.installation.start; - const installed = yield* terminalState(h.installation); - expect(installed.phase).toBe("succeeded"); - const executable = yield* h.installation.resolve(); - expect(executable.source).toBe("managed"); - expect(installed.executablePath).toBe(executable.executablePath); - expect(h.downloads()).toBe(1); - expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} +])("downloads the pinned release when the local CLI is unsupported or broken: %j", (local) => + Effect.gen(function* () { + const h = yield* makeHarness({ local }); + expect((yield* h.installation.state).installedVersion).toBeNull(); + yield* h.installation.start; + const installed = yield* terminalState(h.installation); + expect(installed.phase).toBe("succeeded"); + const executable = yield* h.installation.resolve(); + expect(executable.source).toBe("managed"); + expect(installed.executablePath).toBe(executable.executablePath); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); it.effect("falls back to a managed download when the reused local executable disappears", () => Effect.gen(function* () { const h = yield* makeHarness({ local: { version: "0.156.1" } }); diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 33ccaa13ea8c..494ff88a2299 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -48,178 +48,174 @@ it.effect("managed home defaults to the global Codex home and honors configured assert.equal(configured.effectiveHomePath, "/custom/shadow"); }).pipe(Effect.provide(NodeServices.layer)), ); -for (const source of ["managed", "local"] as const) - for (const account of ["primary", "additional"] as const) - it.effect( - `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`, - () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make( - account === "primary" ? "codex" : "codex-personal", - ); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); - yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); - yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); - const data = new Map(); - const secrets = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), - set: (key, value) => - Effect.sync(() => { - data.set(key, value); - }), - remove: (key) => +it.effect.each( + (["managed", "local"] as const).flatMap((source) => + (["primary", "additional"] as const).map((account) => ({ source, account })), + ), +)( + "$source Codex $account account shares home state without routing owned tokens through ambient CLI overrides", + ({ source, account }) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make( + account === "primary" ? "codex" : "codex-personal", + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); + yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); + yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); + const data = new Map(); + const secrets = ServerSecretStore.ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), + set: (key, value) => + Effect.sync(() => { + data.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + data.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + let leases = 0; + const executable = { + executablePath: + source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", + managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, + source, + version: "0.156.1", + }; + const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "/isolated/tools/codex", + resolve: () => Effect.succeed(executable), + acquire: () => + Effect.gen(function* () { + leases++; + yield* Effect.addFinalizer(() => Effect.sync(() => { - data.delete(key); + leases--; }), - create: () => Effect.die("unused"), - getOrCreateRandom: () => Effect.die("unused"), - }); - let leases = 0; - const executable = { - executablePath: - source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", - managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, - source, - version: "0.156.1", - }; - const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "/isolated/tools/codex", - resolve: () => Effect.succeed(executable), - acquire: () => - Effect.gen(function* () { - leases++; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - leases--; - }), - ); - return executable; - }), - }); - yield* Effect.gen(function* () { - const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); - const json = yield* encodeJson({ - clientId: "oaiapp_test", - accessToken: "dummy-owned-access", - refreshToken: "dummy-refresh", - expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, - earliestRefreshAt: null, - scopes: ["chatgpt.tokens.use.direct"], - subject: "test-user", - email: null, - }); - yield* store.set(new TextEncoder().encode(json)); - const ambient = { - CODEX_HOME: "/user/.codex", - OPENAI_API_KEY: "dummy-global-key", - OPENAI_BASE_URL: "https://user-proxy.test", - T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", - PATH: "/usr/bin", - }; - const runtime = yield* makeCodexManagedRuntime({ - instanceId, - enabled: true, - config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), - environment: ambient, - }); - yield* Effect.gen(function* () { - const effective = yield* runtime.resolve; - assert.strictEqual(leases, 1); - assert.strictEqual(effective.config.binaryPath, executable.executablePath); - assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); - assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); - if (account === "primary") { - assert.equal(effective.config.homePath, sharedHome); - assert.equal(runtime.homeLayout.mode, "direct"); - } else { - assert.include(effective.config.homePath, instanceId); - assert.include(effective.config.homePath, "userdata/providers/codex"); - assert.equal(runtime.homeLayout.mode, "authOverlay"); - assert.equal( - yield* fs.readLink(path.join(effective.config.homePath, "sessions")), - path.join(sharedHome, "sessions"), - ); - assert.equal( - yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), - path.join(sharedHome, "config.toml"), - ); - assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); - } - assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); - assert.isUndefined(effective.environment.OPENAI_API_KEY); - assert.isUndefined(effective.environment.OPENAI_BASE_URL); - assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); - const args = codexAppServerArgs(effective.config.launchArgs); - assert.include( - args, - 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', - ); - assert.include( - args, - 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', - ); - assert.include(args, "features.api_key_model_discovery=true"); - assert.notInclude(effective.config.launchArgs, "model_catalog_json"); - assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); - assert.include( - args, - "model_providers.openai_token_sharing.supports_websockets=false", - ); - assert.include( - args, - "model_providers.openai_token_sharing.requires_openai_auth=false", - ); - assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); - assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); - }).pipe(Effect.scoped); - assert.strictEqual(leases, 0); - yield* runtime.auth.controller.logout(Effect.void); + ); + return executable; + }), + }); + yield* Effect.gen(function* () { + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const json = yield* encodeJson({ + clientId: "oaiapp_test", + accessToken: "dummy-owned-access", + refreshToken: "dummy-refresh", + expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: null, + }); + yield* store.set(new TextEncoder().encode(json)); + const ambient = { + CODEX_HOME: "/user/.codex", + OPENAI_API_KEY: "dummy-global-key", + OPENAI_BASE_URL: "https://user-proxy.test", + T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", + PATH: "/usr/bin", + }; + const runtime = yield* makeCodexManagedRuntime({ + instanceId, + enabled: true, + config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), + environment: ambient, + }); + yield* Effect.gen(function* () { + const effective = yield* runtime.resolve; + assert.strictEqual(leases, 1); + assert.strictEqual(effective.config.binaryPath, executable.executablePath); + assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); + assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); + if (account === "primary") { + assert.equal(effective.config.homePath, sharedHome); + assert.equal(runtime.homeLayout.mode, "direct"); + } else { + assert.include(effective.config.homePath, instanceId); + assert.include(effective.config.homePath, "userdata/providers/codex"); + assert.equal(runtime.homeLayout.mode, "authOverlay"); assert.equal( - yield* fs.readFileString(path.join(sharedHome, "auth.json")), - "native-auth-unchanged", + yield* fs.readLink(path.join(effective.config.homePath, "sessions")), + path.join(sharedHome, "sessions"), ); - }).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - Effect.provide(installerLayer), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - assert.isTrue( - [ - "https://auth.openai.com/.well-known/openid-configuration", - "https://auth.openai.com/revoke", - ].includes(request.url), - ); - return HttpClientResponse.fromWeb( - request, - request.url.endsWith("/revoke") - ? new Response(null, { status: 200 }) - : Response.json({ - issuer: "https://auth.openai.com", - authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", - token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", - jwks_uri: "https://auth.openai.com/jwks", - revocation_endpoint: "https://auth.openai.com/revoke", - }), - ); - }), - ), - ), + assert.equal( + yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), + path.join(sharedHome, "config.toml"), + ); + assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); + } + assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); + assert.isUndefined(effective.environment.OPENAI_API_KEY); + assert.isUndefined(effective.environment.OPENAI_BASE_URL); + assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); + const args = codexAppServerArgs(effective.config.launchArgs); + assert.include( + args, + 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', + ); + assert.include( + args, + 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', ); - }).pipe( - Effect.scoped, - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-managed-runtime-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + assert.include(args, "features.api_key_model_discovery=true"); + assert.notInclude(effective.config.launchArgs, "model_catalog_json"); + assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); + assert.include(args, "model_providers.openai_token_sharing.supports_websockets=false"); + assert.include(args, "model_providers.openai_token_sharing.requires_openai_auth=false"); + assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); + assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); + }).pipe(Effect.scoped); + assert.strictEqual(leases, 0); + yield* runtime.auth.controller.logout(Effect.void); + assert.equal( + yield* fs.readFileString(path.join(sharedHome, "auth.json")), + "native-auth-unchanged", + ); + }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + Effect.provide(installerLayer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + assert.isTrue( + [ + "https://auth.openai.com/.well-known/openid-configuration", + "https://auth.openai.com/revoke", + ].includes(request.url), + ); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith("/revoke") + ? new Response(null, { status: 200 }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), + ); + }), ), ), - ); + ); + }).pipe( + Effect.scoped, + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-managed-runtime-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), +); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index d881b5f22a40..32f207e2b62b 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -33,8 +33,9 @@ const mockRuntimeOptions = { } satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; describe("AcpSessionRuntime", () => { - for (const setupMethod of ["session/new", "session/resume"] as const) { - it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () => + it.effect.each(["session/new", "session/resume"] as const)( + "buffers root metadata while %s startup is still pending", + (setupMethod) => Effect.gen(function* () { const setupReplied = yield* Deferred.make(); const allowStartup = yield* Deferred.make(); @@ -91,8 +92,7 @@ describe("AcpSessionRuntime", () => { (yield* runtime.getConfigOptions).find((option) => option.category === "model"), ).toMatchObject({ currentValue: "gpt-5.4" }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - } + ); it.effect("publishes model changes returned by a config request and live notifications", () => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0460eb82f8f2..7415ae45bfae 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2287,7 +2287,7 @@ it.effect("routes Azure reads and writes through the requested organization's ch }), ); -for (const checkout of [ +it.effect.each([ { host: "ssh.dev.azure.com", repository: "v3/org-b/project/web", @@ -2303,57 +2303,55 @@ for (const checkout of [ repository: "DefaultCollection/project/_git/web", remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", }, -]) { - it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () => - Effect.gen(function* () { - const seen: string[] = []; - const target = project({ - id: "target", - title: "target", - workspaceRoot: "/target", - provider: "azure-devops", - ...checkout, - }); - const service = yield* makeService({ - projects: [ - ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => - project({ - id: repository, - title: repository, - workspaceRoot: `/${repository}`, - provider: "azure-devops", - host: "dev.azure.com", - repository, - }), - ), - target, - ], - providers: [ - fakeProvider("azure-devops", { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), +])("routes Azure URL reads and writes through a $host checkout", (checkout) => + Effect.gen(function* () { + const seen: string[] = []; + const target = project({ + id: "target", + title: "target", + workspaceRoot: "/target", + provider: "azure-devops", + ...checkout, + }); + const service = yield* makeService({ + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: "azure-devops", + host: "dev.azure.com", + repository, }), - ], - }); - const reference = { - projectId: "org-a/project/_git/web" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); - }), - ); -} + ), + target, + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a/project/_git/web" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); + }), +); it.effect("refuses Azure cross-organization reads and writes without its checkout", () => Effect.gen(function* () { @@ -5042,104 +5040,102 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho }), ); -for (const crossHost of [false, true]) { - it.effect( - `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`, - () => - Effect.gen(function* () { - let taken = 0; - let summaryReads = 0; - let mutationFails = false; - let stackRebase = true; - let stackActions = true; - const capabilities = { - diff: true, - comment: true, - actions: ["update-branch"] as const, - mergeMethods: ["merge"] as const, - updateMethods: ["rebase"] as const, - get stackActions() { - return stackActions; - }, - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/b", - repository: "acme/web", - host: "enterprise.test", - }), - ], - providers: [ - fakeProvider("github", { - capabilities, - getViewerPermissions: () => - Effect.succeed({ - actions: [], - stackRebase, - comment: true, - resolve: false, - verdicts: [], - requestReviewers: false, - }), - getChangeRequestSummary: () => - Effect.sync(() => { - summaryReads++; - return changeRequest(8, "2026-07-01T00:00:00Z"); - }), - runAction: () => - Effect.gen(function* () { - taken++; - if (mutationFails) return yield* requestFailed; - }), - }), - ], - }); - const input = { - ...(crossHost ? { host: "enterprise.test" } : {}), - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 3, - action: "update-branch" as const, - updateMethod: "rebase" as const, - stackNumber: 50, - expectedStackHeads: [{ number: 3, headSha: "ccc" }], - }; - yield* service.runAction(input); - assert.strictEqual(taken, 1); - const unrelated = { ...input, number: 8 }; - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackRebase = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - stackRebase = true; - stackActions = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - assert.strictEqual(taken, 1); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackActions = true; - mutationFails = true; - yield* Effect.flip(service.runAction(input)); - assert.strictEqual(taken, 2); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 2); - }), - ); -} +it.effect.each([false, true])( + "authorizes stack rebases and refreshes sibling layers (cross-host: %s)", + (crossHost) => + Effect.gen(function* () { + let taken = 0; + let summaryReads = 0; + let mutationFails = false; + let stackRebase = true; + let stackActions = true; + const capabilities = { + diff: true, + comment: true, + actions: ["update-branch"] as const, + mergeMethods: ["merge"] as const, + updateMethods: ["rebase"] as const, + get stackActions() { + return stackActions; + }, + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/b", + repository: "acme/web", + host: "enterprise.test", + }), + ], + providers: [ + fakeProvider("github", { + capabilities, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + getChangeRequestSummary: () => + Effect.sync(() => { + summaryReads++; + return changeRequest(8, "2026-07-01T00:00:00Z"); + }), + runAction: () => + Effect.gen(function* () { + taken++; + if (mutationFails) return yield* requestFailed; + }), + }), + ], + }); + const input = { + ...(crossHost ? { host: "enterprise.test" } : {}), + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), +); it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 17a77c126b28..5dea39e470ef 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1245,77 +1245,78 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(settingsLayer)); }); - for (const { label, variable, expected, duplicate } of [ - { - label: "preserves an inline secret on a redacted settings save", - variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, - expected: "inline-test-token", - }, - { - label: "preserves the effective last inline secret when names are duplicated", - variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, - expected: "last-inline-test-token", - duplicate: true, - }, - { - label: "replaces an inline secret with an explicit value", - variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true }, - expected: "replacement-test-token", - }, - { - label: "clears an inline secret with an explicit empty value", - variable: { name: "API_TOKEN", value: "", sensitive: true }, - expected: "", - }, - ]) { - it.effect(label, () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("codex_personal"); - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - duplicate - ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}' - : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}', - ); - const initial = yield* serverSettings.getSettings; - assert.equal( - initial.providerInstances[instanceId]?.environment?.[0]?.value, - "inline-test-token", - ); + it.effect.each( + [ + { + label: "preserves an inline secret on a redacted settings save", + variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, + expected: "inline-test-token", + }, + { + label: "preserves the effective last inline secret when names are duplicated", + variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, + expected: "last-inline-test-token", + duplicate: true, + }, + { + label: "replaces an inline secret with an explicit value", + variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true }, + expected: "replacement-test-token", + }, + { + label: "clears an inline secret with an explicit empty value", + variable: { name: "API_TOKEN", value: "", sensitive: true }, + expected: "", + }, + ].map((testCase) => [testCase.label, testCase] as const), + )("%s", ([, { variable, expected, duplicate }]) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_personal"); + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + duplicate + ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}' + : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}', + ); + const initial = yield* serverSettings.getSettings; + assert.equal( + initial.providerInstances[instanceId]?.environment?.[0]?.value, + "inline-test-token", + ); - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Renamed provider", - environment: duplicate ? [variable, variable] : [variable], - config: {}, - }, + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Renamed provider", + environment: duplicate ? [variable, variable] : [variable], + config: {}, }, - }); - assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "inline-test-token"); - assert.notInclude(raw, "replacement-test-token"); - - const reloaded = yield* Effect.gen(function* () { - const fresh = yield* ServerSettingsModule.ServerSettingsService; - return yield* fresh.getSettings; - }).pipe( - Effect.provide( - Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), - ), - ); - assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); - }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - } + }, + }); + assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "inline-test-token"); + assert.notInclude(raw, "replacement-test-token"); + + const reloaded = yield* Effect.gen(function* () { + const fresh = yield* ServerSettingsModule.ServerSettingsService; + return yield* fresh.getSettings; + }).pipe( + Effect.provide( + Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), + ), + ); + assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); - for (const sensitiveLast of [true, false]) { - it.effect(`preserves duplicate secret operation order (sensitive last: ${sensitiveLast})`, () => + it.effect.each([true, false])( + "preserves duplicate secret operation order (sensitive last: %s)", + (sensitiveLast) => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("codex_duplicate"); @@ -1339,8 +1340,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { sensitiveLast ? "secret-last" : "", ); }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - } + ); it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { @@ -1630,8 +1630,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), ); - for (const failure of ["response materialization", "partially committed write"] as const) { - it.effect(`rolls back provider secret changes after ${failure} fails`, () => { + it.effect.each(["response materialization", "partially committed write"] as const)( + "rolls back provider secret changes after %s fails", + (failure) => { const textDecoder = new TextDecoder(); const secrets = new Map(); let rejectNewSecret = false; @@ -1734,8 +1735,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { "sk-kept", ); }).pipe(Effect.provide(settingsLayer)); - }); - } + }, + ); it.effect("folds legacy project overrides into projectSettingsOverrides once", () => Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 2a214fc800ff..751420dfc5a1 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -408,8 +408,9 @@ it("reports an update hint instead of unauthenticated when gh predates --json", ); }); -for (const kind of ["pull", "issues"]) { - it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () => +it.effect.each(["pull", "issues"])( + "resolves %s subjects on the linked host without using the checkout", + (kind) => Effect.gen(function* () { const provider = yield* makeProvider({ execute: (input) => { @@ -449,11 +450,11 @@ for (const kind of ["pull", "issues"]) { undefined, ); }), - ); -} +); -for (const stage of ["read", "decode"] as const) { - it.effect(`retains the ${stage} failure without exposing its raw contents`, () => +it.effect.each(["read", "decode"] as const)( + "retains the %s failure without exposing its raw contents", + (stage) => Effect.gen(function* () { const cause = new GitHubCli.GitHubCliCommandError({ command: "gh", @@ -484,5 +485,4 @@ for (const stage of ["read", "decode"] as const) { if (stage === "read") assert.strictEqual(error.cause, cause); else assert.propertyVal(error.cause, "_tag", "SchemaError"); }), - ); -} +); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index deca59c48b90..de0e6bad76c0 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -227,8 +227,9 @@ selfhosted ); }); -for (const kind of ["merge_requests", "issues"]) { - it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () => +it.effect.each(["merge_requests", "issues"])( + "resolves %s subjects on the linked host without using the checkout", + (kind) => Effect.gen(function* () { const provider = yield* makeProvider({ execute: (input) => { @@ -269,11 +270,11 @@ for (const kind of ["merge_requests", "issues"]) { undefined, ); }), - ); -} +); -for (const stage of ["read", "decode"] as const) { - it.effect(`retains the ${stage} failure without exposing its raw contents`, () => +it.effect.each(["read", "decode"] as const)( + "retains the %s failure without exposing its raw contents", + (stage) => Effect.gen(function* () { const cause = new GitLabCli.GitLabCliCommandError({ command: "glab", @@ -305,5 +306,4 @@ for (const stage of ["read", "decode"] as const) { if (stage === "read") assert.strictEqual(error.cause, cause); else assert.propertyVal(error.cause, "_tag", "SchemaError"); }), - ); -} +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 29edb395b908..b2b379edb85d 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1461,8 +1461,9 @@ it.layer( }), ); - for (const source of ["current", "legacy"] as const) { - it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () => + it.effect.each(["current", "legacy"] as const)( + "reads only a Unicode-safe tail from oversized %s history", + (source) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1513,8 +1514,7 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré"); }), - ); - } + ); it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => Effect.gen(function* () { diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 8107e19e6165..7c6101dfa38e 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -110,8 +110,9 @@ it.effect("waits for the Windows PID without requiring output", () => }).pipe(Effect.provide(testLayer)), ); -for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { - it.effect(`fails Windows startup on ${failure} and cleans up`, () => +it.effect.each(["exit", "close", "error", "invalid-pid"] as const)( + "fails Windows startup on %s and cleans up", + (failure) => Effect.gen(function* () { const { nativeProcess, subscribed } = preparePendingProcess(); const adapter = yield* PtyAdapter.PtyAdapter; @@ -129,8 +130,7 @@ for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { assert.equal(nativeProcess.events.listenerCount("exit"), 0); assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); }).pipe(Effect.provide(testLayer)), - ); -} +); it.effect("cancels the Windows connection without waiting for output", () => Effect.gen(function* () { @@ -160,8 +160,9 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", () }).pipe(Effect.provide(testLayer)), ); -for (const platform of ["win32", "linux", "darwin"] as const) { - it.effect(`terminates through node-pty using ${platform} semantics`, () => +it.effect.each(["win32", "linux", "darwin"] as const)( + "terminates through node-pty using %s semantics", + (platform) => Effect.gen(function* () { const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ @@ -189,8 +190,7 @@ for (const platform of ["win32", "linux", "darwin"] as const) { : [["SIGTERM"], ["SIGKILL"], [undefined]], ); }).pipe(Effect.provide(makeTestLayer(platform))), - ); -} +); it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { @@ -279,8 +279,9 @@ it.effect("reports native module load failures as structured startup defects", ( ), ); -for (const budget of [2048, 8]) { - it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () => +it.effect.each([2048, 8])( + "preserves an exit during readiness handoff with scheduler budget %s", + (budget) => Effect.gen(function* () { const { nativeProcess, subscribed } = preparePendingProcess(); const adapter = yield* PtyAdapter.PtyAdapter; @@ -300,8 +301,7 @@ for (const budget of [2048, 8]) { yield* Fiber.join(fiber); assert.equal(exits.length, 1); }).pipe(Effect.provide(testLayer)), - ); -} +); it.effect("replays an exit to late subscribers and respects unsubscription", () => Effect.gen(function* () { @@ -320,8 +320,9 @@ it.effect("replays an exit to late subscribers and respects unsubscription", () }).pipe(Effect.provide(testLayer)), ); -for (const failure of ["spawn", "interrupt"] as const) { - it.effect(`logs cleanup failures without replacing ${failure}`, () => +it.effect.each(["spawn", "interrupt"] as const)( + "logs cleanup failures without replacing %s", + (failure) => Effect.gen(function* () { const { nativeProcess, subscribed } = preparePendingProcess(); const killError = new Error("native kill failed"); @@ -361,5 +362,4 @@ for (const failure of ["spawn", "interrupt"] as const) { assert.equal(nativeProcess.events.listenerCount("exit"), 0); assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); }).pipe(Effect.provide(testLayer)), - ); -} +); diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index 76629cbf6181..58fa9e3d74d6 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -11,69 +11,67 @@ import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/u import { withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { - for (const { tag, size, limit } of [ + it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, - ]) { - it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () => - Effect.gen(function* () { - const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); - const output = yield* Queue.unbounded(); - const responses = yield* Queue.unbounded(); - const receive = yield* Deferred.make[0]>(); - const protocol = yield* RpcServer.Protocol.make((write) => - Effect.gen(function* () { - yield* Deferred.succeed(receive, write); - const serialization = yield* RpcSerialization.RpcSerialization; - return { - disconnects: yield* Queue.unbounded(), - send: (_clientId, response) => Queue.offer(responses, response), - end: () => Effect.void, - clientIds: Effect.succeed(new Set([0])), - initialMessage: Effect.succeedNone, - supportsAck: true, - supportsTransferables: false, - supportsSpanPropagation: false, - supportsNotifications: true, - codecFor: serialization.codecFor, - }; - }), - ); - yield* RpcServer.make(group).pipe( - Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), - Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), - Effect.forkScoped, - ); - const write = yield* Deferred.await(receive); - yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => + Effect.gen(function* () { + const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); + const output = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (_clientId, response) => Queue.offer(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); - for (let index = 0; index < limit; index++) { - const value = String(index).repeat(size); - yield* Queue.offer(output, value); - yield* TestClock.adjust(0); - assert.equal(yield* Queue.size(responses), 1); - assert.deepEqual(yield* Queue.take(responses), { - _tag: "Chunk", - requestId: "1", - values: [value], - }); - } - yield* Queue.offer(output, "i".repeat(size)); + for (let index = 0; index < limit; index++) { + const value = String(index).repeat(size); + yield* Queue.offer(output, value); yield* TestClock.adjust(0); - assert.equal(yield* Queue.size(responses), 0); - yield* write(0, { _tag: "Ack", requestId: "1" }); - const resumed = yield* Queue.take(responses); - assert.equal(resumed._tag, "Chunk"); - if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); + assert.equal(yield* Queue.size(responses), 1); + assert.deepEqual(yield* Queue.take(responses), { + _tag: "Chunk", + requestId: "1", + values: [value], + }); + } + yield* Queue.offer(output, "i".repeat(size)); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Ack", requestId: "1" }); + const resumed = yield* Queue.take(responses); + assert.equal(resumed._tag, "Chunk"); + if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); - yield* Queue.offer(output, "j"); - yield* TestClock.adjust(0); - assert.equal(yield* Queue.size(responses), 0); - yield* write(0, { _tag: "Interrupt", requestId: "1" }); - assert.equal((yield* Queue.take(responses))._tag, "Exit"); - }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), - ); - } + yield* Queue.offer(output, "j"); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Interrupt", requestId: "1" }); + assert.equal((yield* Queue.take(responses))._tag, "Exit"); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8fe5152d3450..fc51a7248ad3 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -461,69 +461,69 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { }), ); - for (const verbose of [false, true]) { - it.effect(`unwraps a JSON title in ${verbose ? "verbose" : "normal"} Claude output`, () => { - const result = { - type: "result", - structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, - }; - return withFakeClaudeEnv( - { output: JSON.stringify(verbose ? [result] : result) }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Refresh ev-stg APP ASG instances", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); + it.effect.each([ + { verbose: false, label: "normal" }, + { verbose: true, label: "verbose" }, + ])("unwraps a JSON title in $label Claude output", ({ verbose }) => { + const result = { + type: "result", + structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, + }; + return withFakeClaudeEnv( + { output: JSON.stringify(verbose ? [result] : result) }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Refresh ev-stg APP ASG instances", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); - expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); - }), - ); - }); - } + expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); + }), + ); + }); - for (const previousTitle of [undefined, "Old thread title"]) { - it.effect( - `reads the result from verbose Claude output when ${previousTitle ? "regenerating" : "generating"} a title`, - () => - withFakeClaudeEnv( + it.effect.each([ + { previousTitle: undefined, label: "generating" }, + { previousTitle: "Old thread title", label: "regenerating" }, + ])("reads the result from verbose Claude output when $label a title", ({ previousTitle }) => + withFakeClaudeEnv( + { + output: JSON.stringify([ + { type: "system", subtype: "init" }, + { type: "assistant", message: { content: [] } }, + { type: "user", message: { content: [] } }, + { type: "rate_limit_event" }, { - output: JSON.stringify([ - { type: "system", subtype: "init" }, - { type: "assistant", message: { content: [] } }, - { type: "user", message: { content: [] } }, - { type: "rate_limit_event" }, - { - type: "result", - subtype: "success", - result: '{"title":"Refresh ev-stg APP ASG Instances"}', - structured_output: { title: "Refresh ev-stg APP ASG Instances" }, - }, - ]), + type: "result", + subtype: "success", + result: '{"title":"Refresh ev-stg APP ASG Instances"}', + structured_output: { title: "Refresh ev-stg APP ASG Instances" }, }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Refresh ev-stg APP ASG instances", - previousTitle, - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); - - expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); - }), - ), - ); - } + ]), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Refresh ev-stg APP ASG instances", + previousTitle, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); - for (const [name, output] of [ + expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); + }), + ), + ); + + it.effect.each([ ["empty message array", []], ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]], ["invalid title", [{ type: "result", structured_output: { title: 42 } }]], @@ -534,26 +534,24 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { { type: "result", subtype: "error_max_structured_output_retries" }, ], ], - ] as const) { - it.effect(`rejects verbose Claude output with ${name}`, () => - withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }), - ); + ] as const)("rejects verbose Claude output with %s", ([, output]) => + withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }), + ); - expect(error._tag).toBe("TextGenerationError"); - }), - ), - ); - } + expect(error._tag).toBe("TextGenerationError"); + }), + ), + ); it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => withFakeClaudeEnv( diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 6d1ecbfdcf3f..98f0c9836e47 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -170,8 +170,9 @@ function withFakeCodexEnv( } it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { - for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) { - it.effect(`dispatches the qualified live model for ${selectedModel}`, () => + it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])( + "dispatches the qualified live model for %s", + (selectedModel) => withFakeCodexEnv( { output: JSON.stringify({ title: "Bedrock title" }), @@ -189,8 +190,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { expect(result.title).toBe("Bedrock title"); }), ), - ); - } + ); it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { @@ -406,7 +406,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); - for (const example of [ + it.effect.each([ { mode: "static", output: "Add Search", @@ -425,30 +425,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { expected: "Julius/ABC-123.v2", instruction: "Preserve the issue ID and capitalization.", }, - ] as const) { - it.effect(`generates a branch using ${example.mode} naming`, () => - withFakeCodexEnv( - { - output: JSON.stringify({ branch: example.output }), - stdinMustContain: example.instruction, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Add search", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - naming: { - mode: example.mode, - prefix: "team/", - instructions: "Preserve the issue ID and capitalization.", - }, - }); - expect(generated.branch).toBe(example.expected); - }), - ), - ); - } + ] as const)("generates a branch using $mode naming", (example) => + withFakeCodexEnv( + { + output: JSON.stringify({ branch: example.output }), + stdinMustContain: example.instruction, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Add search", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + naming: { + mode: example.mode, + prefix: "team/", + instructions: "Preserve the issue ID and capitalization.", + }, + }); + expect(generated.branch).toBe(example.expected); + }), + ), + ); it.effect("generates branch names even when the ambient scope is already closed", () => withFakeCodexEnv( diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index fff76988e977..684d173463f1 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -253,8 +253,9 @@ describe("CursorTextGeneration", () => { }).pipe(Effect.provide(fsLayer)), ); - for (const status of ["error", "cancelled"] as const) { - it.effect(`rejects a ${status} Cursor SDK run that includes valid title JSON`, () => + it.effect.each(["error", "cancelled"] as const)( + "rejects a %s Cursor SDK run that includes valid title JSON", + (status) => Effect.gen(function* () { const promptResult = { id: "run-cursor-partial-title-test", @@ -284,8 +285,7 @@ describe("CursorTextGeneration", () => { ); expect(cursorSdkMock.close).toHaveBeenCalledOnce(); }).pipe(Effect.provide(fsLayer)), - ); - } + ); it.effect("fails closed when ambient sandbox policy can expand write access", () => Effect.gen(function* () { @@ -329,8 +329,9 @@ describe("CursorTextGeneration", () => { }).pipe(Effect.provide(fsLayer), Effect.scoped), ); - for (const phase of ["create", "send"] as const) { - it.effect(`times out pending ${phase} and releases its late SDK resource`, () => + it.effect.each(["create", "send"] as const)( + "times out pending %s and releases its late SDK resource", + (phase) => Effect.gen(function* () { let started!: () => void; const called = new Promise((resolve) => { @@ -388,8 +389,7 @@ describe("CursorTextGeneration", () => { expect(wait).not.toHaveBeenCalled(); if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); }).pipe(Effect.provide(fsLayer), Effect.scoped), - ); - } + ); it.effect("requires CURSOR_API_KEY before calling the SDK", () => Effect.gen(function* () { diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index f7286a11b60d..29fcecdc234e 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -121,95 +121,96 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { - for (const explicitDefault of [true, false]) { - it.live( - `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`, - () => - Effect.gen(function* () { - const { home, settings } = yield* setup; - const summary = yield* Effect.gen(function* () { - for (const [id, output] of [ - ["codex", 17], - ["codex-personal", 23], - ] as const) { - const sessions = NodePath.join(home, "shared-codex", "sessions"); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(sessions, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(sessions, `${id}-rollout.jsonl`), - [ - { type: "session_meta", payload: { id } }, - { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { - type: "event_msg", - timestamp: "2026-08-01T10:00:00Z", - payload: { - type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, - }, + it.live.each([ + { explicitDefault: true, label: "explicit" }, + { explicitDefault: false, label: "legacy" }, + ])( + "reads shared managed $label default and disabled extra account history once", + ({ explicitDefault }) => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const summary = yield* Effect.gen(function* () { + for (const [id, output] of [ + ["codex", 17], + ["codex-personal", 23], + ] as const) { + const sessions = NodePath.join(home, "shared-codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, `${id}-rollout.jsonl`), + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, }, - ] - .map((line) => encodeUnknownJsonString(line)) - .join("\n") + "\n", - ); - }); - } - const service = yield* UsageService.make; - return yield* service.readSummary(WINDOW); - }).pipe( - Effect.provide( - serviceLayers({ - prefix: "usage-managed-accounts", - home, - settings: { - ...settings, - providers: { - ...settings.providers, - codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, }, - providerInstances: { - ...(explicitDefault - ? { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + } + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-managed-accounts", + home, + settings: { + ...settings, + providers: { + ...settings.providers, + codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, + }, + providerInstances: { + ...(explicitDefault + ? { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), }, - } - : {}), - [ProviderInstanceId.make("codex-personal")]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - shadowHomePath: NodePath.join(home, "personal-shadow"), - }, - environment: [ - { - name: "CODEX_HOME", - value: NodePath.join(home, "ignored-environment"), - sensitive: false, }, - ], + } + : {}), + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + shadowHomePath: NodePath.join(home, "personal-shadow"), }, + environment: [ + { + name: "CODEX_HOME", + value: NodePath.join(home, "ignored-environment"), + sensitive: false, + }, + ], }, }, - }), - ), - ); - assert.strictEqual(totalOutputTokens(summary), 40); - assert.strictEqual( - summary.sources.filter( - (source) => source.fingerprint.provider === "codex" && source.status === "ok", - ).length, - 1, - ); - }).pipe(Effect.scoped), - ); - } + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 40); + assert.strictEqual( + summary.sources.filter( + (source) => source.fingerprint.provider === "codex" && source.status === "ok", + ).length, + 1, + ); + }).pipe(Effect.scoped), + ); it.live("omits Cursor account usage when no file login is saved", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b0fee3638282..10ce966ef1e2 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -234,22 +234,20 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - for (const [code, outcome] of [ + it.effect.each([ ["nothing_to_reset", "nothingToReset"], ["no_credit", "noCredit"], ["already_redeemed", "alreadyRedeemed"], - ] as const) { - it.effect(`reports ${code} accurately`, () => - Effect.gen(function* () { - const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); - const api = yield* test.api; - expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); - expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( - code === "already_redeemed", - ); - }), - ); - } + ] as const)("reports %s accurately", ([code, outcome]) => + Effect.gen(function* () { + const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); + const api = yield* test.api; + expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); + expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( + code === "already_redeemed", + ); + }), + ); it.effect("reports redemption success even if cooldown clearing fails", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 1bba13cd4bd9..6b8920501281 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -392,8 +392,9 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), ); -for (const blockedPhase of ["discovery", "probe", "retry"] as const) { - it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () => +it.effect.each(["discovery", "probe", "retry"] as const)( + "checkpoint recovery has one deadline including %s", + (blockedPhase) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -478,8 +479,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) { assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} +); it.effect("checkpoint recovery preserves interruption and removes the private index", () => Effect.gen(function* () { @@ -548,116 +548,103 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -for (const nested of [false, true]) { - for (const indexState of [ - "sparse", - "flags", - "manual-skip", - "missing", - "non-cone-missing", - ] as const) { - it.effect( - `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = Effect.fn(function* (name: string, contents: string) { - yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); - yield* fs.writeFileString(path.join(cwd, name), contents); - }); - for (const name of [ - "scope/in/edit", - "scope/in/delete", - "scope/out/deep/absent", - "scope/out/present", - "elsewhere/file", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "sparse fixture"]); - yield* git([ - "sparse-checkout", - "set", - "--cone", - "--sparse-index", - "scope/in", - "elsewhere", - ]); - if (indexState === "non-cone-missing") - yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); - yield* write("scope/in/edit", "staged\n"); - yield* write("elsewhere/file", "staged outside\n"); - yield* git(["add", "."]); - if (indexState === "flags") - yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); - if (indexState === "manual-skip") - yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); - yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); - yield* write("scope/in/edit", "working\n"); - yield* write("scope/out/present", "modified skipped\n"); - yield* write("scope/out/new file", "new outside cone\n"); - yield* write("elsewhere/file", "working outside\n"); - yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); - if (indexState.endsWith("missing")) yield* fs.remove(indexPath); - const originalIndex = yield* fs - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - for (const turn of [1, 2]) { - const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); - if (turn === 2) { - yield* write("scope/in/edit", "second\n"); - yield* fs.remove(path.join(cwd, "scope/out/new file")); - yield* write("scope/out/second", "second addition\n"); - } - const capture = driver.checkpoints.captureCheckpoint({ - cwd: captureCwd, - checkpointRef: ref, - }); - if (indexState === "non-cone-missing") { - assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); - assert.isFalse( - yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), - ); - assert.isFalse(yield* fs.exists(indexPath)); - break; - } - yield* capture; - for (const [name, content] of [ - ["scope/out/deep/absent", "original\n"], - ["scope/out/present", "modified skipped\n"], - ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], - ["elsewhere/file", nested ? "original\n" : "working outside\n"], - [ - turn === 1 ? "scope/out/new file" : "scope/out/second", - turn === 1 ? "new outside cone\n" : "second addition\n", - ], - ]) { - assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); - } - const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); - assert.notInclude(files, "scope/in/delete"); - if (turn === 2) assert.notInclude(files, "scope/out/new file"); - assert.deepEqual( - yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), - originalIndex, - ); - assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "elsewhere/file")), - "working outside\n", - ); - } - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); - } -} +it.effect.each( + [false, true].flatMap((nested) => + (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( + (indexState) => ({ nested, indexState }), + ), + ), +)( + "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", + ({ nested, indexState }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = Effect.fn(function* (name: string, contents: string) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), contents); + }); + for (const name of [ + "scope/in/edit", + "scope/in/delete", + "scope/out/deep/absent", + "scope/out/present", + "elsewhere/file", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "sparse fixture"]); + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); + if (indexState === "non-cone-missing") + yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); + yield* write("scope/in/edit", "staged\n"); + yield* write("elsewhere/file", "staged outside\n"); + yield* git(["add", "."]); + if (indexState === "flags") + yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); + if (indexState === "manual-skip") + yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); + yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); + yield* write("scope/in/edit", "working\n"); + yield* write("scope/out/present", "modified skipped\n"); + yield* write("scope/out/new file", "new outside cone\n"); + yield* write("elsewhere/file", "working outside\n"); + yield* fs.remove(path.join(cwd, "scope/in/delete")); + const indexPath = path.join(cwd, ".git/index"); + if (indexState.endsWith("missing")) yield* fs.remove(indexPath); + const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + for (const turn of [1, 2]) { + const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); + if (turn === 2) { + yield* write("scope/in/edit", "second\n"); + yield* fs.remove(path.join(cwd, "scope/out/new file")); + yield* write("scope/out/second", "second addition\n"); + } + const capture = driver.checkpoints.captureCheckpoint({ + cwd: captureCwd, + checkpointRef: ref, + }); + if (indexState === "non-cone-missing") { + assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); + assert.isFalse( + yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), + ); + assert.isFalse(yield* fs.exists(indexPath)); + break; + } + yield* capture; + for (const [name, content] of [ + ["scope/out/deep/absent", "original\n"], + ["scope/out/present", "modified skipped\n"], + ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], + ["elsewhere/file", nested ? "original\n" : "working outside\n"], + [ + turn === 1 ? "scope/out/new file" : "scope/out/second", + turn === 1 ? "new outside cone\n" : "second addition\n", + ], + ]) { + assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); + } + const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); + assert.notInclude(files, "scope/in/delete"); + if (turn === 2) assert.notInclude(files, "scope/out/new file"); + assert.deepEqual( + yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), + originalIndex, + ); + assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "elsewhere/file")), + "working outside\n", + ); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () => Effect.gen(function* () { @@ -699,206 +686,186 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse" }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -for (const indexMode of ["normal", "flags", "sparse"] as const) { - it.effect( - `checkpoint index inspection handles entries beyond the output cap (index=${indexMode})`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); - yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); - yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); - yield* fs.makeDirectory(path.join(cwd, "excluded")); - yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); - yield* fs.writeFileString( - path.join(cwd, ".git/filter.cjs"), - 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', - ); - yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); - yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); - yield* git(["add", "."]); - yield* git(["commit", "-m", "inspection fixture"]); - if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); - if (indexMode === "sparse") - yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); - yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); - yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - liveProcess.run( - input.args.includes("ls-files") - ? { - ...input, - maxOutputBytes: 8, - onStdoutChunk: (chunk) => { - for (let i = 0; i < chunk.length; i++) - input.onStdoutChunk?.(chunk.subarray(i, i + 1)); - }, - } - : input, - ), - }), - ); - yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual( - (yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, - "modified\n", - ); - if (indexMode !== "flags") - assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} +it.effect.each(["normal", "flags", "sparse"] as const)( + "checkpoint index inspection handles entries beyond the output cap (index=%s)", + (indexMode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); + yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); + yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); + yield* fs.makeDirectory(path.join(cwd, "excluded")); + yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); + yield* fs.writeFileString( + path.join(cwd, ".git/filter.cjs"), + 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); + yield* git(["add", "."]); + yield* git(["commit", "-m", "inspection fixture"]); + if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); + if (indexMode === "sparse") + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); + yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); + yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + liveProcess.run( + input.args.includes("ls-files") + ? { + ...input, + maxOutputBytes: 8, + onStdoutChunk: (chunk) => { + for (let i = 0; i < chunk.length; i++) + input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + }, + } + : input, + ), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n"); + if (indexMode !== "flags") + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); -for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) { - it.effect( - `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`, - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - const filePath = path.join(cwd, "file.txt"); - const indexPath = path.join(cwd, ".git", "index"); - yield* git(["config", "core.trustctime", "false"]); - yield* fileSystem.writeFileString(filePath, "before\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* git(["add", "file.txt"]); - yield* git(["commit", "-m", "record racy file"]); - yield* fileSystem.utimes(indexPath, timestamp, timestamp); - const originalIndex = yield* fileSystem.readFile(indexPath); - const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; - yield* fileSystem.writeFileString(filePath, "after!\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); +it.effect.each([1_700_000_000, 1_700_000_000.9999])( + "checkpoint capture preserves same-size edits with racy index timestamps (%s)", + (timestamp) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const filePath = path.join(cwd, "file.txt"); + const indexPath = path.join(cwd, ".git", "index"); + yield* git(["config", "core.trustctime", "false"]); + yield* fileSystem.writeFileString(filePath, "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(["add", "file.txt"]); + yield* git(["commit", "-m", "record racy file"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* fileSystem.writeFileString(filePath, "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); - assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); - assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} -for (const nested of [false, true]) { - for (const indexState of [ - "sparse", - "flags", - "manual-skip", - "missing", - "non-cone-missing", - ] as const) { - it.effect( - `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = Effect.fn(function* (name: string, contents: string) { - yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); - yield* fs.writeFileString(path.join(cwd, name), contents); - }); - for (const name of [ - "scope/in/edit", - "scope/in/delete", - "scope/out/deep/absent", - "scope/out/present", - "elsewhere/file", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "sparse fixture"]); - yield* git([ - "sparse-checkout", - "set", - "--cone", - "--sparse-index", - "scope/in", - "elsewhere", - ]); - if (indexState === "non-cone-missing") - yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); - yield* write("scope/in/edit", "staged\n"); - yield* write("elsewhere/file", "staged outside\n"); - yield* git(["add", "."]); - if (indexState === "flags") - yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); - if (indexState === "manual-skip") - yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); - yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); - yield* write("scope/in/edit", "working\n"); - yield* write("scope/out/present", "modified skipped\n"); - yield* write("scope/out/new file", "new outside cone\n"); - yield* write("elsewhere/file", "working outside\n"); - yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); - if (indexState.endsWith("missing")) yield* fs.remove(indexPath); - const originalIndex = yield* fs - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - for (const turn of [1, 2]) { - const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); - if (turn === 2) { - yield* write("scope/in/edit", "second\n"); - yield* fs.remove(path.join(cwd, "scope/out/new file")); - yield* write("scope/out/second", "second addition\n"); - } - const capture = driver.checkpoints.captureCheckpoint({ - cwd: captureCwd, - checkpointRef: ref, - }); - if (indexState === "non-cone-missing") { - assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); - assert.isFalse( - yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), - ); - assert.isFalse(yield* fs.exists(indexPath)); - break; - } - yield* capture; - for (const [name, content] of [ - ["scope/out/deep/absent", "original\n"], - ["scope/out/present", "modified skipped\n"], - ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], - ["elsewhere/file", nested ? "original\n" : "working outside\n"], - [ - turn === 1 ? "scope/out/new file" : "scope/out/second", - turn === 1 ? "new outside cone\n" : "second addition\n", - ], - ]) { - assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); - } - const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); - assert.notInclude(files, "scope/in/delete"); - if (turn === 2) assert.notInclude(files, "scope/out/new file"); - assert.deepEqual( - yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), - originalIndex, - ); - assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "elsewhere/file")), - "working outside\n", - ); - } - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); - } -} + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); +it.effect.each( + [false, true].flatMap((nested) => + (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( + (indexState) => ({ nested, indexState }), + ), + ), +)( + "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", + ({ nested, indexState }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = Effect.fn(function* (name: string, contents: string) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), contents); + }); + for (const name of [ + "scope/in/edit", + "scope/in/delete", + "scope/out/deep/absent", + "scope/out/present", + "elsewhere/file", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "sparse fixture"]); + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); + if (indexState === "non-cone-missing") + yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); + yield* write("scope/in/edit", "staged\n"); + yield* write("elsewhere/file", "staged outside\n"); + yield* git(["add", "."]); + if (indexState === "flags") + yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); + if (indexState === "manual-skip") + yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); + yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); + yield* write("scope/in/edit", "working\n"); + yield* write("scope/out/present", "modified skipped\n"); + yield* write("scope/out/new file", "new outside cone\n"); + yield* write("elsewhere/file", "working outside\n"); + yield* fs.remove(path.join(cwd, "scope/in/delete")); + const indexPath = path.join(cwd, ".git/index"); + if (indexState.endsWith("missing")) yield* fs.remove(indexPath); + const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + for (const turn of [1, 2]) { + const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); + if (turn === 2) { + yield* write("scope/in/edit", "second\n"); + yield* fs.remove(path.join(cwd, "scope/out/new file")); + yield* write("scope/out/second", "second addition\n"); + } + const capture = driver.checkpoints.captureCheckpoint({ + cwd: captureCwd, + checkpointRef: ref, + }); + if (indexState === "non-cone-missing") { + assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); + assert.isFalse( + yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), + ); + assert.isFalse(yield* fs.exists(indexPath)); + break; + } + yield* capture; + for (const [name, content] of [ + ["scope/out/deep/absent", "original\n"], + ["scope/out/present", "modified skipped\n"], + ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], + ["elsewhere/file", nested ? "original\n" : "working outside\n"], + [ + turn === 1 ? "scope/out/new file" : "scope/out/second", + turn === 1 ? "new outside cone\n" : "second addition\n", + ], + ]) { + assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); + } + const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); + assert.notInclude(files, "scope/in/delete"); + if (turn === 2) assert.notInclude(files, "scope/out/new file"); + assert.deepEqual( + yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), + originalIndex, + ); + assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "elsewhere/file")), + "working outside\n", + ); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); it.effect("checkpoint capture preserves racy edits made after resetting the index", () => Effect.gen(function* () { @@ -943,89 +910,87 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -for (const nested of [false, true]) { - for (const indexMode of ["normal", "flags", "split"] as const) { - it.effect( - `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = (name: string, contents: string) => - fileSystem.writeFileString(path.join(cwd, name), contents); - yield* fileSystem.makeDirectory(path.join(cwd, "scope")); - for (const name of [ - "scope/staged", - "scope/deleted", - "scope/assumed", - "scope/skipped", - "outside", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "initial scoped files"]); - yield* write("scope/staged", "staged\n"); - yield* write("scope/new-deleted", "staged then deleted\n"); - yield* write("outside", "staged outside\n"); - yield* git(["add", "."]); - if (indexMode === "flags") { - yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); - yield* git(["update-index", "--skip-worktree", "scope/skipped"]); - } - if (indexMode === "split") { - yield* git(["update-index", "--split-index"]); - } - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); - for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { - yield* write(name, "working\n"); - } - yield* write("scope/new", "first\n"); - yield* fileSystem.remove(path.join(cwd, "scope/deleted")); - yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); - const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); - yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); - for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { - assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); - } - assert.strictEqual( - (yield* git(["show", `${first}:outside`])).stdout, - nested ? "original\n" : "working\n", - ); - const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); - assert.notInclude(files, "scope/deleted"); - assert.notInclude(files, "scope/new-deleted"); - assert.include(files, "scope/new"); +it.effect.each( + [false, true].flatMap((nested) => + (["normal", "flags", "split"] as const).map((indexMode) => ({ nested, indexMode })), + ), +)( + "checkpoint index reuse preserves two turns (nested=$nested, index=$indexMode)", + ({ nested, indexMode }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string, contents: string) => + fileSystem.writeFileString(path.join(cwd, name), contents); + yield* fileSystem.makeDirectory(path.join(cwd, "scope")); + for (const name of [ + "scope/staged", + "scope/deleted", + "scope/assumed", + "scope/skipped", + "outside", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial scoped files"]); + yield* write("scope/staged", "staged\n"); + yield* write("scope/new-deleted", "staged then deleted\n"); + yield* write("outside", "staged outside\n"); + yield* git(["add", "."]); + if (indexMode === "flags") { + yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); + yield* git(["update-index", "--skip-worktree", "scope/skipped"]); + } + if (indexMode === "split") { + yield* git(["update-index", "--split-index"]); + } + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { + yield* write(name, "working\n"); + } + yield* write("scope/new", "first\n"); + yield* fileSystem.remove(path.join(cwd, "scope/deleted")); + yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); + const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { + assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); + } + assert.strictEqual( + (yield* git(["show", `${first}:outside`])).stdout, + nested ? "original\n" : "working\n", + ); + const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); + assert.notInclude(files, "scope/deleted"); + assert.notInclude(files, "scope/new-deleted"); + assert.include(files, "scope/new"); - yield* write("scope/staged", "second\n"); - yield* fileSystem.remove(path.join(cwd, "scope/new")); - yield* write("scope/second", "added in second turn\n"); - yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); - assert.strictEqual( - (yield* git(["diff", "--name-only", first, second])).stdout, - "scope/new\nscope/second\nscope/staged\n", - ); - assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); - assert.strictEqual( - (yield* git(["show", `${second}:scope/second`])).stdout, - "added in second turn\n", - ); - assert.deepEqual( - yield* fileSystem.readFile(path.join(cwd, ".git", "index")), - originalIndex, - ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); - } -} + yield* write("scope/staged", "second\n"); + yield* fileSystem.remove(path.join(cwd, "scope/new")); + yield* write("scope/second", "added in second turn\n"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); + assert.strictEqual( + (yield* git(["diff", "--name-only", first, second])).stdout, + "scope/new\nscope/second\nscope/staged\n", + ); + assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); + assert.strictEqual( + (yield* git(["show", `${second}:scope/second`])).stdout, + "added in second turn\n", + ); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); -for (const indexState of ["missing", "invalid"] as const) { - it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => +it.effect.each(["missing", "invalid"] as const)( + "checkpoint capture falls back when the user index is %s", + (indexState) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1048,8 +1013,7 @@ for (const indexState of ["missing", "invalid"] as const) { assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); } }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} +); it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 53a18551aaf0..32dc3441dd6a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -254,50 +254,48 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -for (const location of ["root", "nested", "worktree"] as const) { - it.effect( - `skips clean filters while the ${location} index is locked and resumes after unlock`, - () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const repository = yield* makeTmpDir(); - yield* initRepoWithCommit(repository); - const cwd = location === "worktree" ? yield* makeTmpDir() : repository; - if (location === "worktree") { - yield* git(repository, ["worktree", "add", "--detach", cwd]); - } - yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); - yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); - yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); - yield* writeTextFile(cwd, "asset.bin", "original\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "filtered asset"]); - NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); - const runsPath = path.join(cwd, ".filter-runs"); - yield* fs.remove(runsPath, { force: true }); - const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); - const lockPath = `${path.resolve(cwd, indexPath)}.lock`; - yield* fs.writeFileString(lockPath, ""); - const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; - yield* fs.makeDirectory(statusCwd, { recursive: true }); - - for (let poll = 0; poll < 3; poll++) { - const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); - } - assert.isFalse(yield* fs.exists(runsPath)); - assert.isTrue(yield* fs.exists(lockPath)); - - yield* fs.remove(lockPath); - const status = yield* driver.statusDetailsLocal(statusCwd); - assert.isFalse(status.hasWorkingTreeChanges); - assert.include(yield* fs.readFileString(runsPath), "clean"); - }).pipe(Effect.provide(TestLayer)), - ); -} +it.effect.each(["root", "nested", "worktree"] as const)( + "skips clean filters while the %s index is locked and resumes after unlock", + (location) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repository = yield* makeTmpDir(); + yield* initRepoWithCommit(repository); + const cwd = location === "worktree" ? yield* makeTmpDir() : repository; + if (location === "worktree") { + yield* git(repository, ["worktree", "add", "--detach", cwd]); + } + yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); + yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); + yield* writeTextFile(cwd, "asset.bin", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); + const runsPath = path.join(cwd, ".filter-runs"); + yield* fs.remove(runsPath, { force: true }); + const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); + const lockPath = `${path.resolve(cwd, indexPath)}.lock`; + yield* fs.writeFileString(lockPath, ""); + const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; + yield* fs.makeDirectory(statusCwd, { recursive: true }); + + for (let poll = 0; poll < 3; poll++) { + const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); + } + assert.isFalse(yield* fs.exists(runsPath)); + assert.isTrue(yield* fs.exists(lockPath)); + + yield* fs.remove(lockPath); + const status = yield* driver.statusDetailsLocal(statusCwd); + assert.isFalse(status.hasWorkingTreeChanges); + assert.include(yield* fs.readFileString(runsPath), "clean"); + }).pipe(Effect.provide(TestLayer)), +); it.effect("uses stable diagnostics for every parsed non-repository command", () => { const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; @@ -822,7 +820,7 @@ it.effect("backs off and logs failed fetch attempts across linked worktrees", () ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -for (const scenario of [ +it.effect.each([ { name: "HTTPS credentials", stderr: "fatal: Authentication failed for", @@ -885,53 +883,51 @@ for (const scenario of [ stderr: "fatal: unexpected remote failure", expected: "git fetch origin failed", }, -] as const) { - it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () => - Effect.gen(function* () { - const secret = "secret-fetch-token"; - const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; - const attempts = yield* Ref.make(0); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) - return yield* Effect.die("expected Git command"); - if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); - assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); - assert.equal(command.options.env?.LC_ALL, "C"); - assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); - yield* Ref.update(attempts, (count) => count + 1); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText(Stream.make(secret)), - stderr: Stream.encodeText(Stream.make(stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const cwd = yield* makeTmpDir(); - const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); - assert.include(error.detail, scenario.expected); - assert.equal(error.exitCode, 128); - assert.equal(error.stderrLength, stderr.length); - assert.equal(error.stdoutLength, secret.length); - assert.notInclude(error.message, secret); - assert.notInclude(yield* encodeGitCommandError(error), secret); - assert.notProperty(error, "stderr"); - assert.notProperty(error, "args"); - assert.equal(yield* Ref.get(attempts), 1); - }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), - ); -} +] as const)("reports $name during fetch without retaining remote output", (scenario) => + Effect.gen(function* () { + const secret = "secret-fetch-token"; + const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; + const attempts = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("expected Git command"); + if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); + assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); + assert.equal(command.options.env?.LC_ALL, "C"); + assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); + yield* Ref.update(attempts, (count) => count + 1); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(secret)), + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); + assert.include(error.detail, scenario.expected); + assert.equal(error.exitCode, 128); + assert.equal(error.stderrLength, stderr.length); + assert.equal(error.stdoutLength, secret.length); + assert.notInclude(error.message, secret); + assert.notInclude(yield* encodeGitCommandError(error), secret); + assert.notProperty(error, "stderr"); + assert.notProperty(error, "args"); + assert.equal(yield* Ref.get(attempts), 1); + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { @@ -1449,8 +1445,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - for (const splitIndex of [false, true]) { - it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + it.effect.each([false, true])( + "keeps the preceding second cached in review previews (split: %s)", + (splitIndex) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -1484,53 +1481,50 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); }), - ); - } + ); - for (const [timestamp, splitIndex] of [ + it.effect.each([ [1_700_000_000, false], [1_700_000_000.9999, false], [1_700_000_000, true], [1_700_000_000.9999, true], - ] as const) { - it.effect( - `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, - () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const filePath = path.join(cwd, "tracked.txt"); - const indexPath = path.join(cwd, ".git", "index"); - // Reproduce a same-timestamp edit without relying on filesystem clock resolution. - yield* git(cwd, ["config", "core.trustctime", "false"]); - yield* writeTextFile(cwd, "tracked.txt", "before\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* git(cwd, ["add", "tracked.txt"]); - yield* git(cwd, ["commit", "-m", "record racy file"]); - if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); - yield* fileSystem.utimes(indexPath, timestamp, timestamp); - const originalIndex = yield* fileSystem.readFile(indexPath); - const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; - yield* writeTextFile(cwd, "tracked.txt", "after!\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* writeTextFile(cwd, "untracked.txt", "new\n"); - - const preview = yield* driver.getReviewDiffPreview({ cwd }); - const dirty = preview.sources.find((source) => source.kind === "working-tree")!; - assert.deepStrictEqual(dirty.files, [ - { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, - { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, - ]); - assert.include(dirty.diff, "-before"); - assert.include(dirty.diff, "+after!"); - assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); - assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); - }), - ); - } + ] as const)( + "preserves same-size edits with a racy review index (%s, split: %s)", + ([timestamp, splitIndex]) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(cwd, "tracked.txt"); + const indexPath = path.join(cwd, ".git", "index"); + // Reproduce a same-timestamp edit without relying on filesystem clock resolution. + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "record racy file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "-before"); + assert.include(dirty.diff, "+after!"); + assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }), + ); it.effect("keeps complete stats for files beyond the combined patch limit", () => Effect.gen(function* () { @@ -2940,8 +2934,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { - for (const failure of ["offline", "auth", "timeout"] as const) { - it.effect(`does not retry a scoped fetch after ${failure}`, () => + it.effect.each(["offline", "auth", "timeout"] as const)( + "does not retry a scoped fetch after %s", + (failure) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -2996,8 +2991,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); } }), - ); - } + ); it.effect("creates a worktree from the latest fetched remote commit", () => Effect.gen(function* () { diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index 579b546fb651..50e9e469c17b 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -42,24 +42,25 @@ describe("queued message file edits", () => { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); }); - for (const images of [[], [image]]) { - it(`preserves a generic file in a ${images.length === 0 ? "file-only" : "mixed"} save`, async () => { - const attachments = await prepareQueuedEditAttachments({ - existingAttachments: [], - images, - files: [file], - uploadFiles: async () => [uploadedFile], - readImage: async () => "data:image/png;base64,aW1hZ2U=", - }); - expect(attachments.at(-1)).toEqual(uploadedFile); - expect(attachments.length).toBe(images.length + 1); - if (images.length > 0) - expect(attachments[0]).toMatchObject({ - type: "image", - dataUrl: "data:image/png;base64,aW1hZ2U=", - }); + it.each([ + { images: [], label: "file-only" }, + { images: [image], label: "mixed" }, + ])("preserves a generic file in a $label save", async ({ images }) => { + const attachments = await prepareQueuedEditAttachments({ + existingAttachments: [], + images, + files: [file], + uploadFiles: async () => [uploadedFile], + readImage: async () => "data:image/png;base64,aW1hZ2U=", }); - } + expect(attachments.at(-1)).toEqual(uploadedFile); + expect(attachments.length).toBe(images.length + 1); + if (images.length > 0) + expect(attachments[0]).toMatchObject({ + type: "image", + dataUrl: "data:image/png;base64,aW1hZ2U=", + }); + }); it("retains saved attachments alongside newly uploaded files", async () => { const saved = { ...uploadedFile, id: "saved:earlier" }; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 6b347f4e263d..18604e44e19d 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1279,8 +1279,9 @@ describe("composer and pull request shortcuts", () => { ["Enter", "thread.steerQueuedMessage"], ] as const; - for (const platform of ["MacIntel", "Win32", "Linux"]) { - it(`separates queued steering and background start on ${platform}`, () => { + it.each(["MacIntel", "Win32", "Linux"])( + "separates queued steering and background start on %s", + (platform) => { const modifier = { metaKey: platform === "MacIntel", ctrlKey: platform !== "MacIntel", @@ -1301,8 +1302,8 @@ describe("composer and pull request shortcuts", () => { }), "composer.sendBackground", ); - }); - } + }, + ); for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each(shortcuts)( @@ -1331,8 +1332,9 @@ describe("composer and pull request shortcuts", () => { ); } - for (const platform of ["MacIntel", "Win32", "Linux"]) { - it(`edits the last queued message with Alt+ArrowUp from the composer on ${platform}`, () => { + it.each(["MacIntel", "Win32", "Linux"])( + "edits the last queued message with Alt+ArrowUp from the composer on %s", + (platform) => { const input = event({ key: "ArrowUp", altKey: true }); assert.strictEqual( resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { @@ -1347,8 +1349,8 @@ describe("composer and pull request shortcuts", () => { context: { composerFocus: false }, }), ); - }); - } + }, + ); for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each([ diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 6c54600e0543..9031c7df18a2 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -366,79 +366,79 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(layer)); }); - for (const requestKind of ["live-activity", "push-notification"] as const) { - for (const stage of ["send", "read-response"] as const) { - it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const signals: AbortSignal[] = []; - const stalledHttpClient = HttpClient.make((request, _url, signal) => { - signals.push(signal); - const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); - if (stage === "send") return stall; - const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); - Object.defineProperty(response, "text", { value: stall }); - return Effect.succeed(response); - }); - const layer = ApnsClient.layer.pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), - Layer.provide( - Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { - getJwt: () => Effect.succeed("test-jwt"), - }), - ), - ); - const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); - const credentials = { - teamId: "team-timeout", - keyId: "key-timeout", - privateKey: Redacted.make("unused-test-key"), - bundleId: "com.t3tools.test", - environment: "sandbox", - } satisfies ApnsCredentials; - const send = - requestKind === "live-activity" - ? apns.sendLiveActivityRequest({ - credentials, - issuedAtUnixSeconds: 123, - request: apns.makeLiveActivityRequest({ - event: "update", - token: "long-push-token", - state, - nowEpochSeconds: 123, - nowIso: DateTime.formatIso(now), - }), - }) - : apns.sendPushNotificationRequest({ - credentials, - issuedAtUnixSeconds: 123, - request: apns.makePushNotificationRequest({ - token: "long-push-token", - notification: { - title: "Thread", - body: "Done", - environmentId: "env", - threadId: "thread", - deepLink: "/", - }, - }), - }); - const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); - yield* Deferred.await(started); - yield* TestClock.adjust("10 seconds"); - expect(signals[0]?.aborted).toBe(true); - const error = yield* Fiber.join(fiber); - expect(error).toMatchObject({ - _tag: "ApnsHttpRequestError", - requestKind, - event: requestKind === "live-activity" ? "update" : null, - stage, - status: stage === "read-response" ? 200 : null, - tokenSuffix: "sh-token", - cause: { _tag: "TimeoutError" }, - }); - }), + it.effect.each( + (["live-activity", "push-notification"] as const).flatMap((requestKind) => + (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })), + ), + )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const signals: AbortSignal[] = []; + const stalledHttpClient = HttpClient.make((request, _url, signal) => { + signals.push(signal); + const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + if (stage === "send") return stall; + const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + Object.defineProperty(response, "text", { value: stall }); + return Effect.succeed(response); + }); + const layer = ApnsClient.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), + Layer.provide( + Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { + getJwt: () => Effect.succeed("test-jwt"), + }), + ), ); - } - } + const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); + const credentials = { + teamId: "team-timeout", + keyId: "key-timeout", + privateKey: Redacted.make("unused-test-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + } satisfies ApnsCredentials; + const send = + requestKind === "live-activity" + ? apns.sendLiveActivityRequest({ + credentials, + issuedAtUnixSeconds: 123, + request: apns.makeLiveActivityRequest({ + event: "update", + token: "long-push-token", + state, + nowEpochSeconds: 123, + nowIso: DateTime.formatIso(now), + }), + }) + : apns.sendPushNotificationRequest({ + credentials, + issuedAtUnixSeconds: 123, + request: apns.makePushNotificationRequest({ + token: "long-push-token", + notification: { + title: "Thread", + body: "Done", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + }), + }); + const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("10 seconds"); + expect(signals[0]?.aborted).toBe(true); + const error = yield* Fiber.join(fiber); + expect(error).toMatchObject({ + _tag: "ApnsHttpRequestError", + requestKind, + event: requestKind === "live-activity" ? "update" : null, + stage, + status: stage === "read-response" ? 200 : null, + tokenSuffix: "sh-token", + cause: { _tag: "TimeoutError" }, + }); + }), + ); }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index e98c2b639598..934ebfdb4423 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1909,8 +1909,9 @@ describe("live activity alert decisions", () => { }); describe("queued iOS alert policy", () => { - for (const scenario of ["enabled", "muted", "late"] as const) { - it.effect(`checks the current policy for a ${scenario} completion`, () => { + it.effect.each(["enabled", "muted", "late"] as const)( + "checks the current policy for a %s completion", + (scenario) => { let sent = 0; const completed = { ...state, phase: "completed" as const }; const prefs = JSON.parse(enabledPreferences); @@ -1957,8 +1958,8 @@ describe("queued iOS alert policy", () => { }), ), ); - }); - } + }, + ); }); describe("fast completion delivery", () => { @@ -2019,74 +2020,74 @@ describe("fast completion delivery", () => { }); describe("signed APNs registration metadata", () => { - for (const kind of ["live_activity_update", "push_notification"] as const) { - for (const changed of ["bundle", "environment", "legacy"] as const) { - it.effect(`routes ${kind} using current registration with ${changed} job metadata`, () => { - const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; - const requests: HttpClientRequest.HttpClientRequest[] = []; - const payload = makeApnsDeliveryJobPayload({ - kind, - userId: target.user_id, - deviceId: target.device_id, - token: "unchanged-token", - ...(changed === "legacy" - ? {} - : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), - aggregate: kind === "live_activity_update" ? aggregate : null, - ...(kind === "push_notification" - ? { - notification: { - title: "Thread", - body: "Input: Project", - environmentId: "env", - threadId: "thread", - deepLink: "/", - }, - } - : {}), - createdAt: "1970-01-01T00:00:00.000Z", - expiresAt: "1970-01-01T00:10:00.000Z", - jobId: `metadata-${kind}-${changed}`, - }); - const signed = signApnsDeliveryJob({ - secret: config.apnsDeliveryJobSigningSecret, - payload, - }); - return Effect.gen(function* () { - const deliveries = yield* ApnsDeliveries.ApnsDeliveries; - const result = yield* deliveries.processSignedJob(signed); - expect(result.ok).toBe(true); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe( - `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, - ); - expect(requests[0]?.headers["apns-topic"]).toBe( - `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, - ); - }).pipe( - Effect.provide( - makeLayer({ - attempts, - config: signingConfig, - currentTargets: [ - { - ...target, - push_token: "unchanged-token", - activity_push_token: "unchanged-token", - bundle_id: - changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", - aps_environment: changed === "environment" ? "production" : "sandbox", - }, - ], - execute: (request) => - Effect.sync(() => { - requests.push(request); - return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); - }), + it.effect.each( + (["live_activity_update", "push_notification"] as const).flatMap((kind) => + (["bundle", "environment", "legacy"] as const).map((changed) => ({ kind, changed })), + ), + )("routes $kind using current registration with $changed job metadata", ({ kind, changed }) => { + const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; + const requests: HttpClientRequest.HttpClientRequest[] = []; + const payload = makeApnsDeliveryJobPayload({ + kind, + userId: target.user_id, + deviceId: target.device_id, + token: "unchanged-token", + ...(changed === "legacy" + ? {} + : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), + aggregate: kind === "live_activity_update" ? aggregate : null, + ...(kind === "push_notification" + ? { + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + } + : {}), + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: `metadata-${kind}-${changed}`, + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + expect(result.ok).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, + ); + expect(requests[0]?.headers["apns-topic"]).toBe( + `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, + ); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + config: signingConfig, + currentTargets: [ + { + ...target, + push_token: "unchanged-token", + activity_push_token: "unchanged-token", + bundle_id: + changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", + aps_environment: changed === "environment" ? "production" : "sandbox", + }, + ], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); }), - ), - ); - }); - } - } + }), + ), + ); + }); }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index a32819f39b5e..b42a4348a2c0 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -215,36 +215,39 @@ describe("Android delivery routing", () => { deepLink: "/threads/env/second-thread", }; - for (const [firstPhase, secondPhase, title, active] of [ - ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], - ["completed", "failed", "2 agents finished", "false"], - ] as const) { - it.effect( - `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, - () => { - const h = harness(); - h.current.otherStates = [secondState]; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process(h.job); - h.current.state = { ...state, phase: firstPhase }; - h.current.otherStates = [{ ...secondState, phase: secondPhase }]; - yield* delivery.process({ ...h.job, state: h.current.state }); - yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); - yield* delivery.process({ ...h.job, state: h.current.state }); - const alerts = h.sent.filter((message) => message.alert); - expect(alerts).toHaveLength(1); - expect(alerts[0]?.data).toMatchObject({ - alert_title: title, - alert_body: "Fix notifications, Second thread", - alert_path: "/", - active, - }); - expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }, - ); - } + it.effect.each([ + { + firstPhase: "waiting_for_approval", + secondPhase: "waiting_for_input", + title: "2 agents need attention", + active: "true", + }, + { firstPhase: "completed", secondPhase: "failed", title: "2 agents finished", active: "false" }, + ] as const)( + "routes grouped $firstPhase and $secondPhase to the overview once across their queued jobs", + ({ firstPhase, secondPhase, title, active }) => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: "/", + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }, + ); it.effect("filters disabled event types before counting a group", () => { const h = harness(); @@ -294,13 +297,9 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); - for (const phase of [ - "completed", - "waiting_for_approval", - "waiting_for_input", - "failed", - ] as const) { - it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => { + it.effect.each(["completed", "waiting_for_approval", "waiting_for_input", "failed"] as const)( + "deleting one thread preserves another thread's %s alert", + (phase) => { const h = harness(); h.current.otherStates = [secondState]; return Effect.gen(function* () { @@ -316,8 +315,8 @@ describe("Android delivery routing", () => { yield* delivery.process({ ...h.job, state: h.current.state }); expect(h.sent.filter((message) => message.alert)).toHaveLength(1); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); it.effect("registration replay establishes a baseline without alerting", () => { const h = harness(); @@ -333,8 +332,9 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); - for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) { - it.effect(`excludes ${restriction} when forming cross-environment groups`, () => { + it.effect.each(["mutedEnvironments", "revokedEnvironments"] as const)( + "excludes %s when forming cross-environment groups", + (restriction) => { const h = harness(); const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); @@ -349,8 +349,8 @@ describe("Android delivery routing", () => { alert_body: "Approval: Project", }); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); it("gives a group a stable retry identity independent of row order", () => { const other = { @@ -409,13 +409,14 @@ describe("Android delivery routing", () => { ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" }); }); - for (const [phase, body, preference] of [ - ["waiting_for_approval", "Approval: Project", "notifyOnApproval"], - ["waiting_for_input", "Input: Project", "notifyOnInput"], - ["completed", "Done: Project", "notifyOnCompletion"], - ["failed", "Failed: Project", "notifyOnFailure"], - ] as const) { - it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => { + it.effect.each([ + { phase: "waiting_for_approval", body: "Approval: Project", preference: "notifyOnApproval" }, + { phase: "waiting_for_input", body: "Input: Project", preference: "notifyOnInput" }, + { phase: "completed", body: "Done: Project", preference: "notifyOnCompletion" }, + { phase: "failed", body: "Failed: Project", preference: "notifyOnFailure" }, + ] as const)( + "uses iOS alert wording for $phase and honors its preference", + ({ phase, body, preference }) => { const h = harness(); h.current.state = { ...state, phase }; return Effect.gen(function* () { @@ -433,8 +434,8 @@ describe("Android delivery routing", () => { yield* delivery.process({ ...h.job, state: h.current.state }); expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); it("trims and truncates alert text like iOS", () => { expect( @@ -572,23 +573,23 @@ describe("Android delivery routing", () => { expect(h.sent[1]?.data.alert_id).toBeUndefined(); }).pipe(Effect.provide(h.layer)); }); - for (const ongoing of [true, false]) { - for (const phase of ["completed", "failed"] as const) { - it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => { - const h = harness(); - h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; - h.current.target.preferences_json = encodeJson({ - ...preferences, - liveActivitiesEnabled: ongoing, - }); - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process({ ...h.job, state: h.current.state }); - expect(h.sent.every((message) => !message.alert)).toBe(true); - }).pipe(Effect.provide(h.layer)); - }); - } - } + it.effect.each( + [true, false].flatMap((ongoing) => + (["completed", "failed"] as const).map((phase) => ({ ongoing, phase })), + ), + )("does not alert a stale $phase without a baseline (ongoing=$ongoing)", ({ ongoing, phase }) => { + const h = harness(); + h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: ongoing, + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); it.effect( "retains finished results without extending expiry on replay and clears expired cards", @@ -821,8 +822,9 @@ it("stops reducing five-character row fields and fits the remaining alert", () = }); describe("FCM queue message isolation", () => { - for (const failure of ["invalid-job", "fcm-rejection"] as const) { - it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => { + it.effect.each(["invalid-job", "fcm-rejection"] as const)( + "retries only the %s message and delivers the rest of its batch", + (failure) => { const h = harness(); const outcomes = new Map(); const message = (id: string, body: unknown): Cloudflare.Queues.Message => ({ @@ -863,6 +865,6 @@ describe("FCM queue message isolation", () => { expect(h.sent).toHaveLength(1); expect(h.marked).toHaveLength(1); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); }); diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 075d16d5b390..f4669f588f67 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import noTestInLoop from "./rules/no-test-in-loop.ts"; export default definePlugin({ meta: { @@ -20,5 +21,6 @@ export default definePlugin({ "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, + "no-test-in-loop": noTestInLoop, }, }); diff --git a/oxlint-plugin-t3code/rules/no-test-in-loop.test.ts b/oxlint-plugin-t3code/rules/no-test-in-loop.test.ts new file mode 100644 index 000000000000..39efb80f8951 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-test-in-loop.test.ts @@ -0,0 +1,114 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-test-in-loop", { + filename: "fixture.test.ts", +}); + +describe("t3code/no-test-in-loop", () => { + rule.valid( + "allows it.each and it.effect.each", + ` + import { it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; + + it.each([1, 2])("handles %s", (value) => {}); + it.effect.each([1, 2])("handles %s", (value) => Effect.succeed(value)); + `, + ); + + rule.valid( + "allows loops inside a test body", + ` + import { it } from "@effect/vitest"; + + it("checks every value", () => { + for (const value of [1, 2]) { + if (value < 0) throw new Error("negative"); + } + }); + `, + ); + + rule.valid( + "ignores node:test files, which have no .each", + ` + import { test } from "node:test"; + + for (const value of [1, 2]) { + test(\`handles \${value}\`, () => {}); + } + `, + ); + + rule.invalid( + "reports it inside a for...of loop", + ` + import { it } from "@effect/vitest"; + + for (const value of [1, 2]) { + it(\`handles \${value}\`, () => {}); + } + `, + (output) => { + assert.match(output, /Use it\.each\(cases\)/); + }, + ); + + rule.invalid( + "reports it.effect inside a for loop nested in describe", + ` + import { describe, it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; + + describe("cases", () => { + for (let index = 0; index < 2; index++) { + it.effect(\`handles \${index}\`, () => Effect.void); + } + }); + `, + (output) => { + assert.match(output, /Use it\.effect\.each\(cases\)/); + }, + ); + + rule.invalid( + "reports test inside a for...in loop", + ` + import { test } from "vitest"; + + for (const key in { a: 1 }) { + test(key, () => {}); + } + `, + ); + + rule.invalid( + "reports a describe inside a loop once, not the tests inside it", + ` + import { describe, it } from "vitest"; + + for (const bundle of ["a.js", "b.js"]) { + describe(bundle, () => { + it("first", () => {}); + it("second", () => {}); + }); + } + `, + (output) => { + assert.match(output, /Use describe\.each\(cases\)/); + assert.equal(output.match(/no-test-in-loop/g)?.length, 1); + }, + ); +}); + +const productionRule = createOxlintRuleHarness("t3code/no-test-in-loop"); + +productionRule.valid( + "ignores non-test files", + ` + const it = (_name: string) => {}; + for (const value of ["a"]) it(value); + `, +); diff --git a/oxlint-plugin-t3code/rules/no-test-in-loop.ts b/oxlint-plugin-t3code/rules/no-test-in-loop.ts new file mode 100644 index 000000000000..b963fdee62df --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-test-in-loop.ts @@ -0,0 +1,79 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, unwrapExpression } from "../utils.ts"; + +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; +// node:test has no `.each`, so files written against it keep their loops. +const NODE_TEST_IMPORT_PATTERN = /["']node:test["']/u; +// Calls that declare a test or a group of tests; all of them expose `.each`. +const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]); +// Modifiers whose tester also exposes `.each` (@effect/vitest's `it.effect` and `it.live`). +const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]); +const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]); +const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]); + +/** Returns the reported name for `it(…)`, `describe(…)`, `it.effect(…)`, and friends. */ +const testCallName = (callee: unknown): Option.Option => { + const expression = unwrapExpression(callee); + if (Option.isNone(expression)) return Option.none(); + + if (expression.value.type === "Identifier") { + return TEST_FUNCTIONS.has(expression.value.name) + ? Option.some(expression.value.name) + : Option.none(); + } + + if (expression.value.type !== "MemberExpression") return Option.none(); + const object = unwrapExpression(expression.value.object); + const property = getPropertyName(expression.value.property); + if (Option.isNone(object) || Option.isNone(property)) return Option.none(); + if (!EACH_CAPABLE_MODIFIERS.has(property.value)) return Option.none(); + if (object.value.type !== "Identifier" || !TEST_FUNCTIONS.has(object.value.name)) { + return Option.none(); + } + return Option.some(`${object.value.name}.${property.value}`); +}; + +/** + * Reports a test declaration that runs once per loop iteration. A loop around a + * `describe` is reported on the `describe`, so the tests inside it stay quiet. + */ +const isInsideLoop = (node: ESTree.Node): boolean => { + let current = node.parent; + while (current) { + if (LOOPS.has(current.type)) return true; + // Anything inside a function (a describe body, a helper, a test body) runs + // once per call of that function, not once per iteration. + if (FUNCTIONS.has(current.type)) return false; + current = current.parent; + } + return false; +}; + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow declaring tests inside a for loop; use it.each / it.effect.each / describe.each instead.", + }, + }, + create(context) { + if (!TEST_FILE_PATTERN.test(context.filename)) return {}; + if (NODE_TEST_IMPORT_PATTERN.test(context.sourceCode.text)) return {}; + + return { + CallExpression(node) { + const name = testCallName(node.callee); + if (Option.isNone(name)) return; + if (!isInsideLoop(node)) return; + + context.report({ + node: node.callee, + message: `Do not call ${name.value}(…) inside a for loop. Use ${name.value}.each(cases)(name, …) instead.`, + }); + }, + }; + }, +}); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 0a5b72b30231..7f42a631cab4 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -444,8 +444,9 @@ describe("ConnectionResolver", () => { }), ); - for (const scenario of ["unchanged", "changed", "revocation-failed"] as const) { - it.effect(`handles ${scenario} SSH routing consent before saving or authorizing`, () => + it.effect.each(["unchanged", "changed", "revocation-failed"] as const)( + "handles %s SSH routing consent before saving or authorizing", + (scenario) => Effect.gen(function* () { const calls: string[] = []; const target = new SshConnectionTarget({ @@ -537,8 +538,7 @@ describe("ConnectionResolver", () => { } expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write"); }), - ); - } + ); it.effect("preserves relay authorization failure classification and trace details", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a7d7d960efea..a9833f7499d7 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -502,83 +502,81 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); - for (const status of [ + it.effect.each([ "waiting", "completed", "failed", "interrupted", "cancelled", "rolled_back", - ] as const) { - it.effect(`dispatches Stop for ${status} runs with background commands except rollback`, () => - Effect.gen(function* () { - const waitingRunId = RunId.make("run-waiting"); - const projection: OrchestrationV2ThreadProjection = { - ...v2Projection, - runs: [ - { - id: waitingRunId, - threadId: v2ThreadId, - ordinal: 1, - providerInstanceId: v2Projection.thread.providerInstanceId, - modelSelection: v2Projection.thread.modelSelection, - providerThreadId: null, - userMessageId: MessageId.make("message-waiting"), - rootNodeId: null, - activeAttemptId: null, - status, - requestedAt: v2Now, - startedAt: v2Now, - completedAt: null, - checkpointId: null, - contextHandoffId: null, - }, - ], - turnItems: [ - { - id: TurnItemId.make("background-command"), - threadId: v2ThreadId, - runId: waitingRunId, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "running", - title: null, - startedAt: v2Now, - completedAt: null, - updatedAt: v2Now, - type: "command_execution", - input: "vp run dev", - }, - ], - }; - const commands: OrchestrationV2Command[] = []; - const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); + ] as const)("dispatches Stop for %s runs with background commands except rollback", (status) => + Effect.gen(function* () { + const waitingRunId = RunId.make("run-waiting"); + const projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + runs: [ + { + id: waitingRunId, + threadId: v2ThreadId, + ordinal: 1, + providerInstanceId: v2Projection.thread.providerInstanceId, + modelSelection: v2Projection.thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message-waiting"), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: v2Now, + startedAt: v2Now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + turnItems: [ + { + id: TurnItemId.make("background-command"), + threadId: v2ThreadId, + runId: waitingRunId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: v2Now, + completedAt: null, + updatedAt: v2Now, + type: "command_execution", + input: "vp run dev", + }, + ], + }; + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); - const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - ); + const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); - expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 }); - expect(commands).toEqual( - status === "rolled_back" - ? [] - : [ - { - type: "run.interrupt", - commandId: expect.any(String), - threadId: v2ThreadId, - runId: waitingRunId, - holdQueue: true, - }, - ], - ); - }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), - ); - } + expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 }); + expect(commands).toEqual( + status === "rolled_back" + ? [] + : [ + { + type: "run.interrupt", + commandId: expect.any(String), + threadId: v2ThreadId, + runId: waitingRunId, + holdQueue: true, + }, + ], + ); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); it.effect( "dispatches V2-native relationship and queue commands without compatibility shaping", diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index d2baa5927475..246e4f585181 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -429,59 +429,55 @@ describe("RpcSessionFactory", () => { ), ); - for (const options of [ + it.effect.each([ { environmentThemes: true }, { usageLimitSources: true }, { environmentThemes: true, usageLimitSources: true }, - ]) { - it.effect( - `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`, - () => - Effect.scoped( - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(options); - const session = yield* factory.connect(PREPARED); - const readyFiber = yield* Effect.forkChild(session.ready); - const socket = yield* awaitSocket(sockets); - socket.open(); - yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); - yield* Fiber.join(readyFiber); - - const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); - expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); - expect( - socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), - ).toHaveLength(1); - - const fallbackFiber = yield* session - .subscribeServerConfig({}) - .pipe(Stream.runHead, Effect.forkChild); - const fallbackRequest = yield* awaitRequest(socket, 1); - expect(fallbackRequest).toMatchObject({ - tag: WS_METHODS.subscribeServerConfig, - payload: {}, - }); - socket.serverMessage( - encodeJson({ - _tag: "Chunk", - requestId: fallbackRequest.id, - values: [ - { - version: 1, - type: "snapshot", - config: ENCODED_THEME_SERVER_CONFIG, - }, - ], - }), - ); - expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ - _tag: "Some", - value: { type: "snapshot" }, - }); + ])("shares only a config subscription with the same opt-ins: %j", (options) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(options); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); + yield* Fiber.join(readyFiber); + + const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], }), - ), - ); - } + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); it.effect.each([ { usageLimitSources: true }, @@ -1191,37 +1187,38 @@ describe("RpcSessionFactory", () => { ), ); - for (const relay of [false, true]) { - it.effect(`fails readiness when the ${relay ? "relay" : "direct"} websocket never opens`, () => - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); + it.effect.each([ + { relay: false, label: "direct" }, + { relay: true, label: "relay" }, + ])("fails readiness when the $label websocket never opens", ({ relay }) => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); - const error = yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* factory.connect({ - ...PREPARED, - target: relay - ? new RelayConnectionTarget({ - environmentId: TARGET.environmentId, - label: TARGET.label, - }) - : TARGET, - }); - const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); - yield* awaitSocket(sockets); - - yield* TestClock.adjust("15 seconds"); - return yield* Fiber.join(readyFiber); - }), - ); + const error = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect({ + ...PREPARED, + target: relay + ? new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + }) + : TARGET, + }); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + yield* awaitSocket(sockets); - expect(error).toBeInstanceOf(ConnectionTransientError); - expect(error).toMatchObject({ - reason: "transport", - message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`, - }); - expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); - }).pipe(Effect.provide(TestClock.layer())), - ); - } + yield* TestClock.adjust("15 seconds"); + return yield* Fiber.join(readyFiber); + }), + ); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`, + }); + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 95c7622c8c1c..d1407fe79598 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -60,7 +60,7 @@ describe("asset collection keys", () => { }); describe("createAssetEnvironmentAtoms", () => { - for (const scenario of [ + it.effect.each([ { name: "missing video", path: "/tmp/clip.mp4", fallback: true }, { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true }, { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true }, @@ -73,113 +73,111 @@ describe("createAssetEnvironmentAtoms", () => { { name: "same environment", path: "/tmp/clip.mp4", primary: "same" }, { name: "non-media", path: "/tmp/report.html" }, { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" }, - ]) { - it.effect(`uses the correct environment for ${scenario.name}`, () => - Effect.gen(function* () { - const remoteId = EnvironmentId.make("remote"); - const localId = EnvironmentId.make("local"); - const resource = { - _tag: "media-file" as const, - threadId: ThreadId.make("foreign-thread"), - path: scenario.path, - }; - const error = - scenario.error === "auth" - ? new EnvironmentAuthorizationError({ - message: "denied", - requiredScope: "orchestration:read", - }) - : scenario.error === "inspection" - ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) - : scenario.error === "context" - ? new AssetWorkspaceContextNotFoundError({ resource }) - : new AssetWorkspaceAssetNotFoundError({ resource }); - const calls: EnvironmentId[] = []; - const supervisors = new Map< - EnvironmentId, - EnvironmentSupervisor.EnvironmentSupervisor["Service"] - >(); - for (const environmentId of [remoteId, localId]) { - const client = { - [WS_METHODS.assetsCreateUrl]: () => { - calls.push(environmentId); - return environmentId === remoteId && !scenario.success - ? Effect.fail(error) - : Effect.succeed({ - relativeUrl: `/api/assets/${environmentId}/media`, - expiresAt: 999999, - }); - }, - } as unknown as WsRpcProtocolClient; - const session = { client } as RpcSession; - supervisors.set( - environmentId, - EnvironmentSupervisor.EnvironmentSupervisor.of({ - target: new PrimaryConnectionTarget({ - environmentId, - label: environmentId, - httpBaseUrl: `https://${environmentId}.test`, - wsBaseUrl: `wss://${environmentId}.test`, - }), - state: yield* SubscriptionRef.make({ - ...AVAILABLE_CONNECTION_STATE, - phase: "connected" as const, - }), - session: yield* SubscriptionRef.make(Option.some(session)), - prepared: yield* SubscriptionRef.make(Option.none()), - connect: Effect.void, - disconnect: Effect.void, - retryNow: Effect.void, + ])("uses the correct environment for $name", (scenario) => + Effect.gen(function* () { + const remoteId = EnvironmentId.make("remote"); + const localId = EnvironmentId.make("local"); + const resource = { + _tag: "media-file" as const, + threadId: ThreadId.make("foreign-thread"), + path: scenario.path, + }; + const error = + scenario.error === "auth" + ? new EnvironmentAuthorizationError({ + message: "denied", + requiredScope: "orchestration:read", + }) + : scenario.error === "inspection" + ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) + : scenario.error === "context" + ? new AssetWorkspaceContextNotFoundError({ resource }) + : new AssetWorkspaceAssetNotFoundError({ resource }); + const calls: EnvironmentId[] = []; + const supervisors = new Map< + EnvironmentId, + EnvironmentSupervisor.EnvironmentSupervisor["Service"] + >(); + for (const environmentId of [remoteId, localId]) { + const client = { + [WS_METHODS.assetsCreateUrl]: () => { + calls.push(environmentId); + return environmentId === remoteId && !scenario.success + ? Effect.fail(error) + : Effect.succeed({ + relativeUrl: `/api/assets/${environmentId}/media`, + expiresAt: 999999, + }); + }, + } as unknown as WsRpcProtocolClient; + const session = { client } as RpcSession; + supervisors.set( + environmentId, + EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: environmentId, + httpBaseUrl: `https://${environmentId}.test`, + wsBaseUrl: `wss://${environmentId}.test`, + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, }), - ); - } - const environments = EnvironmentRegistry.EnvironmentRegistry.of({ - run: (id, effect) => - Effect.provideService( - effect, - EnvironmentSupervisor.EnvironmentSupervisor, - supervisors.get(id)!, - ), - followStream: (id, stream) => - Stream.provideService( - stream, - EnvironmentSupervisor.EnvironmentSupervisor, - supervisors.get(id)!, - ), - } as EnvironmentRegistry.EnvironmentRegistry["Service"]); - const registry = AtomRegistry.make(); - yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); - const localTarget = { - environmentId: scenario.primary === "same" ? remoteId : localId, - httpBaseUrl: "https://local.test", - }; - const localEnvironment = Atom.make( - scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }), ); - const assets = createAssetEnvironmentAtoms( - Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)), - localEnvironment, + } + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (id, effect) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisors.get(id)!, + ), + followStream: (id, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisors.get(id)!, + ), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const localTarget = { + environmentId: scenario.primary === "same" ? remoteId : localId, + httpBaseUrl: "https://local.test", + }; + const localEnvironment = Atom.make( + scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, + ); + const assets = createAssetEnvironmentAtoms( + Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)), + localEnvironment, + ); + const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); + const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); + if (scenario.fallback || scenario.success) { + expect((yield* result).relativeUrl).toBe( + scenario.fallback + ? "https://local.test/api/assets/local/media" + : "/api/assets/remote/media", ); - const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); - const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); - if (scenario.fallback || scenario.success) { - expect((yield* result).relativeUrl).toBe( - scenario.fallback - ? "https://local.test/api/assets/local/media" - : "/api/assets/remote/media", - ); - } else { - expect(yield* Effect.flip(result)).toEqual(error); - } - expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); - if (scenario.primary === "reconnecting") { - registry.set(localEnvironment, localTarget); - expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); - expect(calls).toEqual([remoteId, remoteId, localId]); - } - }).pipe(Effect.scoped), - ); - } + } else { + expect(yield* Effect.flip(result)).toEqual(error); + } + expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); + if (scenario.primary === "reconnecting") { + registry.set(localEnvironment, localTarget); + expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); + expect(calls).toEqual([remoteId, remoteId, localId]); + } + }).pipe(Effect.scoped), + ); it("keys asset URL queries by environment and resource", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 86e4ad51196f..c33623bbe1bf 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -236,8 +236,9 @@ for (const scenario of [ ); } -for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) { - it.effect(`routes ${provider} viewed marks to their storage environment`, () => +it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( + "routes %s viewed marks to their storage environment", + (provider) => Effect.scoped( Effect.gen(function* () { const reference = { @@ -311,8 +312,7 @@ for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as cons } }), ), - ); -} +); const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -420,8 +420,9 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( return { runtime, atoms, registry, environmentRegistry, supervisor }; }); -for (const permission of ["default", "origin-off", "destination-off", "read-only"] as const) { - it.effect(`does not probe another environment with ${permission} routing permission`, () => +it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( + "does not probe another environment with %s routing permission", + (permission) => Effect.scoped( Effect.gen(function* () { const calls: string[] = []; @@ -480,125 +481,128 @@ for (const permission of ["default", "origin-off", "destination-off", "read-only ); }), ), - ); -} +); -for (const side of ["origin", "destination"] as const) { - for (const stored of ["matching", "changed", "missing", "unavailable", "failed"] as const) { - it.effect(`checks the current ${side} SSH profile before routing with ${stored} storage`, () => - Effect.scoped( - Effect.gen(function* () { - const calls: string[] = []; - const identity = { - host: "github.com", - provider: "github", - viewer: "maria", - accountId: "123", - }; - const client = { - [WS_METHODS.pullRequestsRouting]: () => - Effect.sync(() => { - calls.push("source-probe"); - return identity; - }), - [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) => - Effect.gen(function* () { - if (input.expectedAccountId !== undefined) { - return yield* new PullRequestOperationError({ - operation: "routeIdentity", - detail: "Source guard refused.", - }); - } - calls.push("source-write"); - }), - [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, - } as unknown as WsRpcProtocolClient; - const alternate = { - [WS_METHODS.pullRequestsRoutingIdentity]: () => - Effect.sync(() => { - calls.push("alternate-probe"); - return identity; - }), - [WS_METHODS.pullRequestsRunAction]: () => - Effect.sync(() => { - calls.push("alternate-write"); - }), - [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, - } as unknown as WsRpcProtocolClient; - const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate); - const environmentId = - side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment"); - const profile = new SshConnectionProfile({ - connectionId: "ssh-1", +it.effect.each( + (["origin", "destination"] as const).flatMap((side) => + (["matching", "changed", "missing", "unavailable", "failed"] as const).map((stored) => ({ + side, + stored, + })), + ), +)("checks the current $side SSH profile before routing with $stored storage", ({ side, stored }) => + Effect.scoped( + Effect.gen(function* () { + const calls: string[] = []; + const identity = { + host: "github.com", + provider: "github", + viewer: "maria", + accountId: "123", + }; + const client = { + [WS_METHODS.pullRequestsRouting]: () => + Effect.sync(() => { + calls.push("source-probe"); + return identity; + }), + [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) => + Effect.gen(function* () { + if (input.expectedAccountId !== undefined) { + return yield* new PullRequestOperationError({ + operation: "routeIdentity", + detail: "Source guard refused.", + }); + } + calls.push("source-write"); + }), + [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, + } as unknown as WsRpcProtocolClient; + const alternate = { + [WS_METHODS.pullRequestsRoutingIdentity]: () => + Effect.sync(() => { + calls.push("alternate-probe"); + return identity; + }), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.sync(() => { + calls.push("alternate-write"); + }), + [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, + } as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate); + const environmentId = + side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment"); + const profile = new SshConnectionProfile({ + connectionId: "ssh-1", + environmentId, + label: "SSH", + target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 }, + }); + yield* SubscriptionRef.update(environmentRegistry.entries, (entries) => + new Map(entries).set(environmentId, { + target: new SshConnectionTarget({ environmentId, + connectionId: profile.connectionId, label: "SSH", - target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 }, - }); - yield* SubscriptionRef.update(environmentRegistry.entries, (entries) => - new Map(entries).set(environmentId, { - target: new SshConnectionTarget({ - environmentId, - connectionId: profile.connectionId, - label: "SSH", + }), + profile: Option.some(profile), + enabled: true, + }), + ); + const read = Effect.suspend(() => + stored === "failed" + ? Effect.fail( + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: "Profile storage unavailable.", }), - profile: Option.some(profile), - enabled: true, + ) + : Effect.succeed( + stored === "missing" + ? Option.none() + : Option.some( + stored === "changed" + ? new SshConnectionProfile({ + ...profile, + target: { ...profile.target, hostname: "replacement.example.test" }, + }) + : profile, + ), + ), + ); + const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { + projectId: ProjectId.make("project-1"), + repository: "private/repo", + number: 7, + action: "merge", + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + // This also represents a user enabling the stale catalog entry after re-resolution. + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + ); + yield* stored === "unavailable" + ? route + : route.pipe( + Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, { + get: () => read, + put: () => Effect.die("unused"), + remove: () => Effect.die("unused"), }), ); - const read = Effect.suspend(() => - stored === "failed" - ? Effect.fail( - new ConnectionTransientError({ - reason: "remote-unavailable", - detail: "Profile storage unavailable.", - }), - ) - : Effect.succeed( - stored === "missing" - ? Option.none() - : Option.some( - stored === "changed" - ? new SshConnectionProfile({ - ...profile, - target: { ...profile.target, hostname: "replacement.example.test" }, - }) - : profile, - ), - ), - ); - const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { - projectId: ProjectId.make("project-1"), - repository: "private/repo", - number: 7, - action: "merge", - }).pipe( - Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - // This also represents a user enabling the stale catalog entry after re-resolution. - Effect.provideService(GitHubRoutingPermissions, trustedRouting), - ); - yield* stored === "unavailable" - ? route - : route.pipe( - Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, { - get: () => read, - put: () => Effect.die("unused"), - remove: () => Effect.die("unused"), - }), - ); - expect(calls).toEqual( - stored === "matching" - ? ["source-probe", "alternate-probe", "alternate-write"] - : ["source-write"], - ); - }), - ), - ); - } -} + expect(calls).toEqual( + stored === "matching" + ? ["source-probe", "alternate-probe", "alternate-write"] + : ["source-write"], + ); + }), + ), +); -for (const probe of ["origin", "alternate"] as const) { - it.live(`bounds a stalled ${probe} metadata probe without repeating a strict source read`, () => +it.live.each(["origin", "alternate"] as const)( + "bounds a stalled %s metadata probe without repeating a strict source read", + (probe) => Effect.scoped( Effect.gen(function* () { let sourceReads = 0; @@ -638,81 +642,81 @@ for (const probe of ["origin", "alternate"] as const) { expect(sourceReads).toBe(1); }), ), - ); -} +); -for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) { - it.effect( - source === "offline" - ? "returns held source data only after both fresh paths fail" - : `uses one shared reader with a ${source} source`, - () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make(); - const calls: string[] = []; - const clientFor = (local: boolean) => - ({ - [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: - () => - Effect.succeed({ - host: "github.com", - provider: "github", - viewer: "maria-rcks", - accountId: "123", - }), - [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => - Effect.gen(function* () { - calls.push(local ? "local" : input.allowStale === false ? "origin" : "held"); - if (source === "offline" && !local && input.allowStale === undefined) { - return { state: "open" }; - } - expect(input.allowStale).toBe(false); - if (local && source !== "offline") return null; - if (source !== "pending" && source !== "pending-local") - return yield* new PullRequestOperationError({ - operation: "summary", - detail: "github unreachable", - }); - yield* Deferred.succeed(started, undefined); - return yield* Effect.never; - }), - }) as unknown as WsRpcProtocolClient; - const { environmentRegistry, supervisor } = yield* makeTestRuntime( - clientFor(false), - clientFor(true), - source === "failed-local" || source === "pending-local", - ); - const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { - projectId: ProjectId.make("project-1"), - repository: "acme/web", - number: 7, - }).pipe( - Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - ); - const fiber = yield* request.pipe(Effect.forkChild); - if (source === "pending-local") { - yield* Deferred.await(started); - yield* TestClock.adjust("30 seconds"); - } - const result = yield* Fiber.join(fiber); - if (source === "offline") { - expect(result).toEqual({ state: "open" }); - expect(calls).toEqual(["local", "origin", "held"]); - } else { - expect(result).toBeNull(); - expect(calls).toEqual( - source === "failed-local" || source === "pending-local" - ? ["origin", "local"] - : ["local"], - ); - } - }), - ), - ); -} +it.effect.each( + (["pending", "pending-local", "failed-local", "failed", "offline"] as const).map( + (source) => + [ + source === "offline" + ? "returns held source data only after both fresh paths fail" + : `uses one shared reader with a ${source} source`, + source, + ] as const, + ), +)("%s", ([, source]) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const calls: string[] = []; + const clientFor = (local: boolean) => + ({ + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => + Effect.succeed({ + host: "github.com", + provider: "github", + viewer: "maria-rcks", + accountId: "123", + }), + [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => + Effect.gen(function* () { + calls.push(local ? "local" : input.allowStale === false ? "origin" : "held"); + if (source === "offline" && !local && input.allowStale === undefined) { + return { state: "open" }; + } + expect(input.allowStale).toBe(false); + if (local && source !== "offline") return null; + if (source !== "pending" && source !== "pending-local") + return yield* new PullRequestOperationError({ + operation: "summary", + detail: "github unreachable", + }); + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }), + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor(false), + clientFor(true), + source === "failed-local" || source === "pending-local", + ); + const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const fiber = yield* request.pipe(Effect.forkChild); + if (source === "pending-local") { + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + } + const result = yield* Fiber.join(fiber); + if (source === "offline") { + expect(result).toEqual({ state: "open" }); + expect(calls).toEqual(["local", "origin", "held"]); + } else { + expect(result).toBeNull(); + expect(calls).toEqual( + source === "failed-local" || source === "pending-local" ? ["origin", "local"] : ["local"], + ); + } + }), + ), +); it.live("keeps source workspace metadata when an alternate answers a detail read", () => Effect.scoped( @@ -1191,8 +1195,9 @@ it.effect("refreshes checks without refreshing full detail", () => ), ); -for (const oldAlternate of [false, true]) { - it.effect(`routes checks through one reader with old alternate: ${oldAlternate}`, () => +it.effect.each([false, true])( + "routes checks through one reader with old alternate: %s", + (oldAlternate) => Effect.scoped( Effect.gen(function* () { const calls: string[] = []; @@ -1236,8 +1241,7 @@ for (const oldAlternate of [false, true]) { expect(calls).toEqual(oldAlternate ? ["origin"] : ["local"]); }), ), - ); -} +); it.effect("keeps live detail reads separate from reads that allow stale data", () => Effect.scoped( diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 14ff5efbeabe..c0c45a1e2e3c 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -137,8 +137,9 @@ describe("remote thread lifecycle commands", () => { ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }], ] as const; - for (const [action, input, expected] of actions) { - it.effect(`shows ${action} before a delayed remote reply and rolls back a rejection`, () => + it.effect.each(actions)( + "shows %s before a delayed remote reply and rolls back a rejection", + ([action, input, expected]) => Effect.gen(function* () { const h = yield* makeHarness(); const source = h.snapshotAtom(ENVIRONMENT_ID); @@ -179,8 +180,7 @@ describe("remote thread lifecycle commands", () => { expect((yield* Effect.promise(() => result))._tag).toBe("Failure"); expect(h.registry.get(h.visibleAtom)).toBe(initial); }), - ); - } + ); it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () => Effect.gen(function* () { @@ -290,44 +290,45 @@ describe("remote thread lifecycle commands", () => { }), ); - for (const action of ["settle", "snooze"] as const) { - it.effect(`restores a confirmed ${action} when a queued undo fails`, () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const parked = - action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE }; - const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; - const result = h.commands[action].run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, - }); - const first = yield* Queue.take(h.requests); - const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); - yield* Deferred.succeed(first.reply, { sequence: 2 }); - expect((yield* Effect.promise(() => result))._tag).toBe("Success"); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); - const confirmed = { - ...SNAPSHOT, - snapshotSequence: 2, - threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], - }; - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); - const second = yield* Queue.take(h.requests); - expect(second.command.type).toBe( - action === "settle" ? "thread.unsettle" : "thread.unsnooze", - ); - yield* Deferred.fail(second.reply, new Error("Undo rejected")); - expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); - expect(h.registry.get(h.visibleAtom)).toBe(confirmed); - }), - ); + const undoableActions = ["settle", "snooze"] as const; - it.effect(`preserves a newer approval when the ${action} reply arrives after the shell`, () => + it.effect.each(undoableActions)("restores a confirmed %s when a queued undo fails", (action) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const parked = + action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE }; + const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; + const result = h.commands[action].run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, + }); + const first = yield* Queue.take(h.requests); + const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); + yield* Deferred.succeed(first.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); + const confirmed = { + ...SNAPSHOT, + snapshotSequence: 2, + threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); + const second = yield* Queue.take(h.requests); + expect(second.command.type).toBe(action === "settle" ? "thread.unsettle" : "thread.unsnooze"); + yield* Deferred.fail(second.reply, new Error("Undo rejected")); + expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); + expect(h.registry.get(h.visibleAtom)).toBe(confirmed); + }), + ); + + it.effect.each(undoableActions)( + "preserves a newer approval when the %s reply arrives after the shell", + (action) => Effect.gen(function* () { const h = yield* makeHarness(); const result = h.commands[action].run(h.registry, { @@ -346,9 +347,11 @@ describe("remote thread lifecycle commands", () => { expect((yield* Effect.promise(() => result))._tag).toBe("Success"); expect(h.registry.get(h.visibleAtom)).toBe(newer); }), - ); + ); - it.effect(`shows an accepted ${action} while the shell still has an old input request`, () => + it.effect.each(undoableActions)( + "shows an accepted %s while the shell still has an old input request", + (action) => Effect.gen(function* () { const h = yield* makeHarness(); const stale = { @@ -370,6 +373,5 @@ describe("remote thread lifecycle commands", () => { expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull(); expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale); }), - ); - } + ); }); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 0c1619a52b19..61bd092a4b2e 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -315,8 +315,9 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { }; describe("EnvironmentThreads", () => { - for (const source of ["disk", "HTTP"] as const) { - it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () => + it.effect.each(["disk", "HTTP"] as const)( + "does not rewrite an unchanged %s snapshot on navigation or warm return", + (source) => Effect.gen(function* () { const resumeCache: NonNullable[1]> = { snapshot: undefined, @@ -350,8 +351,7 @@ describe("EnvironmentThreads", () => { ); expect(yield* Ref.get(nextSaved)).toEqual([]); }), - ); - } + ); it.effect("persists a complete bounded HTTP window only once", () => Effect.gen(function* () { @@ -1012,8 +1012,9 @@ describe("EnvironmentThreads", () => { }), ); - for (const cacheKind of ["disk", "retained"] as const) { - it.effect(`retains paging support through a complete bounded ${cacheKind} cache`, () => + it.effect.each(["disk", "retained"] as const)( + "retains paging support through a complete bounded %s cache", + (cacheKind) => Effect.gen(function* () { const resumeCache: NonNullable[1]> = { snapshot: undefined, @@ -1071,11 +1072,11 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(warm.lastSubscribeAfterSequence)).toBe(5); expect(yield* Ref.get(warm.lastAcceptBoundedSnapshot)).toBe(true); }), - ); - } + ); - for (const historyPaging of ["no-http", "no-controller"] as const) { - it.effect(`does not negotiate bounded fallbacks with ${historyPaging}`, () => + it.effect.each(["no-http", "no-controller"] as const)( + "does not negotiate bounded fallbacks with %s", + (historyPaging) => Effect.gen(function* () { for (const source of ["cache", "http"] as const) { const history = { @@ -1105,8 +1106,7 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined(); } }), - ); - } + ); it.effect("socket snapshot clears progressive history meta left from a bounded window", () => Effect.gen(function* () { diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts index 1645eae48259..ef96d1d147b8 100644 --- a/packages/contracts/src/providerInstance.test.ts +++ b/packages/contracts/src/providerInstance.test.ts @@ -21,39 +21,37 @@ describe("provider slug validation (shared by driver + instance ids)", () => { { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind }, ] as const; - for (const { schemaName, decode } of cases) { - describe(schemaName, () => { - it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( - "accepts %s", - (id) => { - expect(decode(id)).toBe(id); - }, - ); - - it.each([ - ["empty string", ""], - ["leading digit", "1codex"], - ["leading dash", "-codex"], - ["leading underscore", "_codex"], - ["whitespace inside", "codex personal"], - ["dot inside", "codex.personal"], - ["slash inside", "codex/personal"], - ])("rejects %s", (_label, value) => { - expect(() => decode(value)).toThrow(); - }); - - it("trims surrounding whitespace before validating", () => { - expect(decode(" codex_work ")).toBe("codex_work"); - }); - - it("rejects ids longer than 64 characters", () => { - const tooLong = "a".repeat(65); - expect(() => decode(tooLong)).toThrow(); - const justRight = "a".repeat(64); - expect(decode(justRight)).toBe(justRight); - }); + describe.each(cases)("$schemaName", ({ decode }) => { + it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( + "accepts %s", + (id) => { + expect(decode(id)).toBe(id); + }, + ); + + it.each([ + ["empty string", ""], + ["leading digit", "1codex"], + ["leading dash", "-codex"], + ["leading underscore", "_codex"], + ["whitespace inside", "codex personal"], + ["dot inside", "codex.personal"], + ["slash inside", "codex/personal"], + ])("rejects %s", (_label, value) => { + expect(() => decode(value)).toThrow(); + }); + + it("trims surrounding whitespace before validating", () => { + expect(decode(" codex_work ")).toBe("codex_work"); + }); + + it("rejects ids longer than 64 characters", () => { + const tooLong = "a".repeat(65); + expect(() => decode(tooLong)).toThrow(); + const justRight = "a".repeat(64); + expect(decode(justRight)).toBe(justRight); }); - } + }); }); describe("ProviderInstanceRef", () => { diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index d42fc08c3f48..051f03627f03 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -1088,8 +1088,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); - for (const operation of ["request", "notification"] as const) { - it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () => + it.effect.each(["request", "notification"] as const)( + "rejects a %s if the connection ends while its logger is running", + (operation) => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const writeStarted = yield* Deferred.make(); @@ -1127,6 +1128,5 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.strictEqual(failure, error); assert.equal(yield* Queue.size(output), 0); }), - ); - } + ); }); diff --git a/packages/shared/src/gitPatchPath.test.ts b/packages/shared/src/gitPatchPath.test.ts index 20c5065729f8..c1592a92acf7 100644 --- a/packages/shared/src/gitPatchPath.test.ts +++ b/packages/shared/src/gitPatchPath.test.ts @@ -46,15 +46,13 @@ describe("a name written into a header and read back out", () => { `every\t\n\r"\\${BELL}.txt`, ]; - for (const name of names) { - it(`is the name that went in: ${JSON.stringify(name)}`, () => { - const written = quoteGitPatchPath(name); - expect(unquoteGitPatchPath(written)).toBe(name); - // A parser that takes the quotes off itself, as the clients' one does, gets there too. - const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; - expect(unquoteGitPatchPath(unwrapped)).toBe(name); - }); - } + it.each(names)("is the name that went in: %j", (name) => { + const written = quoteGitPatchPath(name); + expect(unquoteGitPatchPath(written)).toBe(name); + // A parser that takes the quotes off itself, as the clients' one does, gets there too. + const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; + expect(unquoteGitPatchPath(unwrapped)).toBe(name); + }); it("carries the whole name past the first thing a header stops at", () => { const written = quoteGitPatchPath("tab\tfile.txt"); diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index e69017ccfbcd..38417a2e4f08 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1269,8 +1269,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ).pipe(Effect.provideService(HostProcessPlatform, "linux")), ); - for (const targetArch of ["x64", "arm64"] as const) { - it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () => + it.effect.each(["x64", "arm64"] as const)( + "accepts an embedded archive with the Linux %s node-pty prebuild", + (targetArch) => Effect.scoped( Effect.gen(function* () { const fixture = yield* makeWindowsPayloadFixture({ @@ -1290,33 +1291,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(result.packagedAppDir, fixture.packagedAppDir); }), ).pipe(Effect.provideService(HostProcessPlatform, "linux")), - ); + ); - it.effect( - `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`, - () => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeWindowsPayloadFixture({ - copyUnpackedNatives: true, - wslRuntime: "valid", - targetArch, - ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", - }); - const error = yield* validateWindowsPackagedPayload({ - stageDistDir: fixture.stageDistDir, - appExecutableName: fixture.appExecutableName, - targetArch, - appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, - expectWslRuntime: true, - }).pipe(Effect.flip); - - assert.instanceOf(error, WindowsPackagedPayloadValidationError); - assert.equal(error.reason, "wsl-runtime-invalid"); - }), - ), - ); - } + it.effect.each(["x64", "arm64"] as const)( + "rejects a node-pty prebuild for the wrong architecture in a Linux %s archive", + (targetArch) => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + targetArch, + ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch, + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); it.effect("rejects an embedded archive built for a different release version", () => Effect.scoped( diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 5a270386a30d..be58bcecde08 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -414,8 +414,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // Browser dev is single-origin: Vite proxies the backend, and the client // resolves it from window.location.origin. Baking a localhost URL here is // what breaks sharing a dev server to another device. - for (const mode of ["dev", "dev:web"] as const) { - it.effect(`leaves the client backend URLs unset in ${mode} mode`, () => + it.effect.each(["dev", "dev:web"] as const)( + "leaves the client backend URLs unset in %s mode", + (mode) => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ mode, @@ -442,8 +443,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // the intent has to be stated positively. assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); }), - ); - } + ); // Desktop pins the renderer at loopback deliberately; an ambient marker // must not make Vite discard those URLs. @@ -492,27 +492,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An // inherited one would survive into browser dev and point HMR at the wrong // interface — invisible over a shared origin, since the page still loads. - for (const mode of ["dev", "dev:web"] as const) { - it.effect(`drops an inherited HOST in ${mode} mode`, () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode, - baseEnv: { HOST: "0.0.0.0" }, - serverOffset: 0, - webOffset: 0, - t3Home: undefined, - browser: undefined, - autoBootstrapProjectFromCwd: undefined, - logWebSocketEvents: undefined, - host: undefined, - port: undefined, - devUrl: undefined, - }); + it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode, + baseEnv: { HOST: "0.0.0.0" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); - assert.equal(env.HOST, undefined); - }), - ); - } + assert.equal(env.HOST, undefined); + }), + ); // --host configures the *backend* (T3CODE_HOST). It must not become Vite's // bind address by way of an inherited HOST that happens to agree with it. diff --git a/scripts/legend-list-initial-reveal.test.ts b/scripts/legend-list-initial-reveal.test.ts index 15ebc3f597e5..00e3e30594af 100644 --- a/scripts/legend-list-initial-reveal.test.ts +++ b/scripts/legend-list-initial-reveal.test.ts @@ -78,8 +78,9 @@ function createList(bundle: string) { }; } -for (const bundle of ["react-native.js", "react-native.mjs"]) { - describe(`initial inset end reveal (${bundle})`, () => { +describe.each(["react-native.js", "react-native.mjs"])( + "initial inset end reveal (%s)", + (bundle) => { it("waits for the native offset instead of the optimistic scroll target", () => { const list = createList(bundle); list.start(); @@ -210,5 +211,5 @@ for (const bundle of ["react-native.js", "react-native.mjs"]) { empty.complete(); expect(empty.ready()).toBe(true); }); - }); -} + }, +); diff --git a/vite.config.ts b/vite.config.ts index 6845af150941..5961c106d640 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,7 @@ export default defineConfig({ "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", + "t3code/no-test-in-loop": "error", "t3code/namespace-node-imports": "error", }, overrides: [ From 7ff2eabf56d30af15a8ac86b0eb46013a9e910ee Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:01:36 +0530 Subject: [PATCH 02/82] fix(desktop): markdown no longer loads invisible in new dark-mode browser tabs (#14727) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 117 ++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 58 +++++++-- apps/desktop/src/window/DesktopWindow.test.ts | 1 + apps/desktop/src/window/DesktopWindow.ts | 1 + 4 files changed, 168 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 95197d8916a0..9adaeaaa448b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1691,6 +1691,123 @@ describe("PreviewManager", () => { ), ); + const makeAttachingGuest = (id: number) => { + const listeners = new Map void>(); + const attach = vi.fn(); + const detach = vi.fn(); + const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( + async () => undefined, + ); + let destroyed = false; + const wc = { + id, + isDestroyed: () => destroyed, + isDevToolsOpened: () => { + // Electron throws from native methods once a WebContents is destroyed. + if (destroyed) throw new TypeError("Object has been destroyed"); + return false; + }, + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoading: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + once: (event: string, listener: () => void) => { + listeners.set(event, listener); + }, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > detach.mock.calls.length, + attach, + detach, + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }; + return { + wc: wc as unknown as Electron.WebContents, + attach, + detach, + sendCommand, + destroy: () => { + destroyed = true; + listeners.get("destroyed")?.(); + }, + }; + }; + + effectIt.effect("sets the opaque base as soon as a guest attaches", () => + withManager((manager) => + Effect.gen(function* () { + // The tab's first document can paint before the renderer registers the + // guest, so the opaque base has to be the first command on attach. + const claimed = makeAttachingGuest(44); + fromId.mockReturnValue(claimed.wc); + yield* manager.prepareWebview(claimed.wc); + expect(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand.mock.calls[0]).toEqual([ + "Emulation.setDefaultBackgroundColorOverride", + { color: { r: 255, g: 255, b: 255, a: 1 } }, + ]); + + yield* manager.createTab("tab_early"); + yield* manager.registerWebview("tab_early", 44); + yield* manager.setColorScheme("tab_early", "dark"); + expect(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + + // A guest destroyed before any tab claims it releases its session. + const unclaimed = makeAttachingGuest(45); + yield* manager.prepareWebview(unclaimed.wc); + expect(unclaimed.attach).toHaveBeenCalledTimes(1); + unclaimed.destroy(); + yield* Effect.yieldNow; + expect(unclaimed.detach).toHaveBeenCalledTimes(1); + expect(claimed.detach).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("skips a guest destroyed while another guest's session opens", () => + withManager((manager) => + Effect.gen(function* () { + const slow = makeAttachingGuest(46); + let releaseSlow = () => {}; + const slowCommand = new Promise((resolve) => { + releaseSlow = resolve; + }); + slow.sendCommand.mockImplementation(() => slowCommand); + const queued = makeAttachingGuest(47); + + const slowFiber = yield* manager + .prepareWebview(slow.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + const queuedFiber = yield* manager + .prepareWebview(queued.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + queued.destroy(); + releaseSlow(); + + expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); + expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); + expect(slow.attach).toHaveBeenCalledTimes(1); + expect(queued.attach).not.toHaveBeenCalled(); + }), + ), + ); + const makeAudioWebContents = (id: number) => { const listeners = new Map void>(); const setAudioMuted = vi.fn(); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4195bdffff6a..f7b5046a7fed 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function > => { const existing = sessions.get(wc.id); if (existing) return Effect.succeed([existing, sessions] as const); + // A guest can be destroyed while it waits for this lock, and its native + // methods throw once it is. + if (wc.isDestroyed()) { + return Effect.fail( + new PreviewOperationError({ + operation: "ensureControlSession", + webContentsId: wc.id, + cause: new Error("WebContents was destroyed"), + }), + ); + } if (wc.isDevToolsOpened()) { return Effect.fail( new PreviewAutomationDevToolsOpenError({ @@ -1409,21 +1420,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - yield* Effect.forEach( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], - (method) => - attemptPromise( - { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wcDebugger.sendCommand(method), - ), - { concurrency: "unbounded", discard: true }, - ); // Electron gives `` guests a transparent base background, and // Chromium only paints a dark canvas for dark color-scheme pages over an // opaque base. Without this, dark-scheme pages with no background of // their own (text/plain, e.g. .md files) render white text on white. // White matches the webview's existing white backing, so light pages look // the same; Chromium still swaps in its dark canvas for dark-scheme pages. + // Sent first because a document that paints before it arrives keeps the + // transparent base until its next load. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1431,6 +1435,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function color: { r: 255, g: 255, b: 255, a: 1 }, }), ); + yield* Effect.forEach( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wcDebugger.sendCommand(method), + ), + { concurrency: "unbounded", discard: true }, + ); return [ control, replaceMap(sessions, (copy) => { @@ -2415,6 +2428,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + // Called when a guest attaches to the window, before its first document + // paints. A tab opened straight to a URL often paints before the renderer + // gets to registerWebview, which would leave that page on the transparent + // base. registerWebview reuses the session opened here. + const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( + wc: Electron.WebContents, + ) { + const webContentsId = wc.id; + // A guest destroyed before any tab claims it has no other cleanup path. + wc.once("destroyed", () => { + runFork(detachControlSession(webContentsId)); + }); + // Runs detached from the attach event, so nothing may escape. registerWebview + // opens the session again if this one did not. + yield* ensureControlSession(wc).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Preview webview control session was not opened on attach.", { + webContentsId, + cause, + }), + ), + ); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + prepareWebview, reapplyZoom, refresh, registerWebview, @@ -5011,6 +5049,7 @@ export class PreviewManager extends Context.Service< tabId: string, webContentsId: number, ) => Effect.Effect; + readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect; readonly navigate: (tabId: string, url: string) => Effect.Effect; readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; @@ -5137,6 +5176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { createTab: operations.createTab, closeTab: operations.closeTab, registerWebview: operations.registerWebview, + prepareWebview: operations.prepareWebview, navigate: operations.navigate, goBack: operations.goBack, goForward: operations.goForward, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5df400b90c44..1345c37bf687 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -314,6 +314,7 @@ function makeTestLayer(input: { Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, + prepareWebview: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), reapplyZoom: () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 754de3caa727..aa5aa085a3ce 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -600,6 +600,7 @@ export const make = Effect.gen(function* () { installContextMenu(window, window.webContents); window.webContents.on("did-attach-webview", (_event, contents) => { installContextMenu(window, contents); + void runPromise(previewManager.prepareWebview(contents)); }); window.webContents.setWindowOpenHandler(({ url }) => { From 641426885921b8d7caad1dbe35f804e6cec6a70b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 2 Oct 2026 20:13:34 -0400 Subject: [PATCH 03/82] test(server): the fake Claude CLI exits with its parent (#14926) Signed-off-by: Yordis Prieto --- .../src/provider/Layers/ClaudeCapabilitiesProbe.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 232b8cc02d00..bb6e5bd7c7dd 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -129,7 +129,14 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { " });", " }", "});", - "setInterval(() => {}, 1_000);", + "// Stay alive for follow-up control requests, but never outlive the", + "// parent: the probe aborts the SDK without awaiting the child, so an", + "// unconditional interval would strand this process until reboot.", + "const keepAlive = setInterval(() => {}, 1_000);", + 'lines.on("close", () => {', + " clearInterval(keepAlive);", + " process.exit(0);", + "});", "", ].join("\n"), ); From 8ed276c246b624631e7d39241ebfd22d8314cb68 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 17:24:01 -0700 Subject: [PATCH 04/82] fix(mobile): production OTAs no longer reach the previous major's store binaries (#14944) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/mobile-eas-production.yml | 7 +++++-- apps/mobile/fingerprint.config.js | 18 ++++++++++++++++++ knip.jsonc | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/fingerprint.config.js diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 918cbb45acbf..0f7ca4673165 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -18,8 +18,11 @@ name: Mobile EAS Production # Store stays a manual App Store Connect step. # 2. OTA: publish a production-channel update for each platform where at # least one finished production build matches the current native -# fingerprint. Old-version binaries with a matching fingerprint receive -# it too. When native drift means no binary could install the update, +# fingerprint. Older binaries of the same major version with a matching +# fingerprint receive it too; the major version is part of the +# fingerprint (apps/mobile/fingerprint.config.js), so a new major never +# reaches the previous major's store binaries over the air. When native +# drift means no binary could install the update, # it is skipped and flagged in the job summary instead of published # into the void. # workflow_dispatch remains as a manual override for both modes (e.g. to diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js new file mode 100644 index 000000000000..e6d4d2d218d6 --- /dev/null +++ b/apps/mobile/fingerprint.config.js @@ -0,0 +1,18 @@ +// @ts-check +const fs = require("node:fs"); +const path = require("node:path"); + +// Expo's fingerprint ignores the app version, so binaries of different majors +// share a runtime version whenever native code is unchanged, and a production +// OTA from main would reach every older store binary. Hashing the major +// version keeps each major's OTAs on its own binaries: a new major reaches +// users only once its store build is promoted. +const appConfig = fs.readFileSync(path.join(__dirname, "app.config.ts"), "utf8"); +const majorVersion = appConfig.match(/^ {2}version: "(\d+)\./m)?.[1]; +if (!majorVersion) { + throw new Error("fingerprint.config.js could not read the app version from app.config.ts"); +} + +module.exports = { + extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], +}; diff --git a/knip.jsonc b/knip.jsonc index 306c161b954a..8865bf58869c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -66,9 +66,10 @@ "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] }, }, "apps/mobile": { - // Expo loads plugins by string; Metro embeds the browser entry in the native WebView. + // Expo loads plugins and the fingerprint config by string; Metro embeds the browser entry in the native WebView. "entry": [ "index.ts!", + "fingerprint.config.js", "plugins/*.cjs", "scripts/generate-device-stream.mts", "src/features/devices/device-stream.browser.ts!", From bb7997709dd5ed91a3aa18c091286f5f5331b0ef Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 19:44:14 -0700 Subject: [PATCH 05/82] fix(web): thread panel shows the remote machine a thread runs on (#14972) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/chat/ThreadDetailsPanel.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 38df44721300..1d02a0ada889 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -9,7 +9,11 @@ import { AlertTriangleIcon, XIcon } from "lucide-react"; import type { DraftId } from "../../composerDraftStore"; import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts"; -import type { EnvMode, EnvironmentOption } from "../BranchToolbar.logic"; +import { + shouldShowEnvironmentIndicator, + type EnvMode, + type EnvironmentOption, +} from "../BranchToolbar.logic"; import { BranchToolbar } from "../BranchToolbar"; import { BranchToolbarEnvironmentSelector } from "../BranchToolbarEnvironmentSelector"; import GitActionsControl from "../GitActionsControl"; @@ -78,6 +82,14 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.environmentId, props.activeProjectScripts ? props.gitCwd : null, ); + // Same rule as the composer strip: a lone remote machine still gets a row, + // shown as a static label because there is nothing to pick. + const canPickEnvironment = props.availableEnvironments.length > 1; + const showEnvironment = shouldShowEnvironmentIndicator({ + activeEnvironment: + props.availableEnvironments.find((env) => env.environmentId === props.environmentId) ?? null, + canPickEnvironment, + }); const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, @@ -138,7 +150,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null}
- {density === "full" && props.availableEnvironments.length > 1 ? ( + {density === "full" && showEnvironment ? ( ) : null} From 43bd6677392b80cd12742f865c277c9939239543 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 20:02:22 -0700 Subject: [PATCH 06/82] fix(web): Claude skill calls show the skill name (#14980) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/lib/threadActivity.ts | 4 +-- .../Adapters/ClaudeAdapterV2.test.ts | 7 ++++- .../Adapters/ClaudeAdapterV2.ts | 11 +++++-- .../Adapters/CodexAdapterV2.ts | 4 +-- .../components/chat/MessagesTimeline.logic.ts | 8 ++--- .../src/components/chat/MessagesTimeline.tsx | 31 +++++++++++++------ packages/shared/src/toolActivity.test.ts | 12 +++++++ packages/shared/src/toolActivity.ts | 23 +++++++++++--- 8 files changed, 75 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c453ebf2312d..259b76c9b173 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -48,7 +48,7 @@ import { RunId, ThreadId } from "@t3tools/contracts"; import { classifyToolActivity, collectToolFilePaths, - computerUseToolTitle, + dynamicToolTitle, formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; @@ -540,7 +540,7 @@ function itemSummary( if (item.type === "system_notice") return item.message; if (item.type === "compaction") return contextCompactionLabel(item); const title = - (item.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : undefined) ?? + (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? item.title?.trim(); if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 95f8753ae688..a4164c936fdf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2771,7 +2771,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), ); - it.effect("retains image preview paths on Claude Read tool completion", () => + it.effect("titles Claude reads, searches, and skills on tool completion", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -2789,6 +2789,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } }, { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } }, + { id: "skill", name: "Skill", input: { skill: "full-send" } }, { id: "write", name: "Write", @@ -2859,6 +2860,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, "Searched TODO in src", ); + assert.equal( + items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title, + "Skill: full-send", + ); for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) assert.notProperty(item, "viewedImagePath"); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 99f77ee0779c..2cf91c619594 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,7 +1,11 @@ import * as NodeCrypto from "node:crypto"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; -import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +import { + dynamicToolTitle, + formatReadToolLabel, + formatSearchToolLabel, +} from "@t3tools/shared/toolActivity"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; import { @@ -3726,7 +3730,10 @@ export function makeClaudeAdapterV2( title: readPath !== undefined ? formatReadToolLabel(readPath) - : (searchTitle ?? input.presentation?.title ?? null), + : (searchTitle ?? + dynamicToolTitle(input.toolName, nativeToolInput) ?? + input.presentation?.title ?? + null), startedAt: input.startedAt, completedAt, updatedAt: input.updatedAt, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 88cd870f53c2..19c3486f5af7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; +import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import type { @@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem( item.type === "mcpToolCall" ? `${item.server}.${item.tool}` : [trimText(item.namespace), item.tool].filter(Boolean).join("."); - const title = computerUseToolTitle(toolName, item.arguments); + const title = dynamicToolTitle(toolName, item.arguments); const projection: CodexDynamicToolProjection = { ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}), toolName, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f7eb34f1b584..b035839c4ae4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -48,7 +48,7 @@ import { type T3McpToolPresentation, } from "@t3tools/shared/t3McpToolPresentation"; import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; -import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; +import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { collectToolFilePaths, @@ -81,8 +81,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string { const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); if (toolPresentation) return toolPresentation.displayName; const item = entry.structuredPayload; - const title = - item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; + const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; if (title) return title; // A lone web search keeps its heading; the query stays in its detail. if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; @@ -142,8 +141,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string const providerRetry = entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; const item = entry.structuredPayload; - const title = - item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; + const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; if (title) return title; const compactDetail = entry.detail?.trim(); const detailIsSearchOutput = diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fd71d616bae9..5dec1b6d411d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -57,6 +57,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; +import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { createContext, @@ -5034,8 +5035,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? getQuestionAnswerPreview(workEntry.questionAnswer) : null; const viewedImagePath = workEntryViewedImagePath(workEntry); - const isRead = toolGroupAction(workEntry) === "read"; - const readOutput = isRead ? workEntryReadOutput(workEntry, workspaceRoot) : null; + const payload = workEntry.structuredPayload; + const skill = + payload?.type === "dynamic_tool" + ? claudeSkillInvocation(payload.toolName, payload.input) + : undefined; + // Reads and skills expand to plain text instead of the item inspector. A + // skill's heading already names it, so only its arguments are left to show. + const plainOutput = + toolGroupAction(workEntry) === "read" + ? workEntryReadOutput(workEntry, workspaceRoot) + : skill + ? (skill.args ?? null) + : undefined; const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -5055,8 +5067,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ); const expandedBody = expanded && !isReasoning - ? isRead - ? readOutput + ? plainOutput !== undefined + ? plainOutput : buildToolCallExpandedBody( workEntry, workspaceRoot, @@ -5064,9 +5076,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { viewedImage ? viewedImagePath : null, ) : null; - const canExpandProjectedItem = isRead - ? Boolean(readOutput || viewedImage || workEntry.questionAnswer) - : canExpand || workEntry.projectedItem !== undefined; + const canExpandProjectedItem = + plainOutput !== undefined + ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) + : canExpand || workEntry.projectedItem !== undefined; // Reserve destructive row styling for severe failures, not routine tool errors. const iconWrapperClass = cn( "flex size-4 items-center justify-center", @@ -5237,9 +5250,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { !isReasoning && !workEntry.questionAnswer && canExpandProjectedItem && - (expandedBody || (workEntry.projectedItem && !isRead)) ? ( + (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? ( - {workEntry.projectedItem && !isRead ? ( + {workEntry.projectedItem && plainOutput === undefined ? ( { mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }), ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); }); + + it("titles Claude skill calls with the skill they load", () => { + expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); + expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ + name: "claude-api", + args: "pricing", + }); + expect(dynamicToolTitle("Skill", { skill: " " })).toBeUndefined(); + expect(dynamicToolTitle("Read", { skill: "full-send" })).toBeUndefined(); + }); }); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 04f746a01d1a..d281462cb08d 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -14,13 +14,28 @@ function asTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -/** CUA's `title` describes the action shown in the activity log. */ -export function computerUseToolTitle( +/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */ +export function claudeSkillInvocation( + toolName: string | null | undefined, + input: unknown, +): { readonly name: string; readonly args: string | undefined } | undefined { + if (toolName !== "Skill") return undefined; + const record = asRecord(input); + const name = asTrimmedString(record?.skill); + return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; +} + +/** + * Activity log heading a dynamic tool derives from its input: CUA's `title`, + * or the skill a Claude `Skill` call loads. + */ +export function dynamicToolTitle( toolName: string | null | undefined, input: unknown, ): string | undefined { - if (toolName !== "cua_repl.js") return undefined; - return asTrimmedString(asRecord(input)?.title); + if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title); + const skill = claudeSkillInvocation(toolName, input); + return skill === undefined ? undefined : `Skill: ${skill.name}`; } function recordHasKeys( From 858b98b8fa618cf9b9528b5415493fcf0572ac88 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 2 Oct 2026 23:29:01 -0400 Subject: [PATCH 07/82] test(server): the fake Claude CLI is a fixture file, not a generated string (#14950) Signed-off-by: Yordis Prieto --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 66 +------------------ .../ClaudeCapabilitiesProbe.fixture.mjs | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+), 63 deletions(-) create mode 100755 apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index bb6e5bd7c7dd..ad54e41f0cc9 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -58,7 +58,9 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); - const executablePath = path.join(tempDir, "fake-claude.mjs"); + const executablePath = yield* path.fromFileUrl( + new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), + ); const invocationPath = path.join(tempDir, "invocation.json"); // The probe aborts the SDK without awaiting the child's exit, and on // Windows a directory that is still some process's cwd cannot be @@ -80,68 +82,6 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ), ); - yield* fs.writeFileString( - executablePath, - [ - "#!/usr/bin/env node", - 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', - 'import { createInterface } from "node:readline";', - "const args = process.argv.slice(2);", - 'const mcpConfigIndex = args.indexOf("--mcp-config");', - "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", - "let mcpConfig;", - "if (rawMcpConfig) {", - ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', - " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", - "}", - "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", - " args,", - " cwd: process.cwd(),", - " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", - " mcpConfig,", - "}));", - "const lines = createInterface({ input: process.stdin });", - 'lines.on("line", (line) => {', - " const message = JSON.parse(line);", - ' if (message.type !== "control_request") return;', - " const reply = (response) => process.stdout.write(JSON.stringify({", - ' type: "control_response",', - ' response: { subtype: "success", request_id: message.request_id, response },', - ' }) + "\\n");', - ' if (message.request?.subtype === "initialize") {', - " reply({", - ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', - " agents: [],", - ' output_style: "default",', - ' available_output_styles: ["default"],', - " models: [],", - ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', - " });", - " }", - " // The probe follows initialize with get_usage on the same process.", - ' if (message.request?.subtype === "get_usage") {', - " reply({", - " session: {},", - ' subscription_type: "pro",', - " rate_limits_available: true,", - ' rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },', - " behaviors: null,", - " });", - " }", - "});", - "// Stay alive for follow-up control requests, but never outlive the", - "// parent: the probe aborts the SDK without awaiting the child, so an", - "// unconditional interval would strand this process until reboot.", - "const keepAlive = setInterval(() => {}, 1_000);", - 'lines.on("close", () => {', - " clearInterval(keepAlive);", - " process.exit(0);", - "});", - "", - ].join("\n"), - ); - yield* fs.chmod(executablePath, 0o755); - const capabilities = yield* probeClaudeCapabilities( decodeClaudeSettings({ binaryPath: executablePath }), { diff --git a/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs new file mode 100755 index 000000000000..57fc1795cb65 --- /dev/null +++ b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +const args = process.argv.slice(2); +const mcpConfigIndex = args.indexOf("--mcp-config"); +const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; +let mcpConfig; +if (rawMcpConfig) { + const contents = NodeFS.existsSync(rawMcpConfig) + ? NodeFS.readFileSync(rawMcpConfig, "utf8") + : rawMcpConfig; + try { + mcpConfig = JSON.parse(contents); + } catch { + mcpConfig = contents; + } +} +NodeFS.writeFileSync( + process.env.T3_PROBE_INVOCATION_PATH, + JSON.stringify({ + args, + cwd: process.cwd(), + connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, + mcpConfig, + }), +); +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.type !== "control_request") return; + const reply = (response) => + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { subtype: "success", request_id: message.request_id, response }, + }) + "\n", + ); + if (message.request?.subtype === "initialize") { + reply({ + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + agents: [], + output_style: "default", + available_output_styles: ["default"], + models: [], + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + }); + } + // The probe follows initialize with get_usage on the same process. + if (message.request?.subtype === "get_usage") { + reply({ + session: {}, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, + behaviors: null, + }); + } +}); +// Stay alive for follow-up control requests, but never outlive the +// parent: the probe aborts the SDK without awaiting the child, so an +// unconditional interval would strand this process until reboot. +const keepAlive = setInterval(() => {}, 1_000); +lines.on("close", () => { + clearInterval(keepAlive); + process.exit(0); +}); From 4f7760e6a0037b06917adaae1b8a220e3ee6e5cc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 20:50:04 -0700 Subject: [PATCH 08/82] fix(server): Claude subagents show the model their agent file picks (#14994) Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/threads/ThreadDetailScreen.tsx | 12 +- .../ProviderEventIngestor.test.ts | 106 +++++++++++++++++- .../orchestration-v2/ProviderEventIngestor.ts | 65 ++++++++++- .../ProviderSessionManager.test.ts | 10 +- .../src/orchestration-v2/runtimeLayer.ts | 10 +- .../testkit/ProviderReplayHarness.ts | 10 +- apps/web/src/components/ChatView.tsx | 11 +- 7 files changed, 216 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d1470315a566..19dea22e9e66 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -35,7 +35,7 @@ import { formatModelSelectionEffort, type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; -import { formatModelSlugName } from "@t3tools/shared/model"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; @@ -760,8 +760,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const providerSubagentProvider = props.serverConfig?.providers.find( (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, ); + // Providers can report a dated id or alias (claude-haiku-4-5-20251001). + const providerSubagentModelSlug = providerSubagentProvider + ? resolveSelectableModel( + providerSubagentProvider.driver, + props.selectedThread.modelSelection.model, + providerSubagentProvider.models, + ) + : null; const providerSubagentCatalogModel = providerSubagentProvider?.models.find( - (model) => model.slug === props.selectedThread.modelSelection.model, + (model) => model.slug === providerSubagentModelSlug, ); const workspaceContentWidth = useWorkspaceContentWidth(); // Clearing animated width can retain the unfolded width after Android resumes folded. diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 7b73b7d7f1d9..6bbfecf31d85 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -33,6 +33,7 @@ import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; import { makeProviderEventRoutingState, @@ -54,8 +55,16 @@ const TestLayer = Layer.mergeAll( TestStoresLayer, TestEventSinkLayer, IdAllocator.layer, + ThreadCommandExecutor.layer, ProviderEventIngestor.layer.pipe( - Layer.provide(Layer.mergeAll(TestStoresLayer, TestEventSinkLayer, IdAllocator.layer)), + Layer.provide( + Layer.mergeAll( + TestStoresLayer, + TestEventSinkLayer, + IdAllocator.layer, + ThreadCommandExecutor.layer, + ), + ), ), ); const modelSelection = { @@ -1234,4 +1243,99 @@ layer("ProviderEventIngestorV2", (it) => { assert.equal(messageEvents[0]?.threadId, childThreadId); }), ); + + it.effect("moves a native subagent's thread to the model its provider reports later", () => + 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 rootEvent = yield* threadCreatedEvent(now); + if (rootEvent.type !== "thread.created") { + throw new Error("Expected a thread.created fixture event"); + } + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: CODEX_DRIVER, + nativeThreadId: "native-late-model-subagent", + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + }); + const ingest = (event: ProviderEventIngestor.ProviderEventIngestInput["event"]) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + event, + }); + yield* eventSink.write({ events: [rootEvent] }); + // The subagent's thread starts on the parent's model and options. + yield* ingest({ + type: "app_thread.created", + driver: CODEX_DRIVER, + appThread: { + ...rootEvent.payload, + id: childThreadId, + title: "review design", + modelSelection: { + ...modelSelection, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + activeProviderThreadId: null, + lineage: { + parentThreadId: rootEvent.threadId, + relationshipToParent: "subagent", + rootThreadId: rootEvent.threadId, + }, + }, + }); + const subagentUpdated = { + type: "subagent.updated", + driver: CODEX_DRIVER, + subagent: { + id: NodeId.make("node:late-model-subagent"), + threadId: rootEvent.threadId, + runId: null, + parentNodeId: NodeId.make("node:root"), + origin: "provider_native", + createdBy: "agent", + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Review the design", + title: "review design", + model: "gpt-6.1-sol", + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + } satisfies ProviderEventIngestor.ProviderEventIngestInput["event"]; + + const first = yield* ingest(subagentUpdated); + const repeated = yield* ingest(subagentUpdated); + const childThread = yield* projectionStore.getThread(childThreadId); + + assert.deepEqual( + first.map((stored) => [stored.event.type, stored.event.threadId]), + [ + ["subagent.updated", rootEvent.threadId], + ["thread.model-selection-updated", childThreadId], + ], + ); + assert.deepEqual( + repeated.map((stored) => stored.event.type), + ["subagent.updated"], + ); + assert.deepEqual(childThread.modelSelection, { + instanceId: modelSelection.instanceId, + model: "gpt-6.1-sol", + }); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 8082962b86de..01a34547a237 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -6,6 +6,7 @@ import { type OrchestrationV2PlanArtifact, type OrchestrationV2Run, type OrchestrationV2ProviderTurn, + type OrchestrationV2Subagent, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -31,6 +32,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; import { makeProviderFailureTurnItem } from "./ProviderFailure.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; export class ProviderEventNormalizeError extends Schema.TaggedError()( "ProviderEventNormalizeError", @@ -249,13 +251,17 @@ const decodeDomainEvent = Schema.decodeUnknownEffect(OrchestrationV2DomainEvent) export const layer: Layer.Layer< ProviderEventIngestorV2, never, - EventSink.EventSinkV2 | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 + | EventSink.EventSinkV2 + | IdAllocator.IdAllocatorV2 + | ProjectionStore.ProjectionStoreV2 + | ThreadCommandExecutor.ThreadCommandExecutor > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const completedTurnAnalytics = new Set(); @@ -338,6 +344,48 @@ export const layer: Layer.Layer< }, ); + /** + * A native subagent's thread starts on the parent's model when the + * provider names the real one later (a Claude agent file's model arrives + * with the subagent's first reply). Clients read the thread's model, so + * move the thread to the reported one. Thread commands rewrite the whole + * thread row under the thread's lock, so this read and write take it too. + */ + const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( + function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { + const { childThreadId, model } = subagent; + if (subagent.origin !== "provider_native" || childThreadId === null || model === null) { + return []; + } + const staleThread = projections.getThread(childThreadId).pipe( + Effect.map((thread) => (thread.modelSelection.model === model ? null : thread)), + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + ); + // Nearly every update already matches; only a mismatch takes the lock. + if ((yield* staleThread) === null) return []; + return yield* threadCommands.withLock( + childThreadId, + Effect.gen(function* () { + const thread = yield* staleThread; + if (thread === null) return []; + const now = yield* DateTime.now; + const event = yield* makeDomainEvent(input, { + type: "thread.model-selection-updated", + threadId: thread.id, + // The parent's options belong to the parent's model. + payload: { + ...thread, + modelSelection: { instanceId: thread.modelSelection.instanceId, model }, + updatedAt: now, + }, + occurredAt: now, + }); + return yield* eventSink.write({ events: [event] }); + }), + ); + }, + ); + const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => Effect.gen(function* () { switch (input.event.type) { @@ -543,6 +591,21 @@ export const layer: Layer.Layer< .pipe(Effect.mapError(mapWriteError)); return result.storedEvents; }).pipe( + Effect.flatMap((storedEvents) => + storedEvents.length === 0 || input.event.type !== "subagent.updated" + ? Effect.succeed(storedEvents) + : syncSubagentThreadModel(input, input.event.subagent).pipe( + Effect.map((synced) => [...storedEvents, ...synced]), + Effect.mapError( + (cause) => + new ProviderEventPublishError({ + providerSessionId: input.providerSessionId, + eventCount: 1, + cause, + }), + ), + ), + ), Effect.tap((storedEvents) => Effect.gen(function* () { if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") return; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 46c4695bad1d..a567a4ab4841 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -52,6 +52,7 @@ import { } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; const TestDatabaseLayer = SqlitePersistenceMemory; @@ -383,7 +384,14 @@ function makeTestLayer(input: { }), ); const providerEventIngestorTestLayer = ProviderEventIngestor.layer.pipe( - Layer.provide(Layer.mergeAll(configuredEventSinkLayer, IdAllocator.layer, TestStoresLayer)), + Layer.provide( + Layer.mergeAll( + configuredEventSinkLayer, + IdAllocator.layer, + TestStoresLayer, + ThreadCommandExecutor.layer, + ), + ), ); return Layer.mergeAll( TestStoresLayer, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 7ecbf57f204a..8573c6570795 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -35,6 +35,7 @@ import { layer as providerContinuationRequestsLayer } from "./ProviderContinuati import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; import { layer as threadTitleRegenerationServiceLayer } from "./ThreadTitleRegenerationService.ts"; import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts"; import { layer as providerSwitchServiceLayer } from "./ProviderSwitchService.ts"; @@ -98,7 +99,14 @@ export const ProjectServiceLayerLive = projectServiceLayer.pipe( ); const providerEventIngestorProvided = providerEventIngestorLayer.pipe( - Layer.provide(Layer.mergeAll(eventSinkProvided, idAllocatorLayer, projectionStoreLayer)), + Layer.provide( + Layer.mergeAll( + eventSinkProvided, + idAllocatorLayer, + projectionStoreLayer, + ThreadCommandExecutor.layer, + ), + ), ); const checkpointServiceProvided = checkpointServiceLayer.pipe(Layer.provide(idAllocatorLayer)); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 2fa1345528f9..f4a1201f1f3e 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -50,6 +50,7 @@ import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationServi import * as RuntimePolicy from "../RuntimePolicy.ts"; import * as TurnItemPositionStore from "../TurnItemPositionStore.ts"; import * as RuntimeRequestService from "../RuntimeRequestService.ts"; +import * as ThreadCommandExecutor from "../ThreadCommandExecutor.ts"; import * as ThreadForkService from "../ThreadForkService.ts"; import { runOrchestratorV2Scenario, @@ -301,7 +302,14 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( ); const commandReceiptStoreProvided = CommandReceiptStore.layer.pipe(Layer.provide(databaseLayer)); const providerEventIngestorProvided = ProviderEventIngestor.layer.pipe( - Layer.provide(Layer.mergeAll(storesLayer, eventSinkProvided, IdAllocator.layer)), + Layer.provide( + Layer.mergeAll( + storesLayer, + eventSinkProvided, + IdAllocator.layer, + ThreadCommandExecutor.layer, + ), + ), ); const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProcess.layer), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8fbe528b34b4..b5a96518892c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -116,6 +116,7 @@ import { createModelSelection, formatModelSlugName, resolvePromptInjectedEffort, + resolveSelectableModel, } from "@t3tools/shared/model"; import { projectScriptCwd, @@ -4055,8 +4056,16 @@ export default function ChatView(props: ChatViewProps) { const showProviderSubagentBar = isProviderSubagent; const composerMounted = !showProviderSubagentBar; const providerSubagentModels = selectedProviderEntry?.models ?? EMPTY_PROVIDER_MODELS; + // Providers can report a dated id or alias (claude-haiku-4-5-20251001). + const providerSubagentModelSlug = selectedProviderEntry + ? resolveSelectableModel( + selectedProviderEntry.driverKind, + activeThread?.modelSelection.model, + providerSubagentModels, + ) + : null; const providerSubagentCatalogModel = providerSubagentModels.find( - (model) => model.slug === activeThread?.modelSelection.model, + (model) => model.slug === providerSubagentModelSlug, ); const providerSubagentModelLabel = providerSubagentCatalogModel ? getTriggerDisplayModelName(providerSubagentCatalogModel) From 151c241cd2cc22c3b10ba8735e44f83fb723a511 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 2 Oct 2026 23:11:24 -0500 Subject: [PATCH 09/82] fix(client-runtime): format subagent descriptions as display names (#14999) --- .../src/state/threadExecution.test.ts | 43 +++++++++++++++++++ .../src/state/threadExecution.ts | 9 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 70ed818cadc3..7ebd468400f8 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -478,6 +478,49 @@ describe("threadRuntimeCanArchive", () => { }); describe("presentPendingBackgroundWork", () => { + it.each(["Subagent:", "Subagent: "])( + "falls back to the subagent noun when %s has no display name", + (description) => { + const presentation = presentPendingBackgroundWork([ + { taskId: "unnamed", kind: "subagent", description }, + ]); + + expect(presentation?.title).toBe("Waiting on a subagent"); + expect(presentation?.items[0]?.label).toBe("subagent"); + }, + ); + + it.each([ + "/root/luna_window_properties", + "Subagent: /root/luna_window_properties", + "/root/parent/luna_window_properties", + ])("uses the subagent display name for %s", (description) => { + const childThreadId = ThreadId.make("thread:luna"); + const presentation = presentPendingBackgroundWork([ + { taskId: "luna", kind: "subagent", description, childThreadId }, + ]); + + expect(presentation).toEqual({ + title: "Waiting on subagent Luna Window Properties", + items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }], + }); + }); + + it("formats subagent names in a mixed roster and preserves command descriptions", () => { + const presentation = presentPendingBackgroundWork([ + { taskId: "cmd", kind: "command", description: "/root/run_tests" }, + { taskId: "luna", kind: "subagent", description: "/root/luna_window_properties" }, + { taskId: "review", kind: "subagent", description: "Review src/math.ts" }, + ]); + + expect(presentation?.title).toBe("Waiting on 2 subagents and 1 command"); + expect(presentation?.items.map((item) => item.label)).toEqual([ + "Luna Window Properties", + "Review src/math.ts", + "/root/run_tests", + ]); + }); + it("names a single piece of work by kind", () => { expect( presentPendingBackgroundWork([ diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 0893fadb62c2..5101acc062ef 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -25,6 +25,7 @@ import { type ThreadRunSummary, type ThreadRuntimeSummary, } from "./models.ts"; +import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; const ACTIVITY_RUN_STATUSES = new Set(["preparing", "starting", "running", "waiting"]); const INTERRUPTIBLE_RUN_STATUSES = new Set(["preparing", "starting", "running"]); @@ -295,13 +296,17 @@ export function presentPendingBackgroundWork( const items = tasks .map((task): PendingBackgroundWorkItem => { const description = task.description?.trim(); + const label = + task.kind === "subagent" && description !== undefined + ? formatSubagentDisplayTitle(description).trim() + : description; return { taskId: task.taskId, kind: task.kind, label: - description === undefined || description.length === 0 + label === undefined || label.length === 0 ? BACKGROUND_WORK_KINDS[task.kind].singular - : description, + : label, childThreadId: task.kind === "subagent" ? task.childThreadId : undefined, }; }) From c91e93c231e7ae6ec993f8496120ef04dd5fe24d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 21:20:48 -0700 Subject: [PATCH 10/82] Update TRIAGE_EXEMPTIONS with new entries Added markflorkowski and Yash-Singh1 to exemptions list. --- .github/TRIAGE_EXEMPTIONS.td | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/TRIAGE_EXEMPTIONS.td b/.github/TRIAGE_EXEMPTIONS.td index 6a432bd65678..18c6756bdbbd 100644 --- a/.github/TRIAGE_EXEMPTIONS.td +++ b/.github/TRIAGE_EXEMPTIONS.td @@ -3,4 +3,6 @@ # Keep entries sorted alphabetically. Only the trusted upstream main copy applies. github:juliusmarminge github:maria-rcks +github:markflorkowski github:t3dotgg +github:Yash-Singh1 From 22e9d35613305a04a28e96f83abc10c1bcf4aa8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 21:25:44 -0700 Subject: [PATCH 11/82] fix(web): outdated servers can be updated even when the client can't connect (#15002) Co-authored-by: Claude Opus 5.5 (1M context) --- .../web/src/components/ServerUpdateAction.tsx | 55 +++- .../settings/ConnectionsSettings.tsx | 13 + apps/web/src/state/server.ts | 3 + packages/client-runtime/package.json | 4 + .../client-runtime/src/connection/catalog.ts | 2 + .../src/connection/compatibility.test.ts | 24 ++ .../src/connection/compatibility.ts | 26 +- .../client-runtime/src/connection/index.ts | 2 + .../client-runtime/src/connection/layer.ts | 2 + .../client-runtime/src/connection/model.ts | 2 + .../src/connection/onboarding.test.ts | 52 +++- .../src/connection/onboarding.ts | 5 +- .../src/connection/outdatedHostUpdate.test.ts | 278 ++++++++++++++++++ .../src/connection/outdatedHostUpdate.ts | 190 ++++++++++++ .../client-runtime/src/connection/registry.ts | 36 ++- .../client-runtime/src/connection/resolver.ts | 27 +- .../src/state/outdatedServerUpdate.ts | 78 +++++ packages/client-runtime/src/state/server.ts | 5 +- 18 files changed, 784 insertions(+), 20 deletions(-) create mode 100644 packages/client-runtime/src/connection/outdatedHostUpdate.test.ts create mode 100644 packages/client-runtime/src/connection/outdatedHostUpdate.ts create mode 100644 packages/client-runtime/src/state/outdatedServerUpdate.ts diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index fa7ec5e601f1..82307156ba58 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -10,7 +10,7 @@ import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useEnvironmentSettings } from "~/hooks/useSettings"; -import { serverEnvironment } from "~/state/server"; +import { serverEnvironment, updateOutdatedServer } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; @@ -290,3 +290,56 @@ export function ServerUpdateAction({ ); } + +/** + * Updates a host too old for this client to connect to. Its version comes + * from the host descriptor because the host never delivers a server config. + */ +export function OutdatedServerUpdateAction({ + environmentId, + serverLabel, + fromVersion, + targetVersion, + label = "Update", +}: { + readonly environmentId: EnvironmentId; + readonly serverLabel: string; + readonly fromVersion: string | undefined; + readonly targetVersion: string; + readonly label?: string; +}) { + const update = useAtomCommand(updateOutdatedServer, { reportFailure: false }); + const handleUpdate = async () => { + if (pendingUpdateEnvironmentIds.has(environmentId)) return; + pendingUpdateEnvironmentIds.add(environmentId); + try { + const result = await update({ + environmentId, + input: { targetVersion }, + ...(fromVersion === undefined ? {} : { fromVersion }), + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + toastManager.add({ + type: "success", + title: `${serverLabel} updated`, + description: `Reconnected on t3@${result.value.targetVersion}.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(error), + }); + } finally { + pendingUpdateEnvironmentIds.delete(environmentId); + } + }; + return ( + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 7804bfbdfa2c..e018e7941ec1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -165,11 +165,13 @@ import { usePrimaryEnvironment, useRelayEnvironmentDiscovery, } from "~/state/environments"; +import { APP_VERSION } from "~/branding"; import { requestConfirmDialog } from "~/confirmDialog"; import { useAtomCommand } from "../../state/use-atom-command"; import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { + OutdatedServerUpdateAction, ServerUpdateAction, ServerUpdateProgress, ServerUpdatesAction, @@ -1598,6 +1600,17 @@ function SavedBackendListRow({ ) : null } > + {unsupported && + environment.entry.serverUpdateRequired === true && + serverUpdateState.status !== "running" ? ( + + ) : null} {showUpdateAction ? ( ()( diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index e6a2cf3ca18f..3f9dc8f99245 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -50,5 +50,29 @@ describe("orchestration protocol compatibility", () => { ); expect(error).toMatchObject({ reason: "unsupported" }); expect(error?.message).toContain("This client is not supported"); + expect(error).not.toHaveProperty("serverUpdateRequired"); + }); + + it("offers a remote update only for an older host that can update itself", () => { + const older = descriptor(ORCHESTRATION_PROTOCOL_VERSION - 1); + const withCapabilities = (capabilities: ExecutionEnvironmentDescriptor["capabilities"]) => + orchestrationProtocolCompatibilityError({ ...older, capabilities }); + + expect( + withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "boot-service" }), + ).toMatchObject({ serverUpdateRequired: true }); + expect(withCapabilities({ repositoryIdentity: true })).not.toHaveProperty( + "serverUpdateRequired", + ); + expect( + withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }), + ).not.toHaveProperty("serverUpdateRequired"); + expect( + withCapabilities({ + repositoryIdentity: true, + serverSelfUpdate: "desktop-managed", + desktopAppUpdate: true, + }), + ).toMatchObject({ serverUpdateRequired: true }); }); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 0a08ddb2acfa..c0ec739e55e0 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -14,13 +14,25 @@ export function orchestrationProtocolCompatibilityError( if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) { return null; } - return new ConnectionBlockedError({ - reason: "unsupported", - detail: - serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION - ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.` - : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, - }); + return serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION + ? new ConnectionBlockedError({ + reason: "unsupported", + detail: `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`, + }) + : new ConnectionBlockedError({ + reason: "unsupported", + detail: `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, + ...(canSelfUpdate(descriptor) ? { serverUpdateRequired: true } : {}), + }); +} + +/** Whether this client can drive the host's update remotely. */ +function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { + const { serverSelfUpdate, desktopAppUpdate } = descriptor.capabilities; + return ( + serverSelfUpdate !== undefined && + (serverSelfUpdate !== "desktop-managed" || desktopAppUpdate === true) + ); } export function appendOrchestrationProtocol(socketUrl: string): string { diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index cd6312163681..04833c3feb37 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -16,3 +16,5 @@ export * as EnvironmentSupervisor from "./supervisor.ts"; export * as Wakeups from "./wakeups.ts"; export { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; +// Flat so consumers' inferred command types can name it. +export { OutdatedHostUpdateError } from "./outdatedHostUpdate.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 8b1a4eaa5dde..8ec233fcc097 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -75,6 +75,8 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { registryLayer, RelayEnvironmentDiscovery.layer, onboardingLayer, + // Exposed for updating hosts too old to connect through the driver. + ConnectionResolver.layer, ); const connectionStartupLayer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 45d1866ec713..78d11b84b9e4 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -94,6 +94,8 @@ export class ConnectionBlockedError extends Schema.TaggedError, - options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number }, + options?: { + readonly failDescriptor?: boolean; + readonly protocolVersion?: number; + readonly selfUpdate?: boolean; + }, ) { const fetchFn = ((input, init = {}) => { const url = String(input); @@ -56,6 +60,7 @@ function pairingHttpLayer( orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION, capabilities: { repositoryIdentity: true, + ...(options?.selfUpdate === true ? { serverSelfUpdate: "boot-service" } : {}), }, }), ); @@ -145,6 +150,51 @@ describe("connection onboarding", () => { }), ); + it.effect("pairs an outdated server so it can be updated from this client", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { + protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1, + selfUpdate: true, + }), + ), + ), + ); + expect(registration.target.environmentId).toBe("environment-paired"); + expect(calls.map((call) => call.url)).toContain("https://remote.example.test/oauth/token"); + }), + ); + + it.effect("refuses an outdated server that cannot update itself", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }), + ), + ), + Effect.flip, + ); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error).not.toHaveProperty("serverUpdateRequired"); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }), + ); + it.effect("does not consume a pairing credential when descriptor discovery fails", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 24c03addfa66..fec7847b541f 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -93,7 +93,10 @@ export const preparePairingRegistration = Effect.fn( httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); - if (compatibilityError !== null) return yield* compatibilityError; + // An outdated server is still saved so it can be updated from this client. + if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { + return yield* compatibilityError; + } const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts new file mode 100644 index 000000000000..e63d095facb5 --- /dev/null +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts @@ -0,0 +1,278 @@ +import { + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + ExecutionEnvironmentDescriptor, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +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 SubscriptionRef from "effect/SubscriptionRef"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as Socket from "effect/unstable/socket/Socket"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; +import { PrimaryConnectionTarget } from "./model.ts"; +import { updateOutdatedHost } from "./outdatedHostUpdate.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-old"), + label: "Build Mac", + httpBaseUrl: "https://build.example.test", + wsBaseUrl: "wss://build.example.test", +}); + +const descriptor = (protocol: number | undefined, serverVersion: string) => + ({ + environmentId: TARGET.environmentId, + label: TARGET.label, + platform: { os: "darwin", arch: "arm64" }, + serverVersion, + ...(protocol === undefined ? {} : { orchestrationProtocolVersion: protocol }), + capabilities: { repositoryIdentity: true, serverSelfUpdate: "boot-service" }, + }) satisfies ExecutionEnvironmentDescriptor; + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.Union([Schema.String, Schema.Number]), + payload: Schema.Unknown, + tag: Schema.String, +}); +const isRpcRequest = Schema.is(RpcRequest); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeDescriptor = Schema.encodeSync(Schema.fromJsonString(ExecutionEnvironmentDescriptor)); + +type Listener = (event: { readonly type: string; readonly data?: unknown }) => void; + +/** Answers update RPCs the way a protocol-1 server does. */ +class OutdatedHostSocket { + static readonly OPEN = 1; + readyState = 0; + readonly requests: Array = []; + private readonly listeners = new Map>(); + + readonly url: string; + private readonly onUpdate: () => void; + + constructor(url: string, onUpdate: () => void) { + this.url = url; + this.onUpdate = onUpdate; + queueMicrotask(() => { + this.readyState = OutdatedHostSocket.OPEN; + this.emit({ type: "open" }); + }); + } + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.get(type)?.delete(listener); + } + + send(data: string) { + const message = decodeJson(data); + if (!isRpcRequest(message)) return; + this.requests.push(message); + if (message.tag !== WS_METHODS.serverUpdateServer) return; + this.onUpdate(); + queueMicrotask(() => + this.emit({ + type: "message", + data: encodeJson({ + _tag: "Exit", + requestId: message.id, + exit: { + _tag: "Success", + value: { targetVersion: "0.0.46", method: "boot-service", updateId: "update-1" }, + }, + }), + }), + ); + } + + close() { + this.readyState = 3; + this.emit({ type: "close" }); + } + + private emit(event: { readonly type: string; readonly data?: unknown }) { + for (const listener of this.listeners.get(event.type) ?? []) listener(event); + } +} + +describe("updateOutdatedHost", () => { + it.effect("updates a protocol-1 host over a bare socket, then switches it back on", () => + Effect.gen(function* () { + const blocked = orchestrationProtocolCompatibilityError(descriptor(undefined, "0.0.45")); + expect(blocked).toMatchObject({ serverUpdateRequired: true }); + + const sockets: Array = []; + let served: ExecutionEnvironmentDescriptor = descriptor(undefined, "0.0.45"); + const entries = yield* SubscriptionRef.make< + ReadonlyMap + >( + new Map([ + [ + TARGET.environmentId, + { + target: TARGET, + profile: Option.none(), + enabled: false, + unsupportedReason: blocked?.message ?? "", + serverUpdateRequired: true, + }, + ], + ]), + ); + const calls: Array = []; + const registry = EnvironmentRegistry.EnvironmentRegistry.of({ + entries, + setCompatibility: (_environmentId: EnvironmentId, error: unknown) => + Effect.sync(() => calls.push(`compatibility:${error === null ? "clear" : "block"}`)), + setEnabled: (_environmentId: EnvironmentId, enabled: boolean) => + Effect.sync(() => calls.push(`enabled:${enabled}`)), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const resolver = ConnectionResolver.ConnectionResolver.of({ + prepare: () => Effect.die(new Error("The update must bypass the protocol gate.")), + prepareForUpdate: () => + Effect.sync(() => served).pipe( + Effect.map((current) => ({ + descriptor: current, + prepared: { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://build.example.test/ws?wsTicket=ticket", + httpAuthorization: null, + target: TARGET, + }, + })), + ), + }); + const httpClient = HttpClient.make((request) => + Effect.sync(() => + HttpClientResponse.fromWeb(request, new Response(encodeDescriptor(served))), + ), + ); + + const result = yield* updateOutdatedHost( + TARGET.environmentId, + { targetVersion: "0.0.46" }, + () => Effect.void, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry), + Layer.succeed(ConnectionResolver.ConnectionResolver, resolver), + Layer.succeed( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: yield* SubscriptionRef.make( + RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + refresh: Effect.void, + }), + ), + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(Socket.WebSocketConstructor, (url) => { + // The host relaunches on a compatible protocol once the update lands. + const socket = new OutdatedHostSocket(url, () => { + served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46"); + }); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }), + ), + ), + ); + + expect(sockets[0]?.url).not.toContain("orchestrationProtocol"); + expect(sockets[0]?.requests.map((request) => request.tag)).toEqual([ + WS_METHODS.serverUpdateServer, + ]); + expect(result.targetVersion).toBe("0.0.46"); + expect(calls).toEqual(["compatibility:clear", "enabled:true"]); + }), + ); + + it.effect("refuses a host that cannot update itself without opening a socket", () => + Effect.gen(function* () { + const manual = { + ...descriptor(undefined, "0.0.45"), + capabilities: { repositoryIdentity: true }, + }; + let opened = false; + const error = yield* Effect.flip( + updateOutdatedHost(TARGET.environmentId, { targetVersion: "0.0.46" }, () => Effect.void), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + entries: yield* SubscriptionRef.make< + ReadonlyMap + >( + new Map([ + [ + TARGET.environmentId, + { target: TARGET, profile: Option.none(), enabled: false }, + ], + ]), + ), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + Layer.succeed( + ConnectionResolver.ConnectionResolver, + ConnectionResolver.ConnectionResolver.of({ + prepare: () => Effect.die(new Error("unused")), + prepareForUpdate: () => + Effect.succeed({ + descriptor: manual, + prepared: { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://build.example.test/ws", + httpAuthorization: null, + target: TARGET, + }, + }), + }), + ), + Layer.succeed( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: yield* SubscriptionRef.make( + RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + refresh: Effect.void, + }), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die(new Error("unused"))), + ), + Layer.succeed(Socket.WebSocketConstructor, () => { + opened = true; + throw new Error("unused"); + }), + ), + ), + ); + expect(error).toMatchObject({ _tag: "OutdatedHostUpdateError" }); + expect(opened).toBe(false); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.ts new file mode 100644 index 000000000000..0ca445b89386 --- /dev/null +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.ts @@ -0,0 +1,190 @@ +import { + ORCHESTRATION_PROTOCOL_VERSION, + type EnvironmentId, + type ExecutionEnvironmentDescriptor, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { makeWsRpcProtocolClient } from "../rpc/protocol.ts"; +import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from "../state/server.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as EnvironmentRegistry from "./registry.ts"; + +// A v1 host restarting into v2 runs migrations before its descriptor answers again. +const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4); +const SOCKET_OPEN_TIMEOUT = "15 seconds"; + +export class OutdatedHostUpdateError extends Schema.TaggedError()( + "OutdatedHostUpdateError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export type OutdatedHostUpdateStage = "downloading" | "installing" | "resuming"; + +/** + * Updates a host whose orchestration protocol is too old for this client. + * + * The normal session refuses to open against such a host, so this opens a + * bare socket and calls only the self-update RPCs, whose wire shape has not + * changed across protocol versions. Once the host relaunches on a compatible + * protocol the environment is switched back on and connects normally. + */ +export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutdatedHost")( + function* ( + environmentId: EnvironmentId, + input: ServerSelfUpdateInput, + onStage: (stage: OutdatedHostUpdateStage) => Effect.Effect, + ) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const resolver = yield* ConnectionResolver.ConnectionResolver; + const webSocketConstructor = yield* Socket.WebSocketConstructor; + const httpClient = yield* HttpClient.HttpClient; + const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); + if (entry === undefined) { + return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId }); + } + const { prepared, descriptor } = yield* resolver.prepareForUpdate(entry); + const capabilities = descriptor.capabilities; + if ( + capabilities.serverSelfUpdate === undefined || + (capabilities.serverSelfUpdate === "desktop-managed" && + capabilities.desktopAppUpdate !== true) + ) { + return yield* new OutdatedHostUpdateError({ + environmentId, + message: `Update T3 Code on ${descriptor.label} manually; it cannot update itself.`, + }); + } + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const protocolContext = yield* Layer.build( + Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + Socket.layerWebSocket(prepared.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe( + Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)), + ), + RpcSerialization.layerJson, + ), + ), + ), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + + const updateResult: ServerSelfUpdateResult = + capabilities.serverSelfUpdateProgress === true + ? yield* Effect.gen(function* () { + const terminal = yield* Ref.make(Option.none()); + const streamExit = yield* client[WS_METHODS.serverUpdateServerWithProgress]( + input, + ).pipe( + Stream.runForEach((event) => + event.type === "complete" + ? Ref.set(terminal, Option.some(event.result)) + : onStage(event.stage), + ), + Effect.exit, + ); + return yield* resolveServerUpdateProgressResult( + input.targetVersion, + yield* Ref.get(terminal), + streamExit, + ); + }) + : yield* client[WS_METHODS.serverUpdateServer](input).pipe( + // Older servers can drop the socket before acknowledging the restart. + Effect.catchCauseIf( + (cause) => + (capabilities.serverSelfUpdate === "boot-service" || + capabilities.serverSelfUpdate === "respawn") && + isLegacyUpdateHandoffLoss(cause), + () => + Effect.succeed({ + targetVersion: input.targetVersion, + method: capabilities.serverSelfUpdate as "boot-service" | "respawn", + } satisfies ServerSelfUpdateResult), + ), + ); + + if ( + updateResult.method === "desktop-app" && + updateResult.desktopUpdateToken !== undefined + ) { + // The commit relaunches the desktop app, so a dropped socket is success. + yield* client[WS_METHODS.serverCommitDesktopUpdate]({ + requestId: updateResult.desktopUpdateToken, + }).pipe( + Effect.catchCauseIf( + (cause) => isLegacyUpdateHandoffLoss(cause), + () => Effect.void, + ), + ); + } + return updateResult; + }), + ); + + yield* onStage("resuming"); + const resumed = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: prepared.httpBaseUrl, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.option, + Effect.repeat({ + schedule: Schedule.spaced(Duration.seconds(1)), + until: (current) => Option.exists(current, isCompatibleDescriptor), + }), + Effect.timeoutOption(OUTDATED_HOST_RESTART_TIMEOUT), + Effect.map(Option.flatten), + ); + if (Option.isNone(resumed)) { + return yield* new OutdatedHostUpdateError({ + environmentId, + message: `${descriptor.label} did not come back on a compatible T3 Code version.`, + }); + } + + // Discovery still holds the old relay descriptor and would re-block the + // environment from it, so replace that before clearing the block. + if (entry.target._tag === "RelayConnectionTarget") { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + yield* discovery.refresh; + } + yield* registry.setCompatibility(environmentId, null); + yield* registry.setEnabled(environmentId, true); + return { ...result, targetVersion: resumed.value.serverVersion }; + }, +); + +function isCompatibleDescriptor(descriptor: ExecutionEnvironmentDescriptor): boolean { + return (descriptor.orchestrationProtocolVersion ?? 1) === ORCHESTRATION_PROTOCOL_VERSION; +} diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index af1cc46faa9b..ab8eeb2a3b22 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -42,6 +42,17 @@ import { const isSshConnectionProfile = Schema.is(SshConnectionProfile); +function unsupportedState( + entry: ConnectionCatalogEntry, +): Pick { + return { + ...(entry.unsupportedReason === undefined + ? {} + : { unsupportedReason: entry.unsupportedReason }), + ...(entry.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), + }; +} + export class EnvironmentNotRegisteredError extends Schema.TaggedError()( "EnvironmentNotRegisteredError", { @@ -455,7 +466,7 @@ export const make = Effect.gen(function* () { enabled: previous.enabled, ...(previous.unsupportedReason !== undefined && gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) - ? { unsupportedReason: previous.unsupportedReason } + ? unsupportedState(previous) : {}), }; if ( @@ -494,7 +505,7 @@ export const make = Effect.gen(function* () { const entry: ConnectionCatalogEntry = previous?.unsupportedReason !== undefined && gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) - ? { ...registered, enabled: false, unsupportedReason: previous.unsupportedReason } + ? { ...registered, enabled: false, ...unsupportedState(previous) } : registered; const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( target.environmentId, @@ -844,11 +855,26 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); - if (entry === undefined || entry.unsupportedReason === (error?.message ?? undefined)) + if ( + entry === undefined || + (entry.unsupportedReason === (error?.message ?? undefined) && + entry.serverUpdateRequired === (error?.serverUpdateRequired ?? undefined)) + ) return; - const { unsupportedReason: _previousReason, ...rest } = entry; + const { + unsupportedReason: _previousReason, + serverUpdateRequired: _previousUpdateRequired, + ...rest + } = entry; const next: ConnectionCatalogEntry = - error === null ? rest : { ...rest, enabled: false, unsupportedReason: error.message }; + error === null + ? rest + : { + ...rest, + enabled: false, + unsupportedReason: error.message, + ...(error.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), + }; if ( error !== null && entry.enabled && diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index f54ebd3949fb..77b778a50aa8 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -1,4 +1,7 @@ -import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import type { + AuthClientPresentationMetadata, + ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -49,6 +52,17 @@ export class ConnectionResolver extends Context.Service< readonly prepare: ( entry: ConnectionCatalogEntry, ) => Effect.Effect; + /** + * Authorizes a socket without the orchestration protocol gate, for hosts + * too old to connect normally. Only update RPCs may run over it. + */ + readonly prepareForUpdate: (entry: ConnectionCatalogEntry) => Effect.Effect< + { + readonly prepared: PreparedConnection; + readonly descriptor: ExecutionEnvironmentDescriptor; + }, + ConnectionAttemptError + >; } >()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} @@ -245,7 +259,7 @@ export const make = Effect.gen(function* () { const ssh = yield* makeSshBroker(); const httpClient = yield* HttpClient.HttpClient; - const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + const authorize = Effect.fn("clientRuntime.connection.broker.authorize")(function* ( entry: ConnectionCatalogEntry, ) { const target: ConnectionTarget = entry.target; @@ -277,6 +291,13 @@ export const make = Effect.gen(function* () { actual: descriptor.environmentId, }); } + return { prepared, descriptor }; + }); + + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const { prepared, descriptor } = yield* authorize(entry); const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); if (compatibilityError !== null) { return yield* compatibilityError; @@ -287,7 +308,7 @@ export const make = Effect.gen(function* () { }; }); - return ConnectionResolver.of({ prepare }); + return ConnectionResolver.of({ prepare, prepareForUpdate: authorize }); }); export const layer = Layer.effect(ConnectionResolver, make); diff --git a/packages/client-runtime/src/state/outdatedServerUpdate.ts b/packages/client-runtime/src/state/outdatedServerUpdate.ts new file mode 100644 index 000000000000..36d7ba1f6713 --- /dev/null +++ b/packages/client-runtime/src/state/outdatedServerUpdate.ts @@ -0,0 +1,78 @@ +import type { EnvironmentId, ServerSelfUpdateInput } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { Atom } from "effect/unstable/reactivity"; +import type * as Socket from "effect/unstable/socket/Socket"; + +import { updateOutdatedHost } from "../connection/outdatedHostUpdate.ts"; +import type * as ConnectionResolver from "../connection/resolver.ts"; +import type * as EnvironmentRegistry from "../connection/registry.ts"; +import type * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts"; +import { + serverUpdateFailureMessage, + serverUpdateStateAtom, + type ServerUpdateStage, +} from "./server.ts"; + +export interface OutdatedServerUpdateTarget { + readonly environmentId: EnvironmentId; + readonly input: ServerSelfUpdateInput; + /** From the host descriptor when known; an outdated host never delivers a server config. */ + readonly fromVersion?: string; +} + +/** + * Updates a host too old for this client to connect to. Progress lands in the + * same per-environment update state as a normal server update. + */ +export function createOutdatedServerUpdateCommand( + runtime: Atom.AtomRuntime< + | EnvironmentRegistry.EnvironmentRegistry + | ConnectionResolver.ConnectionResolver + | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery + | Socket.WebSocketConstructor + | HttpClient.HttpClient, + E + >, +) { + return createRuntimeCommand(runtime, { + label: "environment-data:server:update-outdated-server", + scheduler: createAtomCommandScheduler(), + concurrency: { + mode: "singleFlight", + key: ({ environmentId }: OutdatedServerUpdateTarget) => environmentId, + }, + execute: (target: OutdatedServerUpdateTarget, atomRegistry) => { + const stateAtom = serverUpdateStateAtom(target.environmentId); + const targetVersion = target.input.targetVersion; + const fromVersion = target.fromVersion ?? targetVersion; + let currentStage: ServerUpdateStage = "downloading"; + const setStage = (stage: ServerUpdateStage) => + Effect.sync(() => { + currentStage = stage; + atomRegistry.set(stateAtom, { status: "running", stage, fromVersion, targetVersion }); + }); + return setStage(currentStage).pipe( + Effect.andThen(updateOutdatedHost(target.environmentId, target.input, setStage)), + Effect.onExit((exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { + atomRegistry.set(stateAtom, { status: "idle" }); + return; + } + atomRegistry.set(stateAtom, { + status: "failed", + stage: currentStage, + fromVersion, + targetVersion, + message: serverUpdateFailureMessage(Cause.squash(exit.cause)), + }); + }), + ), + ); + }, + }); +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a5077aa74f39..f17fa59adcc9 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -84,7 +84,8 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel("environment-data:server:update-state:empty"), ); -const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => +/** Shared with the outdated-host update, which reports through the same state. */ +export const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel(`environment-data:server:update-state:${environmentId}`), ), @@ -307,7 +308,7 @@ export function serverUpdateStateForServerVersion( : IDLE_SERVER_UPDATE_STATE; } -function serverUpdateFailureMessage(error: unknown): string { +export function serverUpdateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } From 3a7058da50c91906158c89fc3f845e6c04379414 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 21:37:18 -0700 Subject: [PATCH 12/82] fix(web): new worktree threads no longer flicker while starting (#15019) Co-authored-by: Claude Opus 5.5 (1M context) --- .../web/src/components/ChatView.logic.test.ts | 24 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 7 ++++-- apps/web/src/components/ChatView.tsx | 5 +++- .../chat/MessagesTimeline.logic.test.ts | 16 +++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 18 +++++++++----- .../src/components/chat/WorktreeSetupCard.tsx | 19 ++++++++------- 6 files changed, 72 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0f48fa1b9f06..9e6b96b21018 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -628,6 +628,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("holds a first send while the thread shell still reports a preparing run", () => { + // The draft had no run. The server thread's shell shows the new run before + // the detail projection behind `phase` loads. + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const preparingRun = { + ...completedTurn, + status: "preparing" as const, + startedAt: null, + completedAt: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "disconnected", + latestRun: preparingRun, + runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("waits for the matching running turn before acknowledging", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 255a4f7e8cd3..3323a8f3e0b9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; -import type { TimelineEntry } from "../session-logic"; +import { derivePhase, type TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; @@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } - if (input.phase === "connecting") { + // The thread shell can report a preparing or starting run before the detail + // projection behind `phase` loads, so either source still connecting holds + // the send. + if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b5a96518892c..e2037c2a0645 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) { optimisticUserMessages, ]); + // Keyed on the thread, not the draft: a draft's promotion to its server + // route keeps this instance and drops `draftId`, and the send it is still + // dispatching must survive that swap. useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) { }); resetLocalDispatch(); setExpandedImage(null); - }, [draftId, resetLocalDispatch, threadId]); + }, [resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e7c0afa479d6..7c2fb5bd8893 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4625,6 +4625,22 @@ it("keeps the working header in place across worktree setup handoff", () => { expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false }); + // A clean finish before the turn is live keeps that same layout, so the card + // does not jump above the header in the gap before the run starts. + const settledRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { + ...snapshot, + phase: "done", + stages: [stage("setup-script", "done"), stage("agent", "done")], + }, + }); + expect(settledRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); + // A script that already finished has nothing left to show once the turn is live. const finishedRows = deriveMessagesTimelineRows({ timelineEntries: [userEntry], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b035839c4ae4..88da32d28fc3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: { input.worktreeSetup !== undefined && worktreeSetupAgentStarted(input.worktreeSetup) && input.latestRun?.startedAt != null; - const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running"; + // A finished setup keeps the slot until the turn is live, so the card does + // not jump above the working header in the gap before the run starts. + const setupOwnsWorkingSlot = + !setupHandedOff && + (input.worktreeSetup?.phase === "running" || input.worktreeSetup?.phase === "done"); if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { const setupRow = { kind: "worktree-setup", @@ -1688,7 +1692,9 @@ export function deriveMessagesTimelineRows(input: { // While the setup runs, the working header leads the card in the same // slot it keeps once the agent's own turn takes over. The main pass may // already have placed that header (a bootstrap counts as working). - const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1; + const workingRowIndex = setupOwnsWorkingSlot + ? nextRows.findIndex((row) => row.kind === "working") + : -1; if (workingRowIndex >= 0) { nextRows.splice(workingRowIndex + 1, 0, setupRow); } else { @@ -1696,7 +1702,7 @@ export function deriveMessagesTimelineRows(input: { nextRows.splice( insertAt, 0, - ...(setupRunning + ...(setupOwnsWorkingSlot ? [ { kind: "working", @@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: { } } - // A running setup owns the working slot above its card and shows no - // activity row of its own; every other state gets the usual tail. + // A setup that owns the working slot sits under it and shows no activity + // row of its own; every other state gets the usual tail. const hasWorkingRow = nextRows.some((row) => row.kind === "working"); if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) { appendWorkingRow(); } if ( input.isWorking && - !setupRunning && + !setupOwnsWorkingSlot && !hasActiveCompaction && (!hasActivityRow || latestToolFailed) ) { diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index ae625c6a0d6f..0d0a0bc1b415 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -356,17 +356,20 @@ export function WorktreeSetupCard({ const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; const collapsed = embedded && !running; - // While running, the timeline's working row above the card carries the - // "Setting up worktree…" label (and keeps that slot when the agent takes - // over). The card only brings its own header for a settled outcome that - // has no working row to sit under. - const showHeader = !embedded && !running; + // While running, and after a clean finish until the agent's turn is live, + // the timeline's working row above the card carries the label (and keeps + // that slot when the agent takes over). The card only brings its own + // header for a failed or cancelled setup that has no working row above it. + const showHeader = !embedded && !running && snapshot.phase !== "done"; // The tail box is part of the script row's footprint while the script runs // (and after it failed, so the last lines explain the failure). It mounts - // as soon as the script is running, empty lines and all, so the card takes - // its final height once instead of growing with each output line. + // with the first output line at its full fixed height, so the card grows + // once instead of with each line. A script that ends before printing + // anything never flashes an empty box. const showTail = - setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed"); + setupStage !== undefined && + (setupStage.status === "failed" || + (setupStage.status === "running" && setupStage.tail.length > 0)); return (
From d668ffcd64d74daba16520cbbfb0fdb431983eac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 21:41:33 -0700 Subject: [PATCH 13/82] fix(server): subagent threads stop publishing tombstones to the relay (#15016) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/relay/AgentAwarenessRelay.test.ts | 24 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 10 ++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 30ada288bd76..a90226f67c7e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -659,6 +659,30 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect.each([ + { label: "live", archived: false }, + { label: "archived", archived: true }, + ])("never publishes tombstones for $label subagent threads", ({ archived }) => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set( + currentShell, + shell({ + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: THREAD_ID, + relationshipToParent: "subagent", + }, + ...(archived ? { archivedAt: yield* DateTime.now } : {}), + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 0); + }), + ); + it.effect("confirms a first completed state and respects disabling during confirmation", () => Effect.gen(function* () { const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 71e74d11b913..bc0e3d233119 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -522,6 +522,16 @@ export const make = Effect.gen(function* () { // domain event, so materializing the full shell here would make the cost // of one thread's activity proportional to how many threads exist. const threadShell = yield* threads.getThreadShell(threadId); + if ( + threadShell?.lineage.relationshipToParent === "subagent" && + !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) + ) { + // Subagents never project activity, so the relay holds no row to clear. + // Their events would otherwise publish a tombstone each, and every + // publish re-delivers the user's aggregate. Checked before the archive + // filter so archiving one stays quiet too. + return; + } const thread = threadShell === null || threadShell.archivedAt !== null ? Option.none() From 9333509c918083cbfb7e66759ddbcbfa71a3a452 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 21:42:36 -0700 Subject: [PATCH 14/82] fix(client-runtime): reconnects back off with jitter and keep healthy sockets (#14897) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/telemetry/AnalyticsService.test.ts | 83 ++++++ apps/server/src/telemetry/AnalyticsService.ts | 124 +++++++-- docs/internals/connection-runtime.md | 22 +- docs/internals/product-analytics.md | 7 + .../src/connection/registry.test.ts | 40 +++ .../src/connection/supervisor.test.ts | 245 ++++++++++++++++- .../src/connection/supervisor.ts | 251 +++++++++++------- .../client-runtime/src/connection/wakeups.ts | 1 + packages/client-runtime/src/state/server.ts | 6 +- 9 files changed, 631 insertions(+), 148 deletions(-) diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index c135467efffa..e2966306329d 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -4,6 +4,10 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -46,7 +50,86 @@ interface RecordedBatchBody { }>; } +const SentBatch = Schema.fromJsonString( + Schema.Struct({ + batch: Schema.Array(Schema.Struct({ uuid: Schema.String })), + }), +); + +/** + * HTTP client that reads each batch, then fails as if the connection dropped + * before the response arrived. PostHog stores these batches, so the server + * must not send them forever. + */ +const acceptThenFailClient = (batches: Array>) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + if (request.body._tag === "Uint8Array") { + const body = yield* Schema.decodeEffect(SentBatch)( + new TextDecoder().decode(request.body.body), + ).pipe(Effect.orDie); + batches.push(body.batch); + } + return yield* new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: "connection reset" }), + }); + }), + ), + ); + +it("retryDelayMs doubles from 2s and stays under the 5 minute cap", () => { + assert.equal(AnalyticsService.retryDelayMs(1, 0), 1_000); + assert.equal(AnalyticsService.retryDelayMs(2, 0.999_999), 4_000); + assert.equal(AnalyticsService.retryDelayMs(30, 0), 150_000); + assert.equal(AnalyticsService.retryDelayMs(30, 0.999_999), 300_000); +}); + it.layer(NodeServices.layer)("AnalyticsService test", (it) => { + it.effect("a batch that keeps failing is retried with backoff, then dropped", () => + Effect.gen(function* () { + const batches: Array> = []; + const runtimeLayer = AnalyticsService.layer.pipe( + Layer.provideMerge( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), + ), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_TELEMETRY_ENABLED: true, + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "http://localhost", + }), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + acceptThenFailClient(batches), + ), + ), + ); + + yield* Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + for (let index = 0; index < 20; index += 1) { + yield* analytics.record("test.retry", { index }); + } + // Before the fix this loop sent the batch about once a second. + for (let second = 0; second < 600; second += 1) { + yield* TestClock.adjust("1 second"); + } + }).pipe(Effect.provide(runtimeLayer)); + + assert.equal(batches.length, 5); + const uuids = batches.map((batch) => batch.map((event) => event.uuid).join(",")); + assert.equal(new Set(uuids).size, 1, "every retry carries the same uuids"); + assert.equal(new Set(batches[0]?.map((event) => event.uuid)).size, 20); + }), + ); + it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index d663a8f321e9..4d1db714eb96 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -2,19 +2,27 @@ * Anonymous PostHog telemetry service. * * Persists an installation-scoped anonymous identifier, buffers events in - * memory, and flushes batches over Effect's HTTP client. + * memory, and flushes batches over Effect's HTTP client. A failed batch is + * retried with backoff and dropped after a few tries. Each event carries a + * uuid, so PostHog can tell a retried copy from a new event. * * @module AnalyticsService */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import type { ClientOs } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -24,11 +32,40 @@ import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { + readonly uuid: string; readonly event: string; readonly properties?: Readonly>; readonly capturedAt: string; } +interface DeliveryState { + /** Batch that failed last. It is sent again before newer events. */ + readonly failedBatch: ReadonlyArray; + /** Failed sends of `failedBatch`. */ + readonly batchAttempts: number; + /** Failed sends since the last success. Sets the backoff delay. */ + readonly failures: number; + /** The background flush does not send before this time (epoch ms). */ + readonly retryAt: number; +} + +const FLUSH_INTERVAL_MS = 1_000; +// A hung send would hold the flush lock, and with it the shutdown flush. +const SEND_TIMEOUT = "10 seconds"; +const MAX_BATCH_ATTEMPTS = 5; +const RETRY_BASE_DELAY_MS = 2_000; +const RETRY_MAX_DELAY_MS = 300_000; + +/** + * Delay before the next send after `failures` consecutive failed sends. The + * ceiling doubles from 2s up to 5 minutes, and the delay is a random point in + * its upper half. `random` is in [0, 1). + */ +export function retryDelayMs(failures: number, random: number): number { + const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); + return Math.round(ceiling / 2 + (ceiling / 2) * random); +} + const TelemetryEnvConfig = Config.all({ posthogKey: Config.String("T3CODE_POSTHOG_KEY").pipe( Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), @@ -88,17 +125,31 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; const identifier = yield* getTelemetryIdentifier; + const crypto = yield* Crypto.Crypto; const bufferRef = yield* Ref.make>([]); + const deliveryRef = yield* Ref.make({ + failedBatch: [], + batchAttempts: 0, + failures: 0, + retryAt: 0, + }); + // The background flush and the shutdown flush must not send the same batch at once. + const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; - const enqueueBufferedEvent = (event: string, properties?: Readonly>) => + const enqueueBufferedEvent = ( + uuid: string, + event: string, + properties?: Readonly>, + ) => Effect.flatMap(DateTime.now, (now) => Ref.modify(bufferRef, (current) => { const appended = [ ...current, { + uuid, event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), @@ -128,6 +179,7 @@ export const make = Effect.gen(function* () { const payload = { api_key: telemetryConfig.posthogKey, batch: events.map((event) => ({ + uuid: event.uuid, event: event.event, distinct_id: identifier, properties: { @@ -152,39 +204,69 @@ export const make = Effect.gen(function* () { HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.timeout(SEND_TIMEOUT), ); }); + const takeBatch = Ref.modify(bufferRef, (current) => { + const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); + return [nextBatch, current.slice(nextBatch.length)] as const; + }); + + // Sends batches until the buffer is empty or a send fails. A failed batch is + // kept for the next flush, and dropped after MAX_BATCH_ATTEMPTS failed sends. const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { - const batch = yield* Ref.modify(bufferRef, (current) => { - if (current.length === 0) { - return [[] as ReadonlyArray, current] as const; - } - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - const remaining = current.slice(nextBatch.length); - return [nextBatch, remaining] as const; - }); - + const delivery = yield* Ref.get(deliveryRef); + const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; if (batch.length === 0) { return; } - yield* sendBatch(batch).pipe( - Effect.catch((error) => - Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( - Effect.flatMap(() => Effect.fail(error)), - ), - ), - ); + const sent = yield* Effect.result(sendBatch(batch)); + if (Result.isSuccess(sent)) { + yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures: 0, retryAt: 0 }); + continue; + } + + const failures = delivery.failures + 1; + const batchAttempts = delivery.batchAttempts + 1; + const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMs(failures, yield* Random.next); + if (batchAttempts < MAX_BATCH_ATTEMPTS) { + yield* Ref.set(deliveryRef, { failedBatch: batch, batchAttempts, failures, retryAt }); + yield* Effect.logDebug("Failed to send telemetry batch; will retry", { + attempt: batchAttempts, + cause: sent.failure, + }); + return; + } + yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures, retryAt }); + yield* Effect.logWarning("Dropped telemetry batch after repeated send failures", { + events: batch.length, + attempts: batchAttempts, + cause: sent.failure, + }); + return; } - }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); + }).pipe(flushLock.withPermit); + + const flushWhenDue = Effect.gen(function* () { + const { retryAt } = yield* Ref.get(deliveryRef); + if ((yield* Clock.currentTimeMillis) >= retryAt) { + yield* flush; + } + }); const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { if (!telemetryConfig.enabled || !identifier) return; - const enqueueResult = yield* enqueueBufferedEvent(event, properties); + // Telemetry is best effort: an event without a uuid is not sent. The + // Node implementation throws (a defect) rather than failing, so catch both. + const uuid = yield* Effect.exit(crypto.randomUUIDv7); + if (Exit.isFailure(uuid)) return; + + const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties); if (enqueueResult.dropped) { yield* Effect.logDebug("analytics buffer full; dropping oldest event", { size: enqueueResult.size, @@ -194,7 +276,7 @@ export const make = Effect.gen(function* () { }, ); - yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 301e1e0ffecb..35c8b0a73658 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -10,16 +10,20 @@ several views need the same environment. The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns transport retry policy; resolving an endpoint and opening an RPC session are single -attempts. Transient failures retry with capped backoff. Offline states and -authentication failures wait for a wakeup instead of spending attempts on -unchanged conditions. +attempts. Transient failures retry with jittered exponential backoff, capped at +five minutes, that resets only after a connection stays up. Without jitter, every +client of a restarted server reconnects in the same second; with a short cap, a +client that can never connect retries all day. Offline states and authentication +failures wait for a wakeup instead of spending attempts on unchanged conditions. -Foregrounding needs different treatment depending on the connection's state. -It wakes a retry immediately, leaves an ordinary in-flight attempt alone, and -probes an established session before replacing it. A long mobile background -suspension forces replacement because the OS can kill a socket without reporting -closure. Treating every foreground event as a reconnect delays healthy attempts; -treating every resume as harmless leaves suspended sockets stuck. +Foregrounding, an explicit retry, and an offline report probe the established +session, and only a failed probe reconnects. Offline reports are often wrong, for +example for a loopback server. A long mobile background suspension is the one +exception: it replaces the session at once, because the OS can kill a socket +without reporting closure, and a probe would hold a dead socket in "Resuming" +until it times out. That fresh attempt runs even while the network reports +offline. Foregrounding also wakes a pending retry immediately and +leaves an ordinary in-flight attempt alone. The [registry](../../packages/client-runtime/src/connection/registry.ts) scopes connections by environment. An involuntary disconnect retains the registration diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md index 0e88610ada99..e9123640a541 100644 --- a/docs/internals/product-analytics.md +++ b/docs/internals/product-analytics.md @@ -41,6 +41,13 @@ Unknown counts stay absent. Partial usage contains valid observed counts but cannot establish a whole-turn total. Keep these distinctions when changing token normalization or building reports. +## Delivery + +A send can fail after PostHog has stored the batch, so every retry is a copy. +[Delivery](../../apps/server/src/telemetry/AnalyticsService.ts) gives each event a +uuid when it is recorded, backs off after a failed send, and drops a batch after a +few tries. Without these limits, one stuck batch was sent every second for days. + ## Collection boundary Keep analytics payloads to product metadata and normalized measurements. Do not diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 998cbe479f2a..7a4d581d3c8e 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -1443,6 +1443,46 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("keeps one session per environment across concurrent registrations and retries", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const registration = new PrimaryConnectionRegistration({ target: TARGET }); + yield* Effect.all( + Array.from({ length: 5 }, () => registry.registerPlatform(registration)), + { concurrency: "unbounded", discard: true }, + ); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + // Platform polls and explicit retries reach a healthy connection at once. + yield* Effect.all( + [ + ...Array.from({ length: 5 }, () => registry.registerPlatform(registration)), + ...Array.from({ length: 5 }, () => registry.retryNow(TARGET.environmentId)), + registry.reconcilePlatform([registration]), + ], + { concurrency: "unbounded", discard: true }, + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + expect(yield* registry.state(TARGET.environmentId)).toMatchObject({ + phase: "connected", + generation: 1, + }); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("retains a healthy runtime when the platform repeats an identical registration", () => Effect.gen(function* () { const harness = yield* makeHarness([]); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index d64863b9ffcf..1d518f017d6d 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -5,6 +5,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -186,6 +187,11 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: }); const dependencies = Layer.mergeAll( + // Jitter at its maximum, so each retry waits exactly its ceiling: 2s, 4s, 8s... + Layer.succeed(Random.Random, { + nextDoubleUnsafe: () => 1 - Number.EPSILON, + nextIntUnsafe: () => 0, + }), Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( ConnectionWakeups.ConnectionWakeups, @@ -225,6 +231,17 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: }; }); +describe("retryDelayMs", () => { + it("doubles from 2 seconds to a 5 minute cap, jittered within the upper half of each step", () => { + const ceilings = [2_000, 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, 256_000, 300_000]; + for (const [failureCount, ceiling] of [...ceilings, 300_000].entries()) { + expect(EnvironmentSupervisor.retryDelayMs(failureCount, 0)).toBe(ceiling / 2); + expect(EnvironmentSupervisor.retryDelayMs(failureCount, 0.5)).toBe((ceiling * 3) / 4); + expect(EnvironmentSupervisor.retryDelayMs(failureCount, 1 - Number.EPSILON)).toBe(ceiling); + } + }); +}); + describe("EnvironmentSupervisor", () => { it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => Effect.gen(function* () { @@ -353,7 +370,7 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("retries forever with exponential backoff capped at sixteen seconds", () => + it.effect("retries forever with exponential backoff capped at five minutes", () => Effect.gen(function* () { const harness = yield* makeHarness({ prepare: () => Effect.fail(transient()), @@ -368,15 +385,20 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) { - yield* TestClock.adjust(delay); + const delays = [ + 2_000, 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, 256_000, 300_000, 300_000, + ]; + for (const [index, delay] of delays.entries()) { + yield* TestClock.adjust(delay - 1); + expect(yield* Ref.get(harness.prepareCount)).toBe(index + 1); + yield* TestClock.adjust(1); yield* eventuallyState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === index + 2, ); } - expect(yield* Ref.get(harness.prepareCount)).toBe(7); + expect(yield* Ref.get(harness.prepareCount)).toBe(delays.length + 1); }).pipe(Effect.provide(TestClock.layer())), ); @@ -578,7 +600,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("2999 millis"); + yield* TestClock.adjust("1999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(3); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -641,9 +663,12 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("releases a live session while offline and starts a new generation when online", () => + it.effect("keeps a session that still answers when the network reports offline", () => Effect.gen(function* () { - const harness = yield* makeHarness(); + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -652,21 +677,160 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "connected" && state.generation === 1, ); + // A loopback server, or a flap shorter than the probe, keeps working. yield* harness.setNetworkStatus("offline"); - yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + yield* harness.setNetworkStatus("online"); + yield* Effect.yieldNow; - expect(yield* Ref.get(harness.releaseCount)).toBe(1); - expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "connected", + generation: 1, + }); + }), + ); + + it.effect("replaces the session on a long resume while the network reports offline", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); - yield* harness.setNetworkStatus("online"); yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + // A wrong offline report: the probe answers, so the session stays. + yield* harness.setNetworkStatus("offline"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + + // The replacement connects although the network still reports offline. + yield* harness.wake("application-active-reconnect"); + const replaced = yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); + + expect(replaced.attempt).toBe(1); + expect(yield* Ref.get(probeCount)).toBe(1); expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect( + "releases a session that stops answering while offline and reconnects when online", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.setNetworkStatus("offline"); + yield* TestClock.adjust("3 seconds"); + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + yield* harness.setNetworkStatus("online"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("probes instead of replacing a healthy session on an explicit retry", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* supervisor.retryNow; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); }), ); + it.effect("keeps the backoff ladder after an explicit retry finds a healthy session", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("2 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + yield* supervisor.retryNow; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(probeCount)).toBe(1); + + // The flapping session keeps climbing the ladder: the answered retry does + // not reset it after this unrelated close. + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + yield* TestClock.adjust("4 seconds"); + const reconnected = yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3, + ); + expect(reconnected.attempt).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("retries a blocked connection when platform credentials change", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -844,7 +1008,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("3 seconds"); + yield* TestClock.adjust("2 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2, @@ -964,6 +1128,35 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("reconnects immediately when the session closes during a resume probe", () => + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: (attempt) => + attempt === 1 + ? Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active-probe"); + yield* Deferred.await(probeStarted); + // The OS reports the suspended socket's close before the probe answers. + yield* harness.closeLatestSession(); + + // No TestClock advance: the unanswered probe skips the first backoff rung. + const reconnected = yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(reconnected.attempt).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("reconnects immediately when the foreground liveness probe fails", () => Effect.gen(function* () { const allowReconnect = yield* Deferred.make(); @@ -1020,7 +1213,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("2999 millis"); + yield* TestClock.adjust("1999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(2); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -1055,6 +1248,32 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("an explicit retry shortens a stalled desktop foreground probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("5 seconds"); + yield* supervisor.retryNow; + // The retry's 3 second limit applies, not the 10 seconds left of the 15. + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + yield* TestClock.adjust("1 milli"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("quickly times out a stalled mobile foreground liveness probe", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index b75413bbb7de..a8e1a0831f90 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -2,11 +2,13 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -29,10 +31,13 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; +const RETRY_BASE_DELAY_MS = 1_000; +const RETRY_MAX_DELAY_MS = 300_000; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; -const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; +// Mobile resumes, explicit retries, and offline events want a fast answer: +// the user is waiting, or the network may be gone. +const QUICK_CONNECTION_PROBE_TIMEOUT = "3 seconds"; const BACKOFF_RESET_AFTER_MS = 30_000; interface SupervisorIntent { @@ -101,8 +106,19 @@ export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; } -function retryDelayMs(failureCount: number): number { - return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; +/** + * Delay before the next attempt after `failureCount` consecutive failures + * (0 for the first retry). The ceiling doubles from 2s up to 5 minutes, and + * the delay is a random point in its upper half: never quicker than half the + * ceiling, and spread out so clients that lost the same server do not all + * reconnect in the same second. `random` is in [0, 1). + * + * The long cap only applies to a connection that keeps failing. Returning to + * the app, the network coming back, and an explicit retry all skip the wait. + */ +export function retryDelayMs(failureCount: number, random: number): number { + const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failureCount + 1)); + return Math.round(ceiling / 2 + (ceiling / 2) * random); } function annotateTarget(target: ConnectionTarget) { @@ -235,10 +251,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); - // Set when a foreground wake probe fails or times out: the user is actively - // returning to the app on a dead transport, so the follow-up reconnect skips - // the first backoff rung instead of sleeping. - const wakeProbeFailed = yield* Ref.make(false); + // Set while a probe of the live session is running, and kept when it fails + // or times out: something asked whether the connection still works and it + // closed or failed before answering, so the follow-up reconnect skips the + // first backoff rung instead of sleeping. + const probeUnanswered = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -392,96 +409,118 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); + // Signals that end a connected lease whatever its health: "reset" ends it + // and restarts the retry ladder, "end" ends it, undefined keeps it. + const connectedLeaseEnd = Effect.fnUntraced(function* (next: SupervisorSignal) { + if (next._tag === "DisconnectRequested") { + return "end" as const; + } + if (next._tag !== "Wakeup") { + return undefined; + } + if (next.reason === "application-active-reconnect") { + // Mobile operating systems often kill a suspended socket without a close + // event. A probe would show a dead socket as "Resuming" until it times + // out, so a long background resume replaces the session at once. + return "reset" as const; + } + if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { + yield* logManagedRelayAccountChange; + return "end" as const; + } + return undefined; + }); + + // How long a signal waits for the live session to answer a probe, or + // undefined when the signal does not question the connection. + const probeTimeoutFor = (next: SupervisorSignal): Duration.Input | undefined => { + switch (next._tag) { + case "RetryRequested": + return QUICK_CONNECTION_PROBE_TIMEOUT; + case "NetworkChanged": + return next.network === "offline" ? QUICK_CONNECTION_PROBE_TIMEOUT : undefined; + case "Wakeup": + if (next.reason === "application-active") { + return CONNECTION_PROBE_TIMEOUT; + } + return next.reason === "application-active-probe" + ? QUICK_CONNECTION_PROBE_TIMEOUT + : undefined; + case "ConnectRequested": + case "DisconnectRequested": + return undefined; + } + }; + + // Holds a connected lease until it must end, and returns whether to restart + // the retry ladder. Returning to the app, an explicit retry, and the network + // reporting offline all probe the live session instead of replacing it, so a + // healthy socket is not torn down (the offline report is often wrong, for + // example for a loopback server). Only a long mobile resume replaces the + // session without a probe. A failed probe fails this effect, and the + // supervisor reconnects. const monitorConnectedLease = Effect.fnUntraced(function* ( lease: ConnectionDriver.EnvironmentConnectionLease, ) { + // A probe answers an explicit retry here, so the retry must not also reset + // the backoff of a later, unrelated failure. + const takeSignal = Queue.take(signals).pipe( + Effect.tap((next) => + next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void, + ), + ); for (;;) { - const next = yield* Queue.take(signals); - switch (next._tag) { - case "DisconnectRequested": - case "RetryRequested": - return false; - case "NetworkChanged": - if (next.network === "offline") { - return false; - } - break; - case "Wakeup": - if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { - yield* logManagedRelayAccountChange; - return false; - } - if (next.reason === "application-active-reconnect") { - // Mobile operating systems commonly suspend sockets without - // delivering a close event. A long background resume deliberately - // replaces that lease and starts a fresh attempt without backoff. - return true; - } - if (next.reason === "application-active" || next.reason === "application-active-probe") { - const probe = yield* lease.session.probe.pipe( - Effect.timeoutOrElse({ - duration: - next.reason === "application-active-probe" - ? MOBILE_CONNECTION_PROBE_TIMEOUT - : CONNECTION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new ConnectionTransientError({ - reason: "timeout", - detail: `${target.label} did not respond to a connection health check.`, - }), - ), - }), - Effect.forkChild, - ); - for (;;) { - const probeEvent = yield* Effect.raceFirst( - Fiber.await(probe).pipe( - Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), - ), - Queue.take(signals).pipe( - Effect.map((signal) => ({ _tag: "Signal" as const, signal })), - ), - ); - if (probeEvent._tag === "ProbeCompleted") { - if (Exit.isFailure(probeEvent.exit)) { - yield* Ref.set(wakeProbeFailed, true); - } - yield* probeEvent.exit; - break; - } - switch (probeEvent.signal._tag) { - case "DisconnectRequested": - case "RetryRequested": - yield* Fiber.interrupt(probe); - return false; - case "NetworkChanged": - if (probeEvent.signal.network === "offline") { - yield* Fiber.interrupt(probe); - return false; - } - break; - case "Wakeup": - if (probeEvent.signal.reason === "application-active-reconnect") { - yield* Fiber.interrupt(probe); - return true; - } - if ( - probeEvent.signal.reason === "credentials-changed" && - target._tag === "RelayConnectionTarget" - ) { - yield* Fiber.interrupt(probe); - return false; - } - break; - case "ConnectRequested": - break; - } - } + const next = yield* takeSignal; + const end = yield* connectedLeaseEnd(next); + if (end !== undefined) { + return end === "reset"; + } + const probeTimeout = probeTimeoutFor(next); + if (probeTimeout === undefined) { + continue; + } + yield* Ref.set(probeUnanswered, true); + const probe = yield* Effect.forkChild(lease.session.probe); + // Monotonic nanoseconds, so a wall-clock correction cannot move the deadline. + let deadline = (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(probeTimeout); + for (;;) { + const remaining = deadline - (yield* Clock.monotonicTimeNanos); + const probeEvent = yield* Effect.raceAllFirst([ + Fiber.await(probe).pipe( + Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), + ), + takeSignal.pipe(Effect.map((signal) => ({ _tag: "Signal" as const, signal }))), + Effect.sleep(Duration.nanos(remaining > 0n ? remaining : 0n)).pipe( + Effect.as({ _tag: "TimedOut" as const }), + ), + ]); + if (probeEvent._tag === "TimedOut") { + yield* Fiber.interrupt(probe); + return yield* new ConnectionTransientError({ + reason: "timeout", + detail: `${target.label} did not respond to a connection health check.`, + }); + } + if (probeEvent._tag === "ProbeCompleted") { + if (Exit.isSuccess(probeEvent.exit)) { + yield* Ref.set(probeUnanswered, false); } + yield* probeEvent.exit; break; - case "ConnectRequested": - break; + } + const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal); + if (endDuringProbe !== undefined) { + yield* Fiber.interrupt(probe); + return endDuringProbe === "reset"; + } + // A retry or an offline report during a desktop foreground probe wants + // its quicker answer, so it shortens the running probe. + const signalTimeout = probeTimeoutFor(probeEvent.signal); + if (signalTimeout !== undefined) { + const signalDeadline = + (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(signalTimeout); + if (signalDeadline < deadline) deadline = signalDeadline; + } } } }); @@ -491,6 +530,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( generation: number, lastFailure: ConnectionAttemptError | null, pendingRetry: Option.Option, + ignoreOffline: boolean, ) { yield* SubscriptionRef.set(prepared, Option.none()); const establishment = yield* Effect.raceAllFirst([ @@ -556,7 +596,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const active = establishment.exit.value; const currentIntent = yield* Ref.get(intent); - if (!currentIntent.desired || currentIntent.network === "offline") { + if (!currentIntent.desired || (currentIntent.network === "offline" && !ignoreOffline)) { return { _tag: "Interrupted", established: false, @@ -641,6 +681,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( failureCount = 0; pendingRetry = Option.none(); }; + // Set after a long resume ends an attempt or a session. The fresh attempt + // runs even while the network reports offline: the report is often wrong, + // and the replaced session must not leave the client offline. + let replacing = false; for (;;) { if (yield* Ref.getAndSet(resetRetryState, false)) { @@ -657,7 +701,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* waitForSignal; continue; } - if (currentIntent.network === "offline") { + if (currentIntent.network === "offline" && !replacing) { yield* clearLease; yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); const applicationActivated = yield* waitForSignal; @@ -670,11 +714,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const attempt = failureCount + 1; const nextGeneration = generation + 1; const outcome: AttemptOutcome = yield* Effect.scoped( - runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), + runAttempt(attempt, nextGeneration, latestFailure, pendingRetry, replacing), ); + replacing = false; // Consumed on every iteration so a stale marker can never leak into a // later, unrelated failure. - const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); + const failedProbe = yield* Ref.getAndSet(probeUnanswered, false); if (outcome.established) { generation = nextGeneration; if (outcome.stable) { @@ -685,6 +730,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (outcome._tag === "Interrupted") { if (outcome.resetRetry) { resetRetryLadder(); + replacing = true; } continue; } @@ -711,18 +757,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } - if (failedWakeProbe) { - // The wake probe found a dead transport while the user is returning to - // the app, so reconnect immediately instead of sleeping the first - // backoff rung. Only this first attempt skips the ladder; if it fails - // too, normal backoff resumes. + if (failedProbe) { + // A probe found a dead transport, or the transport closed while a probe + // waited for an answer (the user returned to the app, asked to retry, + // or the network changed), so reconnect immediately instead + // of sleeping the first backoff rung. Only this first attempt skips the + // ladder; if it fails too, normal backoff resumes. resetRetryLadder(); yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); continue; } failureCount += 1; - const delayMs = retryDelayMs(failureCount - 1); + const delayMs = retryDelayMs(failureCount - 1, yield* Random.next); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ previousAttempt, failureCount, diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 721b941645c0..5f19924c47a1 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -16,6 +16,7 @@ export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { ); } +// A long resume replaces the session, and the new session subscribes on its own. export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean { return reason === "application-active" || reason === "application-active-probe"; } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f17fa59adcc9..0a0e3ca377aa 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -179,9 +179,9 @@ export function validateServerUpdateReadyEvent( * Keeps reconnect attempts ~1s apart for the whole update restart. * * A restart takes the server down for ~15 seconds, but the supervisor's normal - * backoff ladder (1/2/4/8/16s) assumes an unexpected failure and lands attempts - * at ~3, 5, 9, 17 and 33 seconds — so a 15-second restart is observed as a - * 33-second "Resuming". Nudging on every backoff entry (not just the first) + * backoff assumes an unexpected failure and doubles its delay after each failed + * attempt, so a 15-second restart can be observed as a ~30-second "Resuming". + * Nudging on every backoff entry (not just the first) * holds the retry cadence flat until the server answers again. The sleep before * each nudge is the pacer: a connection that fails instantly re-enters backoff * immediately and would otherwise spin a tight retry loop. From 736130c2fd39b85cd515fa2a41cd10c8a8a52630 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 21:55:57 -0700 Subject: [PATCH 15/82] fix(server): working timers no longer reset on every background wake (#15029) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/Orchestrator.ts | 39 ++++++ .../orchestration-v2/ProjectionStore.test.ts | 31 +++++ .../src/orchestration-v2/ProjectionStore.ts | 11 +- .../src/orchestration-v2/runtimeLayer.test.ts | 118 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 7 +- packages/client-runtime/src/state/models.ts | 5 +- .../src/state/threadExecution.test.ts | 13 ++ .../src/state/threadExecution.ts | 10 +- packages/contracts/src/orchestrationV2.ts | 22 +++- 9 files changed, 245 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c58dd46609ec..c52087778d63 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -39,6 +39,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, + orchestrationV2RunWorkStartedAt, ProviderInstanceId, type ProviderSessionId, RunId, @@ -304,6 +305,37 @@ function nextRunOrdinal(projection: Pick, + trigger: { + readonly notification?: unknown; + readonly delegatedCompletion?: unknown; + readonly restartContinuationOfRunId?: RunId | undefined; + }, +): Pick { + if ( + trigger.notification === undefined && + trigger.delegatedCompletion === undefined && + trigger.restartContinuationOfRunId === undefined + ) { + return {}; + } + const previous = runs + .flatMap((run) => (run.startedAt === null ? [] : [{ run, startedAt: run.startedAt }])) + .toSorted( + (left, right) => + DateTime.Order(right.startedAt, left.startedAt) || right.run.ordinal - left.run.ordinal, + )[0]?.run; + return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; +} + function isNativeMaintenanceCommand(message: { readonly text: string; readonly attachments: ReadonlyArray; @@ -1459,6 +1491,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio queuePosition: null, startedAt: null, contextHandoffId: activeHandoff?.id ?? null, + ...wakeWorkStartedAt(projection.runs, { + notification: queuedMessage.notification, + delegatedCompletion: queuedMessage.delegatedCompletion, + restartContinuationOfRunId: queuedRun.restartContinuationOfRunId, + }), }; const userTurnItem: OrchestrationV2TurnItem = { ...(legacyQueuedTurnItem ?? { @@ -4949,6 +4986,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -5642,6 +5680,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index f3b6348c5d31..c64fece9db07 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1982,6 +1982,37 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { assert.isNull(onlyHeldShell.latestRunId); assert.equal(onlyHeldShell.status, "idle"); } + + // A wake run counts from the start of the work it continues. + yield* projectionStore.apply({ + id: EventId.make("event:projection-shell-interruptible:wake"), + type: "run.updated", + threadId, + runId, + nodeId: rootNodeId, + driver, + occurredAt: later, + payload: { + ...run, + status: "running", + requestedAt: later, + startedAt: later, + workStartedAt: now, + }, + }); + const wakeProjection = yield* projectionStore.getThreadProjection(threadId); + const wakeSqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (row) => row.id === threadId, + )!; + for (const wakeShell of [ + wakeSqlShell, + ProjectionStore.threadShellFromProjection(wakeProjection), + ]) { + assert.equal( + wakeShell.activityRunStartedAt && DateTime.toEpochMillis(wakeShell.activityRunStartedAt), + DateTime.toEpochMillis(now), + ); + } }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 6718240d9e7a..b662a528b4de 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -48,6 +48,7 @@ import { OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema, OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema, OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema, + orchestrationV2RunWorkStartedAt, RunId, CheckpointScopeId, ThreadId, @@ -1368,7 +1369,8 @@ export function threadShellFromProjection( latestRunCompletedAt: latestRun?.completedAt ?? null, activeRunId: activeRun?.id ?? null, activityRunStatus: activityRun?.status ?? null, - activityRunStartedAt: activityRun?.startedAt ?? activityRun?.requestedAt ?? null, + activityRunStartedAt: + activityRun === null ? null : orchestrationV2RunWorkStartedAt(activityRun), status: latestRun?.status ?? "idle", ...threadErrorSummary( latestRootProviderFailure(latestRun, projection.turnItems), @@ -4782,7 +4784,12 @@ export const layer: Layer.Layer = LIMIT 1 ) AS activity_run_status, ( - SELECT COALESCE(json_extract(r.payload_json, '$.startedAt'), r.requested_at) + -- Mirrors orchestrationV2RunWorkStartedAt. + SELECT COALESCE( + json_extract(r.payload_json, '$.workStartedAt'), + json_extract(r.payload_json, '$.startedAt'), + r.requested_at + ) FROM orchestration_v2_projection_runs r WHERE r.thread_id = t.thread_id AND r.status IN ('preparing', 'starting', 'running', 'waiting') diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7f66cebd27d1..d077f7e20910 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2800,6 +2800,124 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("starts a wake's work clock from the run that ran before it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-wake-work-start-thread"); + const messageId = (key: string) => MessageId.make(`runtime-layer-wake-work-start-${key}`); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-wake-work-start-create"), + threadId, + projectId: ProjectId.make("runtime-layer-wake-work-start-project"), + title: "Wake work start", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const dispatch = (key: string, wake: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: wake ? "agent" : "user", + creationSource: wake ? "provider" : "web", + ...(wake + ? { + notification: { + source: { kind: "background_task" as const }, + outcome: "updated" as const, + summary: "Background activity updated", + }, + } + : {}), + commandId: CommandId.make(`runtime-layer-wake-work-start-${key}`), + threadId, + messageId: messageId(key), + text: key, + attachments: [], + modelSelection, + dispatchMode: + key === "prompt" ? { type: "start_immediately" } : { type: "queue_after_active" }, + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => { + const run = runs.find((candidate) => candidate.userMessageId === messageId(key)); + assert.isDefined(run); + return run; + }); + yield* dispatch("prompt", false); + yield* dispatch("queued", false); + yield* dispatch("early-wake", true); + // The early wake now runs ahead of the older queued prompt, as a + // delegated result does when it jumps the queue. + yield* orchestrator.dispatch({ + type: "queued-run.reorder", + commandId: CommandId.make("runtime-layer-wake-work-start-reorder"), + threadId, + runId: (yield* runFor("queued")).id, + beforeRunId: null, + }); + yield* dispatch("late-wake", true); + // A queued wake has no clock yet: what runs before it is still unknown. + assert.isUndefined((yield* runFor("early-wake")).workStartedAt); + + const startedRunIds = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting" + ? Queue.offer(startedRunIds, stored.event.payload.id) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + + const now = yield* DateTime.now; + // Runs start and settle the way the provider would report them. + const settle = (key: string, startedAt: DateTime.Utc) => + Effect.gen(function* () { + const run = yield* runFor(key); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`runtime-layer-wake-work-start-${key}-completed`), + type: "run.updated", + threadId, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: startedAt, + payload: { ...run, status: "completed", startedAt, completedAt: startedAt }, + }, + ], + }); + }); + const millis = (value: DateTime.Utc | undefined) => + value === undefined ? undefined : DateTime.toEpochMillis(value); + + yield* settle("prompt", now); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("early-wake")).id); + assert.equal(millis((yield* runFor("early-wake")).workStartedAt), millis(now)); + + yield* settle("early-wake", DateTime.add(now, { seconds: 1 })); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("queued")).id); + assert.isUndefined((yield* runFor("queued")).workStartedAt); + + // The queued prompt starts long after it was requested; the wake after it + // counts from that start, not from the request. + const queuedStartedAt = DateTime.add(now, { minutes: 10 }); + yield* settle("queued", queuedStartedAt); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("late-wake")).id); + assert.equal(millis((yield* runFor("late-wake")).workStartedAt), millis(queuedStartedAt)); + }), + ); + it.effect.each(["usage_limit", "provider_error"] as const)( "handles a queued message after a %s failure", (failureClass) => diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ad2c9a1fedc8..4f504e286d0c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1147,10 +1147,9 @@ export function sortInboxThreadsByReturn< ); } -/** The timestamp a working thread's elapsed label counts from: the running - turn's start (request time until adoption), falling back to the session's - last transition when the turn projection lags behind. Malformed - timestamps fall through to the next candidate, not just missing ones. */ +/** The timestamp a working thread's elapsed label counts from: when its + current work started (request time until adoption). Background wakes do + not reset it. Malformed timestamps fall through to the next candidate. */ export function resolveWorkingStartedAt( thread: Pick, ): string | null { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 7163bd79a820..37b7b5b8e303 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -296,7 +296,10 @@ export function resolveThreadProviderStack( return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current]; } -/** Both shell and detail timers use the activity-owning run, never last activity. */ +/** + * Both shell and detail timers count from the activity-owning run's work + * start, never last activity. A wake keeps the start of the work it continues. + */ export function resolveThreadWorkingStartedAt(input: { readonly latestRun: Pick< ThreadRunSummary, diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 7ebd468400f8..7a94233e34e9 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -96,6 +96,19 @@ describe("thread execution presentation", () => { ).toMatchObject({ status: "running", lastError: null, lastErrorClass: null }); }); + it("counts a wake run's activity from the start of the work it continues", () => { + const workStartedAt = DateTime.makeUnsafe("2026-07-28T09:20:00.000Z"); + const wake = { ...run("wake", 2, "running"), workStartedAt }; + expect( + deriveThreadRuntime({ ...v2Projection, runs: [run("prompt", 1, "completed"), wake] }) + ?.activityStartedAt, + ).toBe("2026-07-28T09:20:00.000Z"); + expect( + deriveThreadRuntime({ ...v2Projection, runs: [run("prompt", 1, "running")] }) + ?.activityStartedAt, + ).toBe("2026-07-28T10:00:00.000Z"); + }); + it("keeps a subscription limit visible while later messages stay queued", () => { const failed = { ...run("limited", 1, "failed"), diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 5101acc062ef..707295891119 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -13,6 +13,7 @@ import { type ServerProviderModel, type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, + orchestrationV2RunWorkStartedAt, type ThreadId, } from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; @@ -213,6 +214,9 @@ export function deriveThreadRuntime( const usageLimitedRun = presentedUsageLimitRun(projection); const latestRunProjection = presentedLatestRun(projection); const activityRun = deriveThreadActivityRun(projection); + const liveActivityRun = latestMatchingRun(projection, (run) => + ACTIVITY_RUN_STATUSES.has(run.status), + ); if (latestRun === null && projection.thread.activeProviderThreadId === null) return null; const activeRunId = latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; @@ -232,9 +236,9 @@ export function deriveThreadRuntime( : (activityRun?.status ?? "idle"), activeRunId, activityStartedAt: - activityRun !== null && ACTIVITY_RUN_STATUSES.has(activityRun.status) - ? (activityRun.startedAt ?? activityRun.requestedAt) - : null, + liveActivityRun === null + ? null + : DateTime.formatIso(orchestrationV2RunWorkStartedAt(liveActivityRun)), providerInstanceId: projection.thread.providerInstanceId, providerName: providerSession?.driver ?? null, ...threadErrorSummary( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index cb220a5b4ccd..9d907c81785d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -528,6 +528,12 @@ export const OrchestrationV2Run = Schema.Struct({ contextHandoffId: Schema.NullOr(ContextHandoffId), /** Links server-generated restart continuations to the interrupted run. */ restartContinuationOfRunId: Schema.optional(RunId), + /** + * Set on wake runs (background notifications, delegated task results, + * restart continuations): when the work they continue started. Read it + * through orchestrationV2RunWorkStartedAt. + */ + workStartedAt: Schema.optional(Schema.DateTimeUtc), /** * Set by restart recovery on the thread's latest started run. Delivered to * the provider with the first later run that reaches a provider turn. @@ -545,6 +551,16 @@ export const OrchestrationV2Run = Schema.Struct({ }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; +/** + * When the work a run belongs to started. A wake does not start new work, so + * working timers count from the prompt that did, not from the latest wake. + */ +export function orchestrationV2RunWorkStartedAt( + run: Pick, +): OrchestrationV2Run["requestedAt"] { + return run.workStartedAt ?? run.startedAt ?? run.requestedAt; +} + export const OrchestrationV2RunAttempt = Schema.Struct({ id: RunAttemptId, // Provider-thread rows can be reused after recovery; retain the native input destination. @@ -1688,7 +1704,10 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunCompletedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activeRunId: Schema.NullOr(RunId), - /** Start of the activity-owning run; request time while it is preparing. */ + /** + * orchestrationV2RunWorkStartedAt of the activity-owning run: a wake keeps + * the start of the work it continues; request time while preparing. + */ activityRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activityRunStatus: Schema.optional( Schema.NullOr(Schema.Literals(["preparing", "starting", "running", "waiting"])), @@ -1841,6 +1860,7 @@ export const OrchestrationV2RunJson = OrchestrationV2Run.mapFields((fields) => ( requestedAt: Schema.DateTimeUtcFromString, startedAt: Schema.NullOr(Schema.DateTimeUtcFromString), completedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + workStartedAt: Schema.optional(Schema.DateTimeUtcFromString), })); export type OrchestrationV2RunJson = typeof OrchestrationV2RunJson.Type; From ca4f84e702428656665d53635e16251112091657 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:02:59 -0700 Subject: [PATCH 16/82] fix(server): threads settle as soon as an agent merges their PR (#15024) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ProjectionSettlement.test.ts | 4 + .../src/orchestration-v2/ProjectionStore.ts | 19 ++- .../PullRequestSyncReactor.test.ts | 137 +++++++++++++++++- .../PullRequestSyncReactor.ts | 54 ++++++- docs/internals/overview.md | 7 +- 5 files changed, 202 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index eec1ad1c3f08..60149fe8a292 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -335,6 +335,10 @@ it.effect.each([ [open, null, 2], ], ); + assert.deepEqual( + (yield* store.getThreadsWithPullRequests(open)).map((thread) => thread.id), + [open], + ); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index b662a528b4de..ac0624b05806 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -349,12 +349,12 @@ export interface ProjectionStoreV2Shape { ) => Effect.Effect, ProjectionStoreV2Error>; /** * Active (not deleted, not archived) threads with at least one pull request - * link, in shell snapshot order. Skips run, message and item reads. + * link, in shell snapshot order, or only `threadId` when given. Skips run, + * message and item reads. */ - readonly getThreadsWithPullRequests: () => Effect.Effect< - ReadonlyArray, - ProjectionStoreV2Error - >; + readonly getThreadsWithPullRequests: ( + threadId?: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getTurnStartContext: ( threadId: ThreadId, runId: RunId, @@ -5133,12 +5133,14 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); - const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = () => + const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = ( + threadId, + ) => Effect.gen(function* () { const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads - WHERE deleted_at IS NULL + WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} AND json_extract(payload_json, '$.archivedAt') IS NULL AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC @@ -5583,13 +5585,14 @@ export const layerMemory: Layer.Layer = Layer.effect( left.id.localeCompare(right.id), ); }), - getThreadsWithPullRequests: () => + getThreadsWithPullRequests: (threadId) => Ref.get(replayState).pipe( Effect.map((state) => [...state.projections.values()] .map(({ thread }) => thread) .filter( (thread) => + (threadId === undefined || thread.id === threadId) && thread.deletedAt === null && thread.archivedAt === null && (thread.pullRequests ?? []).length > 0, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 7871b3ed6ee3..6febdb9bbce3 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,10 +1,16 @@ import * as Stream from "effect/Stream"; import { + EventId, + MessageId, ProjectId, ProviderInstanceId, PullRequestOperationError, + RunId, ThreadId, + TurnItemId, type OrchestrationV2Command as OrchestrationCommand, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, type OrchestrationProjectShell, type PullRequestRef, type PullRequestStack, @@ -171,6 +177,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const linkCommands = yield* Ref.make>([]); const summaryCalls = yield* Ref.make>([]); const stackCalls = yield* Ref.make>([]); + const domainEvents = yield* Queue.unbounded(); const summary: PullRequestService.PullRequestService["Service"]["summary"] = ( input, @@ -211,12 +218,17 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ // Mirrors the store's filter: active threads that have at least one link. - getThreadsWithPullRequests: () => + getThreadsWithPullRequests: (threadId) => Queue.offer(snapshotReads, undefined).pipe( Effect.andThen(Ref.get(snapshots)), Effect.map((snapshot) => snapshot.threads - .filter((thread) => thread.archivedAt === null && thread.pullRequests.length > 0) + .filter( + (thread) => + (threadId === undefined || thread.id === threadId) && + thread.archivedAt === null && + thread.pullRequests.length > 0, + ) .map((thread) => ({ id: thread.id, projectId: thread.projectId, @@ -233,7 +245,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: Effect.andThen(Effect.die(new Error("pull request sync must not read the shell"))), ), dispatch, - streamDomainEvents: Stream.empty, + streamDomainEvents: Stream.fromQueue(domainEvents), }), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), @@ -248,6 +260,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: linkCommands, summaryCalls, stackCalls, + domainEvents, layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), }; }); @@ -272,6 +285,68 @@ const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( yield* reactor.drain; }); +function runUpdated( + threadId: ThreadId, + status: OrchestrationV2Run["status"], +): OrchestrationV2DomainEvent { + const runId = RunId.make(`run:${threadId}:1`); + const at = DateTime.makeUnsafe(NOW); + return { + type: "run.updated", + id: EventId.make(`event:${threadId}:${status}`), + threadId, + runId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + providerThreadId: null, + userMessageId: MessageId.make(`message:${threadId}:1`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }; +} + +function commandRan(threadId: ThreadId, input: string): OrchestrationV2DomainEvent { + const runId = RunId.make(`run:${threadId}:1`); + const at = DateTime.makeUnsafe(NOW); + return { + type: "turn-item.updated", + id: EventId.make(`event:${threadId}:command`), + threadId, + runId, + occurredAt: at, + payload: { + id: TurnItemId.make(`item:${threadId}:command`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "command_execution", + input, + }, + }; +} + /** What the reactor would have persisted, so the next sweep sees its own writes. */ function applySync( snapshot: TestShellSnapshot, @@ -832,4 +907,60 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("reads open links fresh only when a run that ran a merge command ends", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const invalidated = yield* Ref.make>([]); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + makeThread("other", { pullRequests: [makeLink(9, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + invalidate: ({ reference }) => + Ref.update(invalidated, (numbers) => [...numbers, reference?.number ?? -1]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + // The agent merges from a shell during its turn, inside the summary cache window. + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.summaryCalls, []); + + yield* Queue.offerAll(fixture.domainEvents, [ + // A run that only reads its pull request costs no host read when it ends. + commandRan(ThreadId.make("other"), "gh pr view 9"), + runUpdated(ThreadId.make("other"), "completed"), + commandRan(ThreadId.make("agent"), "gh pr merge 7 --squash 2>&1 | tail -3"), + runUpdated(ThreadId.make("agent"), "completed"), + ]); + // The agent thread's lookup, then the requested sweep. + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual(yield* Ref.get(invalidated), [7]); + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), + [7], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 670d401004b6..1b526d8f532e 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -2,6 +2,7 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type PullRequestSummary, + type ThreadId, type ThreadPullRequestKey, type ThreadPullRequestLink, type ThreadPullRequestSnapshot, @@ -29,8 +30,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { isTerminalRunStatus } from "./ThreadManagementService.ts"; const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; +/** Shell commands that can merge or close a pull request without a merge notification. */ +const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; type SnapshotFields = Omit; @@ -330,22 +334,60 @@ export const make = Effect.gen(function* () { }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); + // Threads whose current run ran a merge or close command, until that run ends. + const closeCommandThreads = new Set(); + const refreshOpenLinks = (threadId: ThreadId) => + projections.getThreadsWithPullRequests(threadId).pipe( + Effect.flatMap((threads) => + Effect.forEach( + threads.flatMap((thread) => + visibleThreadPullRequests(thread.pullRequests ?? []).filter( + (link) => link.snapshot?.state === "open", + ), + ), + requestSync, + { discard: true }, + ), + ), + Effect.catchCause(logSkipped("pull request refresh after run skipped", { threadId })), + ); + const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( "PullRequestSyncReactor.start", )(function* () { const events = engine.streamDomainEvents; yield* forkParked( - Stream.runForEach(events, (event) => - event.type === "thread.pull-request-synced" - ? Effect.forEach( + Stream.runForEach(events, (event) => { + switch (event.type) { + case "thread.pull-request-synced": + return Effect.forEach( visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( (link) => link.snapshot === null, ), requestSync, { discard: true }, - ) - : Effect.void, - ).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), + ); + // An agent can merge or close its pull request from a shell (`gh pr merge`), which + // sends no merge notification. When a run that ran such a command ends, read the + // thread's open links fresh, so settlement does not wait for the next sweep and the + // cached summary. Other runs add no host reads. + case "turn-item.updated": + if ( + event.payload.type === "command_execution" && + PULL_REQUEST_CLOSE_COMMAND.test(event.payload.input) + ) { + closeCommandThreads.add(event.threadId); + } + return Effect.void; + case "run.updated": + return isTerminalRunStatus(event.payload.status) && + closeCommandThreads.delete(event.threadId) + ? refreshOpenLinks(event.threadId) + : Effect.void; + default: + return Effect.void; + } + }).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), ); yield* forkParked( Effect.gen(function* () { diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 3d3218a7ca3d..149bcf57dff0 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -87,8 +87,11 @@ must reject that operation before changing the filesystem. Thread settlement is server-owned. The [settlement service](../../apps/server/src/orchestration-v2/ThreadSettlementService.ts) evaluates PR and inactivity settings without a connected client. Merge notifications invalidate cached PR state -and trigger a check. The guarded `thread.auto-settle` command rejects newer activity, explicit -settlement overrides, and live or blocked work. It records the activity timestamp for stable +and trigger a check. A merge outside T3, such as an agent running `gh pr merge`, sends no +notification, so the [PR sync reactor](../../apps/server/src/orchestration-v2/PullRequestSyncReactor.ts) +re-reads a thread's open links when a run that ran a merge or close command ends. The guarded +`thread.auto-settle` command rejects newer activity, explicit settlement overrides, and live or +blocked work. It records the activity timestamp for stable sorting and detaches idle provider sessions. Clients render the persisted result; they do not derive settlement from their own clocks or PR caches. From 63f74e209dc27df1f8733535d6fd4a79d442add8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 3 Oct 2026 00:05:53 -0500 Subject: [PATCH 17/82] fix(opencode): fill blank reasoning labels (#14978) --- .../src/features/threads/ThreadComposer.tsx | 3 + .../features/threads/ThreadDetailScreen.tsx | 7 ++ .../features/threads/ThreadSettingsSheet.tsx | 21 +++++- apps/mobile/src/state/entities.ts | 11 ++- .../Adapters/OpenCode2AdapterV2.test.ts | 50 +++++++++++++ .../Adapters/OpenCode2AdapterV2.ts | 51 +++++++++++-- apps/web/src/components/ChatView.tsx | 11 ++- apps/web/src/components/chat/ChatComposer.tsx | 4 + .../src/components/chat/TraitsPicker.test.ts | 37 +++++++++- apps/web/src/components/chat/TraitsPicker.tsx | 29 +++++++- .../components/chat/composerProviderState.tsx | 4 + .../src/state/threadExecution.test.ts | 73 +++++++++++++++++++ .../src/state/threadExecution.ts | 15 +++- packages/contracts/src/orchestrationV2.ts | 2 + packages/shared/src/model.test.ts | 61 ++++++++++++++++ packages/shared/src/model.ts | 39 ++++++++-- 16 files changed, 397 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a0eaaaf85548..16b09e7c1b1a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -150,6 +150,7 @@ export interface ThreadComposerProps { */ readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: EnvironmentThreadShell; + readonly reportedModelSelection?: ModelSelection | null; readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -667,6 +668,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer providerInstanceId: currentModelSelection.instanceId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, + reportedModelSelection: props.reportedModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), optionDescriptors: providerOptionDescriptors, @@ -683,6 +685,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [ currentModelSelection, + props.reportedModelSelection, currentRuntimeMode, props.onUpdateModelSelection, props.onUpdateRuntimeMode, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 19dea22e9e66..623ae734053d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = { export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const navigation = useNavigation(); + const reportedModelSelection = useThreadReportedModelSelection({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const deviceState = useEnvironmentQuery( deviceEnvironment.state({ environmentId: props.environmentId, input: {} }), ); @@ -1277,6 +1282,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread effortLabel={formatModelSelectionEffort( props.selectedThread.modelSelection, providerSubagentProvider?.models, + reportedModelSelection, )} status={props.providerSubagentStatus ?? null} onOpenParent={ @@ -1293,6 +1299,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : ( <> ; readonly selectedModel: ModelSelection | null; + readonly reportedModelSelection?: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; readonly optionDescriptors: ReadonlyArray; readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; @@ -304,6 +305,8 @@ type ThreadSettingsSessionValue = { readonly runtimeModeChoices: ReturnType; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; + readonly displayedModelSelection: ModelSelection | null; + readonly reportedModelSelection: ModelSelection | null; readonly providerExpansionOverrides: ReadonlySet; readonly hasLegacyModels: boolean; readonly pendingModel: ModelOption | null; @@ -476,6 +479,8 @@ function ThreadSettingsSessionProvider( runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, + displayedModelSelection: pendingModel?.selection ?? props.selectedModel, + reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), favoriteKeys, favoritesLoaded, providerExpansionOverrides, @@ -507,6 +512,8 @@ function ThreadSettingsSessionProvider( isApplied, isDisplayed, props.environmentId, + props.selectedModel, + props.reportedModelSelection, props.providerInstanceId, pendingModel, pressModel, @@ -743,7 +750,11 @@ function ThreadSettingsOptionsItem(props: { > props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} /> @@ -997,7 +1008,13 @@ function ThreadSettingsChoiceContent(props: { id: choice.id, label: choice.label, description: undefined, - selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), + selected: + choice.id === + getProviderOptionCurrentValue( + activeDescriptor, + session.displayedModelSelection, + session.reportedModelSelection, + ), onPress: () => { void Haptics.selectionAsync(); session.applyOptionChange(activeDescriptor.id, choice.id); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 649545b1f927..3bc9066a399e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -1,8 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; +import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution"; import { appAtomRegistry } from "./atom-registry"; import type { EnvironmentProject, + EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { @@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; -import { environmentThreadShells } from "./threads"; +import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-project:empty"), @@ -87,3 +89,10 @@ export function useEnvironmentServerConfig( export function useServerConfigs(): ReadonlyMap { return useAtomValue(environmentServerConfigsAtom); } + +const selectReportedModelSelection = (thread: EnvironmentThread | null) => + thread === null ? null : deriveReportedModelSelection(thread.projection); + +export function useThreadReportedModelSelection(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadDetails.threadAtom(ref), selectReportedModelSelection); +} diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index b437ae9cf926..6df5f827fa76 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -410,6 +410,11 @@ describe("OpenCode2 adapter", () => { promptAccepted, event("session.execution.succeeded", { sessionID: SESSION }), ]); + assert.deepEqual(thread.nativeMetadata?.modelSelection, { + ...bigPickle, + options: [{ id: "variant", value: "default" }], + }); + assert.equal(bigPickle.options, undefined); const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); yield* runtime.startTurn( turnInput(thread, { @@ -2833,6 +2838,10 @@ describe("OpenCode2 adapter", () => { modelSelection: bigPickle, runtimePolicy: customPolicy, }); + assert.deepEqual(thread.nativeMetadata?.modelSelection, { + ...bigPickle, + options: [{ id: "variant", value: "default" }], + }); // Each directory keeps its own limit, whichever was read last. assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000); assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000); @@ -3869,3 +3878,44 @@ const providerTurnId = Effect.gen(function* () { nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, }); }).pipe(Effect.provide(IdAllocator.layer)); + +describe("OpenCode reported model variants", () => { + it.effect( + "updates reported variants from selected-model and step events without duplicate updates", + () => + Effect.gen(function* () { + const model = { providerID: "opencode", id: "big-pickle", variant: "thinking" }; + const step = { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + agent: "build", + model, + started: 1, + }; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.model.selected", { sessionID: SESSION, model }), + event("session.step.started", step), + event("session.step.started", { ...step, model: { ...model, variant: "none" } }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const collected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const updates = (yield* Fiber.join(collected)).filter( + (event) => event.type === "provider_thread.updated", + ); + assert.deepEqual( + updates.map( + (event) => event.providerThread.nativeMetadata?.modelSelection?.options?.[0]?.value, + ), + ["default", "thinking", "none", "none"], + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index c972e060ad91..d1e81eb725c4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -78,7 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; @@ -1385,6 +1385,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); + const withReportedModel = ( + providerThread: OrchestrationV2ProviderThread, + model: ModelRef | undefined, + ): OrchestrationV2ProviderThread => { + if (model === undefined) return providerThread; + const modelSelection: ModelSelection = { + instanceId, + model: `${model.providerID}/${model.id}`, + options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }], + }; + if ( + providerThread.nativeMetadata?.modelSelection !== undefined && + modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection) + ) + return providerThread; + return { + ...providerThread, + nativeMetadata: { ...providerThread.nativeMetadata, modelSelection }, + }; + }; + /** * Gives a subagent call its session once both are known: OpenCode names * the session on the call's progress, and announces it just before. The @@ -1463,7 +1484,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const child = previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); child.subagent = subagent; - child.providerThread = providerThread; + child.model = info?.model ?? child.model; + child.providerThread = withReportedModel(providerThread, child.model); child.directory = call.state.directory; child.agent = info?.agent ?? call.agent ?? child.agent; // OpenCode gives a new session its parent's rules, which are the thread's. @@ -1472,7 +1494,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr childOwners.set(childId, rootOf(call.state)); call.child = child; yield* emit({ type: "app_thread.created", driver, appThread }); - yield* emit({ type: "provider_thread.updated", driver, providerThread }); + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: child.providerThread, + }); yield* emitSubagent(call); // A session called again was not made now, so it may hold the rules of // a mode the thread has left. OpenCode applies a rules change to the @@ -2520,6 +2546,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (sessionId === undefined) return; const state = threads.get(sessionId); if (state === undefined) return; + if (event.type === "session.model.selected" || event.type === "session.step.started") { + if (event.type === "session.model.selected") state.model = event.data.model; + const providerThread = withReportedModel(state.providerThread, event.data.model); + if (providerThread !== state.providerThread) { + state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + } + } if ( event.type === "session.inbox.enqueued" && state.subagent !== undefined && @@ -2962,6 +3000,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, directory: string, ) => { + providerThread = withReportedModel(providerThread, native.model); const existing = threads.get(native.id); if (existing !== undefined) { existing.providerThread = providerThread; @@ -3582,7 +3621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr directory, ); state.policy = policy; - return providerThread; + return state.providerThread; }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -3625,7 +3664,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr directory: AbsolutePath.make(cwd), }); } - return providerThread; + return state.providerThread; }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -4183,7 +4222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr directory: AbsolutePath.make(cwd), }); } - return providerThread; + return state.providerThread; }).pipe( exclusive(forkInput.sourceProviderThread), Effect.mapError((cause) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e2037c2a0645..6f01da357d5f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -88,6 +88,7 @@ import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/th import { useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, + deriveReportedModelSelection, formatModelSelectionEffort, deriveRunlessWorkStartedAt, deriveThreadActivityRun, @@ -1626,6 +1627,9 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; + const reportedModelSelection = serverProjection + ? deriveReportedModelSelection(serverProjection) + : null; const threadStatus = useThreadStatus(routeThreadDetailRef); const threadSyncPhase = resolveThreadSyncPhase({ detailExists: serverProjection !== null, @@ -4073,7 +4077,11 @@ export default function ChatView(props: ChatViewProps) { const providerSubagentEffortLabel = activeThread === undefined ? null - : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels); + : formatModelSelectionEffort( + activeThread.modelSelection, + providerSubagentModels, + reportedModelSelection, + ); const mountComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, @@ -10818,6 +10826,7 @@ export default function ChatView(props: ChatViewProps) { ) : null} {!composerMounted ? null : ( { ]); }); }); + +it("shows Unknown until a matching provider report provides Default", () => { + const selection = { + instanceId: ProviderInstanceId.make("opencode"), + model: "ling", + options: [], + }; + const input = { + provider: ProviderDriverKind.make("opencode"), + descriptors: [ + selectDescriptor( + "variant", + [ + { id: "none", label: "None" }, + { id: "thinking", label: "Thinking" }, + ], + "", + ), + ], + primarySelectDescriptorId: "variant", + ultrathinkPromptControlled: false, + modelSelection: selection, + }; + expect(buildTraitsTriggerDisplay(input).label).toBe("Unknown"); + expect( + buildTraitsTriggerDisplay({ + ...input, + reportedModelSelection: { ...selection, options: [{ id: "variant", value: "default" }] }, + }).label, + ).toBe("Default"); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 0dbfc3ad531d..2debe0ba0c70 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,4 +1,5 @@ import { + type ModelSelection, type ProviderDriverKind, type ProviderInstanceId, type ProviderOptionDescriptor, @@ -122,11 +123,13 @@ function replaceDescriptorCurrentValue( function getDescriptorStringValue( descriptor: Extract | null, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, ): string | null { if (!descriptor) { return null; } - const value = getProviderOptionCurrentValue(descriptor); + const value = getProviderOptionCurrentValue(descriptor, selection, reportedSelection); return typeof value === "string" ? value : null; } @@ -274,6 +277,7 @@ export interface TraitsMenuContentProps { prompt: string; onPromptChange: (prompt: string) => void; modelOptions?: ProviderOptions | null | undefined; + reportedModelSelection?: ModelSelection | null | undefined; allowPromptInjectedEffort?: boolean; planModeEnabled: boolean; triggerClassName?: string; @@ -288,10 +292,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ prompt, onPromptChange, modelOptions, + reportedModelSelection, allowPromptInjectedEffort = true, planModeEnabled, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { + const modelSelection = + instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const updateModelOptions = useCallback( (nextOptions: ProviderOptions | undefined) => { @@ -362,7 +369,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> {descriptors.map((descriptor, index) => { - const value = getProviderOptionCurrentLabel(descriptor); + const value = getProviderOptionCurrentLabel( + descriptor, + modelSelection, + reportedModelSelection, + ); if (!value) return null; return (
@@ -386,7 +397,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ const selectedValue = ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id ? "ultrathink" - : (getDescriptorStringValue(descriptor) ?? ""); + : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""); return (
@@ -483,6 +494,8 @@ export function buildTraitsTriggerDisplay(input: { descriptors: ReadonlyArray; primarySelectDescriptorId: string | null; ultrathinkPromptControlled: boolean; + modelSelection?: ModelSelection | null; + reportedModelSelection?: ModelSelection | null | undefined; }): { label: string; speedIcon: "fast" | "ultrafast" | null } { let fastModeFallbackLabel: string | null = null; let speedIcon: "fast" | "ultrafast" | null = null; @@ -522,7 +535,11 @@ export function buildTraitsTriggerDisplay(input: { ? "Ultrathink" : descriptor.type === "boolean" ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` - : getProviderOptionCurrentLabel(descriptor); + : getProviderOptionCurrentLabel( + descriptor, + input.modelSelection, + input.reportedModelSelection, + ); if (typeof label === "string" && label.length > 0) { labels.push(label); } @@ -545,6 +562,7 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt, onPromptChange, modelOptions, + reportedModelSelection, allowPromptInjectedEffort = true, planModeEnabled, triggerClassName, @@ -588,6 +606,8 @@ export const TraitsPicker = memo(function TraitsPicker({ descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, + modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null, + reportedModelSelection, }); const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on"; const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel; @@ -686,6 +706,7 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt={prompt} onPromptChange={onPromptChange} modelOptions={modelOptions} + reportedModelSelection={reportedModelSelection} allowPromptInjectedEffort={allowPromptInjectedEffort} planModeEnabled={planModeEnabled} {...persistence} diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1d53d498dbd8..4d887587e935 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -1,5 +1,6 @@ import { type ModelCapabilities, + type ModelSelection, type ProviderDriverKind, type ProviderInstanceId, type ProviderOptionSelection, @@ -48,6 +49,7 @@ type TraitsRenderInput = { model: string; models: ReadonlyArray; modelOptions: ReadonlyArray | undefined; + reportedModelSelection?: ModelSelection | null | undefined; prompt: string; onPromptChange: (prompt: string) => void; planModeEnabled: boolean; @@ -170,6 +172,7 @@ function renderTraitsControl( model, models, modelOptions, + reportedModelSelection, prompt, onPromptChange, planModeEnabled, @@ -208,6 +211,7 @@ function renderTraitsControl( {...(draftId ? { draftId } : {})} model={model} modelOptions={resolvedModelOptions} + reportedModelSelection={reportedModelSelection} prompt={prompt} onPromptChange={onPromptChange} planModeEnabled={planModeEnabled} diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 7a94233e34e9..92745606d1ea 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -3,6 +3,9 @@ import { NodeId, MessageId, ProviderInstanceId, + ProviderThreadId, + ProviderSessionId, + ProviderDriverKind, RunId, ThreadId, type OrchestrationV2ExecutionNode, @@ -15,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { presentPendingBackgroundWork, + deriveReportedModelSelection, deriveLatestThreadRun, deriveProviderSubagentStatus, formatModelSelectionEffort, @@ -578,3 +582,72 @@ describe("presentPendingBackgroundWork", () => { ); }); }); + +describe("provider-reported model selection", () => { + const selected = v2Projection.thread.modelSelection; + const reported = { ...selected, options: [{ id: "reasoningEffort", value: "default" }] }; + const providerThread = { + id: ProviderThreadId.make("active"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: selected.instanceId, + providerSessionId: ProviderSessionId.make("session"), + appThreadId: v2Projection.thread.id, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + nativeMetadata: { modelSelection: reported }, + }; + const projection = { + ...v2Projection, + thread: { ...v2Projection.thread, activeProviderThreadId: providerThread.id }, + providerThreads: [providerThread], + }; + + it("reads only the active provider thread's reported selection", () => { + expect(deriveReportedModelSelection(projection)).toBe(reported); + expect( + deriveReportedModelSelection({ + ...projection, + thread: { ...projection.thread, activeProviderThreadId: null }, + }), + ).toBeNull(); + expect( + deriveReportedModelSelection({ + ...projection, + providerThreads: [ + { ...providerThread, providerInstanceId: ProviderInstanceId.make("other") }, + ], + }), + ).toBeNull(); + }); + + it("shows the reported default in a subagent's effort label", () => { + const models = [ + { + slug: selected.model, + name: selected.model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, + ]; + const variantReport = { ...selected, options: [{ id: "variant", value: "default" }] }; + expect(formatModelSelectionEffort(selected, models, variantReport)).toBe("Default"); + expect(formatModelSelectionEffort(selected, models)).toBe("Unknown"); + }); +}); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 707295891119..962e508670eb 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -142,6 +142,18 @@ export function deriveProviderSubagentStatus( }; } +/** The observed selection belongs to the active provider thread, never a previous handoff. */ +export function deriveReportedModelSelection( + projection: OrchestrationV2ThreadProjection, +): ModelSelection | null { + const providerThread = projection.providerThreads.find( + (candidate) => + candidate.id === projection.thread.activeProviderThreadId && + candidate.providerInstanceId === projection.thread.modelSelection.instanceId, + ); + return providerThread?.nativeMetadata?.modelSelection ?? null; +} + // Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode). const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const; @@ -155,6 +167,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", " export function formatModelSelectionEffort( selection: ModelSelection, models: ReadonlyArray = [], + reportedSelection?: ModelSelection | null, ): string | null { const caps = models.find((model) => model.slug === selection.model)?.capabilities; if (!caps) return null; @@ -162,7 +175,7 @@ export function formatModelSelectionEffort( for (const id of REASONING_EFFORT_OPTION_IDS) { const descriptor = descriptors.find((candidate) => candidate.id === id); if (descriptor?.type !== "select") continue; - const label = getProviderOptionCurrentLabel(descriptor); + const label = getProviderOptionCurrentLabel(descriptor, selection, reportedSelection); if (label) return label; } return null; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 9d907c81785d..1820008b77dd 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -796,6 +796,8 @@ export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2Pending /** Provider and adapter metadata that should not overwrite the app thread's title. */ export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({ + /** Provider-reported selection for display, separate from the app's saved preferences. */ + modelSelection: Schema.optional(ModelSelection), title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Version 2 scopes provider-derived item ids by provider instance. */ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index c0c60620dc2a..32691d242e6e 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -9,6 +9,7 @@ import { createModelSelection, formatCodexModelName, formatModelSlugName, + getProviderOptionCurrentLabel, getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, @@ -302,3 +303,63 @@ describe("readCustomModelEntries", () => { }); }); }); + +describe("provider-reported option display", () => { + const selection = createModelSelection(ProviderInstanceId.make("opencode"), "ling"); + const reported = { ...selection, options: [{ id: "variant", value: "default" }] }; + const descriptor = { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "none", label: "None" }, + { id: "thinking", label: "Thinking" }, + ], + }; + + it("shows explicit reports without adding a choice or a dispatch option", () => { + expect(getProviderOptionCurrentLabel(descriptor, selection, reported)).toBe("Default"); + expect( + getProviderOptionCurrentLabel(descriptor, selection, { + ...reported, + options: [{ id: "variant", value: "thinking" }], + }), + ).toBe("Thinking"); + expect( + getProviderOptionCurrentLabel( + { ...descriptor, currentValue: "none" }, + { ...selection, options: [{ id: "variant", value: "none" }] }, + reported, + ), + ).toBe("None"); + expect(descriptor.options.map((option) => option.id)).toEqual(["none", "thinking"]); + expect(buildProviderOptionSelectionsFromDescriptors([descriptor])).toBeUndefined(); + expect(getProviderOptionCurrentLabel(descriptor, selection)).toBe("Unknown"); + const effortDescriptor = { ...descriptor, id: "effort", currentValue: "default" }; + expect(getProviderOptionCurrentLabel(effortDescriptor, selection)).toBeUndefined(); + expect( + getProviderOptionCurrentLabel(effortDescriptor, selection, { + ...reported, + model: "other", + options: [{ id: "effort", value: "default" }], + }), + ).toBeUndefined(); + expect( + getProviderOptionCurrentLabel(effortDescriptor, selection, { + ...reported, + options: [{ id: "effort", value: "default" }], + }), + ).toBe("Default"); + expect( + getProviderOptionCurrentLabel({ ...descriptor, currentValue: "thinking" }, selection), + ).toBe("Unknown"); + }); + + it.each([ + { ...selection, model: "other" }, + { ...selection, instanceId: ProviderInstanceId.make("other") }, + { ...selection, options: [{ id: "variant", value: "none" }] }, + ])("ignores reports after changing the model, instance, or option: %j", (selected) => { + expect(getProviderOptionCurrentLabel(descriptor, selected, reported)).toBe("Unknown"); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 58973046b107..0e05fb6926cf 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -202,12 +202,35 @@ export function getProviderOptionDescriptors(input: { ); } +function getReportedOptionValue( + id: string, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, +) { + if ( + !selection || + !reportedSelection || + selection.instanceId !== reportedSelection.instanceId || + selection.model !== reportedSelection.model || + selection.options?.some((option) => option.id === id) + ) + return undefined; + return getRawSelectionValueById(reportedSelection.options, id); +} + export function getProviderOptionCurrentValue( descriptor: ProviderOptionDescriptor | null | undefined, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, ): string | boolean | undefined { if (!descriptor) { return undefined; } + const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id); + // Reported values are display-only; callers that build dispatch options omit this context. + const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); + if (reportedValue !== undefined) return reportedValue; + if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined; if (descriptor.type === "boolean") { return descriptor.currentValue; } @@ -219,6 +242,8 @@ export function getProviderOptionCurrentValue( export function getProviderOptionCurrentLabel( descriptor: ProviderOptionDescriptor | null | undefined, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, ): string | undefined { if (!descriptor) { return undefined; @@ -230,11 +255,15 @@ export function getProviderOptionCurrentLabel( : "Off" : undefined; } - const currentValue = getProviderOptionCurrentValue(descriptor); - if (typeof currentValue !== "string") { - return undefined; - } - return descriptor.options.find((option) => option.id === currentValue)?.label; + const currentValue = getProviderOptionCurrentValue(descriptor, selection, reportedSelection); + return ( + descriptor.options.find((option) => option.id === currentValue)?.label ?? + (getReportedOptionValue(descriptor.id, selection, reportedSelection) === "default" + ? "Default" + : descriptor.id === "variant" + ? "Unknown" + : undefined) + ); } export function buildProviderOptionSelectionsFromDescriptors( From 798e945aa77939937464ff45deaa8947be8d402f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:10:21 -0700 Subject: [PATCH 18/82] fix(preview): agent browser clicks no longer steal your composer focus (#15008) Co-authored-by: Claude Opus 5.5 (1M context) --- .../preview/PreviewAutomationHosts.tsx | 9 +- .../preview/previewClickFocus.test.ts | 152 ++++++++++++++++++ .../components/preview/previewClickFocus.ts | 67 ++++++++ 3 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/preview/previewClickFocus.test.ts create mode 100644 apps/web/src/components/preview/previewClickFocus.ts diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 173a027e9df2..4a3f9b7c88ac 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -90,6 +90,7 @@ import { resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget"; +import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; import { isPreviewViewportReady } from "./previewViewportReadiness"; import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; @@ -686,9 +687,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "click": { const ready = await requireReadyTab(); - return await ready.bridge.automation.click( - ready.runtimeTabId, - request.input as Parameters[1], + return await runPreviewClickKeepingHostFocus(ready.runtimeTabId, () => + ready.bridge.automation.click( + ready.runtimeTabId, + request.input as Parameters[1], + ), ); } case "type": { diff --git a/apps/web/src/components/preview/previewClickFocus.test.ts b/apps/web/src/components/preview/previewClickFocus.test.ts new file mode 100644 index 000000000000..559872c2ace4 --- /dev/null +++ b/apps/web/src/components/preview/previewClickFocus.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; + +const TAB = "runtime-tab"; + +const mount = (tagName: string, previewTab?: string) => { + const element = document.createElement(tagName); + element.tabIndex = -1; + if (previewTab) element.setAttribute("data-preview-tab", previewTab); + document.body.append(element); + return element; +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("runPreviewClickKeepingHostFocus", () => { + it("gives focus back to the composer after the click focuses the page", async () => { + const composer = mount("textarea"); + const webview = mount("webview", TAB); + composer.focus(); + + const result = await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + return "clicked"; + }); + + expect(result).toBe("clicked"); + expect(document.activeElement).toBe(composer); + }); + + it("gives focus back when the user's typing interrupts the click", async () => { + const composer = mount("textarea"); + const webview = mount("webview", TAB); + composer.focus(); + const interrupted = new Error("PreviewAutomationControlInterruptedError"); + + await expect( + runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + throw interrupted; + }), + ).rejects.toBe(interrupted); + + expect(document.activeElement).toBe(composer); + }); + + it("gives focus back once overlapping clicks in two tabs finish", async () => { + const composer = mount("textarea"); + const first = mount("webview", TAB); + const second = mount("webview", "other-tab"); + composer.focus(); + let finishSecond = () => {}; + + const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => { + first.focus(); + }); + // Starts while the first click holds focus in its page, and finishes last. + const secondClick = runPreviewClickKeepingHostFocus("other-tab", async () => { + second.focus(); + await new Promise((resolve) => { + finishSecond = resolve; + }); + }); + await firstClick; + finishSecond(); + await secondClick; + + expect(document.activeElement).toBe(composer); + }); + + it("keeps the user's page when overlapping clicks finish in reverse order", async () => { + const first = mount("webview", TAB); + const second = mount("webview", "other-tab"); + second.focus(); + let finishFirst = () => {}; + + const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => { + first.focus(); + await new Promise((resolve) => { + finishFirst = resolve; + }); + }); + // Starts while the first click holds focus in its page, and finishes first. + await runPreviewClickKeepingHostFocus("other-tab", async () => { + second.focus(); + }); + finishFirst(); + await firstClick; + + expect(document.activeElement).toBe(second); + }); + + it("ignores a click that never finishes", async () => { + const composer = mount("textarea"); + const stuck = mount("webview", "stuck-tab"); + const webview = mount("webview", TAB); + const other = mount("webview", "other-tab"); + composer.focus(); + + // A click queued behind a page promise that never settles. + void runPreviewClickKeepingHostFocus("stuck-tab", () => new Promise(() => {})); + await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + }); + expect(document.activeElement).toBe(composer); + + // The user opens the stuck tab's page themselves. + stuck.focus(); + await runPreviewClickKeepingHostFocus("other-tab", async () => { + other.focus(); + }); + expect(document.activeElement).toBe(stuck); + }); + + it("leaves no focus in the page when the composer can no longer take focus", async () => { + const composer = mount("div"); + const webview = mount("webview", TAB); + composer.focus(); + + await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + // Like the editor going read-only for an approval request. + composer.removeAttribute("tabindex"); + }); + + expect(document.activeElement).toBe(document.body); + }); + + it("leaves no focus in the page when nothing in the app had focus", async () => { + const webview = mount("webview", TAB); + + await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + }); + + expect(document.activeElement).toBe(document.body); + }); + + it("keeps focus in the page when the user was already typing there", async () => { + const webview = mount("webview", TAB); + webview.focus(); + + await runPreviewClickKeepingHostFocus(TAB, async () => undefined); + + expect(document.activeElement).toBe(webview); + }); +}); diff --git a/apps/web/src/components/preview/previewClickFocus.ts b/apps/web/src/components/preview/previewClickFocus.ts new file mode 100644 index 000000000000..9009568a4712 --- /dev/null +++ b/apps/web/src/components/preview/previewClickFocus.ts @@ -0,0 +1,67 @@ +interface PreviewClick { + readonly runtimeTabId: string; + readonly order: number; + previous: Element | null; +} + +// Agent clicks can overlap across tabs and threads. +const inFlightClicks = new Set(); +let nextClickOrder = 0; + +const focusedPreviewTab = (element: Element | null): string | null => + element?.localName === "webview" ? element.getAttribute("data-preview-tab") : null; + +const restoreHostFocus = ({ runtimeTabId, previous }: PreviewClick): void => { + const current = document.activeElement; + if ( + !(current instanceof HTMLElement) || + current === previous || + focusedPreviewTab(current) !== runtimeTabId + ) { + return; + } + if (previous instanceof HTMLElement && previous.isConnected && previous !== document.body) { + previous.focus({ preventScroll: true }); + } + // The previous element may no longer take focus, like a composer that was + // disabled during the click. Never leave focus in the page. + if (document.activeElement === current) current.blur(); +}; + +/** + * Runs an agent click in a preview tab and hands keyboard focus back to the + * host page afterwards. + * + * The desktop dispatches the click as a CDP mouse press, which focuses the + * guest page, and nothing gives that focus back. The user's next keystrokes + * then go into the page, even when its tab is hidden or belongs to another + * thread. Only the host document knows what had focus before the click, so + * the restore happens here and not in the desktop main process. + */ +export async function runPreviewClickKeepingHostFocus( + runtimeTabId: string, + click: () => Promise, +): Promise { + const entry: PreviewClick = { + runtimeTabId, + order: nextClickOrder++, + previous: document.activeElement, + }; + inFlightClicks.add(entry); + try { + return await click(); + } finally { + // Also runs when the click fails: a keystroke that reaches the page after + // the press counts as human input and fails the click as interrupted. + inFlightClicks.delete(entry); + // A click that started while this one held focus in its page saw that page + // as what had focus. Hand it this click's target instead. A click that never + // finishes never gets here, so it cannot override where the user goes later. + for (const other of inFlightClicks) { + if (other.order > entry.order && focusedPreviewTab(other.previous) === runtimeTabId) { + other.previous = entry.previous; + } + } + restoreHostFocus(entry); + } +} From c5a929e1ac05dec15945a36905ab8bb2f1982e59 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:16:54 -0700 Subject: [PATCH 19/82] fix(web): Woke dismiss syncs, preview mute is back, and subscription retries back off (#15034) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 29 ++++- apps/web/src/components/Sidebar.tsx | 10 +- .../components/settings/SettingsPanels.tsx | 58 +++++++++- .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/hooks/useThreadActions.ts | 24 ++++ apps/web/src/modelSelection.test.ts | 109 ++++++------------ apps/web/src/modelSelection.ts | 50 ++------ apps/web/src/planAgentSelectionHeal.tsx | 52 --------- apps/web/src/routes/__root.tsx | 2 - .../client-runtime/src/rpc/client.test.ts | 109 ++++++++++++++++++ packages/client-runtime/src/rpc/client.ts | 50 ++++++-- 11 files changed, 306 insertions(+), 193 deletions(-) delete mode 100644 apps/web/src/planAgentSelectionHeal.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6f01da357d5f..d819312a4685 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,7 +85,7 @@ import { import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { useThreadActions } from "../hooks/useThreadActions"; +import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, deriveReportedModelSelection, @@ -251,6 +251,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; +import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -2234,6 +2235,14 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewServerEpoch = activePreviewState.serverEpoch; + const resolvePreviewRuntimeTabId = useMemo( + () => + activeThreadRef + ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) + : undefined, + [activeThreadRef, activePreviewServerEpoch], + ); const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); @@ -6634,14 +6643,20 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell !== null && supportsSnooze ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() }) : null; + const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); const acknowledgeActiveThreadWoke = useCallback(() => { if (activeThreadRef === null || activeThreadWokeAt === null) return; - markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); - }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. - const activeThreadLastVisitedAt = useUiStateStore((store) => + acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); + }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]); + // Mirror of the sidebar's Woke pill for the open thread. Same watermark as + // the sidebar, so an acknowledgement from any device hides it. + const activeThreadLocalVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); + const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( + activeThreadShell?.lastVisitedAt, + activeThreadLocalVisitedAt, + ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; if (activeThreadShell?.settledOverride === "settled") return false; @@ -6651,7 +6666,7 @@ export default function ChatView(props: ChatViewProps) { // above stamps it); folding that floor in here keeps a completion- // triggered wake from flashing a banner for one frame before the stamp // lands. An unparseable stored visit counts as never-visited: corrupt - // local data must not eat the wake signal. + // data must not eat the wake signal. const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; const completedAtMs = activeLatestRun?.completedAt ? Date.parse(activeLatestRun.completedAt) @@ -11168,6 +11183,7 @@ export default function ChatView(props: ChatViewProps) { pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -11222,6 +11238,7 @@ export default function ChatView(props: ChatViewProps) { pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9280947c5e47..eb0499b0d93f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -130,7 +130,7 @@ import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; -import { useThreadActions } from "../hooks/useThreadActions"; +import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -2381,13 +2381,7 @@ export default function Sidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); - const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); - const acknowledgeWoke = useCallback( - (threadRef: ScopedThreadRef, visitedAt: string) => { - markThreadVisited(scopedThreadKey(threadRef), visitedAt); - }, - [markThreadVisited], - ); + const acknowledgeWoke = useAcknowledgeThreadWoke(); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a11bcf730b86..2d3442bdbcee 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -44,6 +44,7 @@ import { type ResponseStreamingMode, MIN_TERMINAL_FONT_SIZE, type QuitConfirmationMode, + SidebarProjectSortOrder, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, + selectsPlanAgent, } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -181,6 +183,13 @@ const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record = { + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", +}; +const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder); + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -552,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder + ? ["Project order"] + : []), ...(settings.sidebarWorkingShelfEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? ["Working section"] @@ -690,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, settings.sidebarProjectGroupingMode, + settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -789,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, @@ -2270,6 +2284,46 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, + }) + } + /> + ) : null + } + control={ + + } + /> {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} + planModeEnabled={ + settings.planModeEnabled || selectsPlanAgent(textGenModelOptions) + } triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onModelOptionsChange={(nextOptions) => { updateSettings({ diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7e58a92e679f..ae55a327d163 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -280,6 +280,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["combine matching repositories environments sidebar"], }, + { + id: "project-order", + title: "Project order", + to: "/settings/general", + searchTerms: ["sort projects sidebar manual created recent"], + }, { id: "snooze-limited-threads", title: "Snooze limited threads", diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7ebfe47e6d68..4328d61da82f 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -219,6 +219,30 @@ function useMarkThreadUnread() { ); } +/** + * Clears a thread's Woke marker by recording a visit at the wake time. + * Servers with visited tracking own the watermark (thread.visit keeps the + * later of the stored and supplied values, so this syncs to every device); + * older servers keep the browser-local watermark. + */ +export function useAcknowledgeThreadWoke() { + const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); + return useCallback( + (target: ScopedThreadRef, wokeAt: string) => { + if (readEnvironmentSupportsVisitedTracking(target.environmentId)) { + void visitThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, visitedAt: wokeAt }, + }); + return; + } + markThreadVisited(scopedThreadKey(target), wokeAt); + }, + [markThreadVisited, visitThreadMutation], + ); +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index cc4a65f2162c..bcd9e59519bc 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -15,8 +15,6 @@ import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, - resolvePlanAgentHealPatch, - withoutPlanAgentSelection, } from "./modelSelection"; function provider(input: { @@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => { }); }); -describe("withoutPlanAgentSelection", () => { - const instance = ProviderInstanceId.make("opencode"); +describe("resolveAppModelSelectionState with the opencode plan agent", () => { + const instanceId = ProviderInstanceId.make("opencode"); const model = "opencode/gpt-5.4"; - - it("drops a stored plan agent option", () => { - const selection = createModelSelection(instance, model, [ - { id: "variant", value: "high" }, + const opencode: ServerProvider = { + ...provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), + models: [ + { + slug: model, + name: model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ], + }, + }, + ], + }; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + planModeEnabled: false, + textGenerationModelSelection: createModelSelection(instanceId, model, [ { id: "agent", value: "plan" }, - ]); - expect(withoutPlanAgentSelection(selection)).toEqual( - createModelSelection(instance, model, [{ id: "variant", value: "high" }]), - ); - }); - - it("keeps non-plan agent options", () => { - const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]); - expect(withoutPlanAgentSelection(selection)).toBe(selection); - }); - - it("omits options entirely when plan was the only stored option", () => { - const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]); - expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model }); - }); - - it("returns null and undefined selections unchanged", () => { - expect(withoutPlanAgentSelection(null)).toBeNull(); - expect(withoutPlanAgentSelection(undefined)).toBeUndefined(); - }); -}); - -describe("resolvePlanAgentHealPatch", () => { - const instance = ProviderInstanceId.make("opencode"); - const model = "opencode/gpt-5.4"; - const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]); - const storedPlan = createModelSelection(instance, model, [ - { id: "variant", value: "high" }, - { id: "agent", value: "plan" }, - ]); - const nullPatch = { - planModeEnabled: true, - textGenerationModelSelection: storedPlan, - sourceControlWriterModelSelection: null, + ]), }; - it("returns null when plan mode is on", () => { - expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull(); - }); - - it("returns null when nothing needs healing", () => { - expect( - resolvePlanAgentHealPatch({ - planModeEnabled: false, - textGenerationModelSelection: healed, - sourceControlWriterModelSelection: null, - }), - ).toBeNull(); - }); - - it("patches the stored text generation selection to drop the plan agent", () => { - expect( - resolvePlanAgentHealPatch({ - planModeEnabled: false, - textGenerationModelSelection: storedPlan, - sourceControlWriterModelSelection: null, - }), - ).toEqual({ textGenerationModelSelection: healed }); - }); - - it("patches a stored source control writer selection that uses the plan agent", () => { - expect( - resolvePlanAgentHealPatch({ - planModeEnabled: false, - textGenerationModelSelection: healed, - sourceControlWriterModelSelection: storedPlan, - }), - ).toEqual({ sourceControlWriterModelSelection: healed }); + it("keeps a stored plan agent this device did not pick", () => { + expect(resolveAppModelSelectionState(settings, [opencode])).toEqual( + createModelSelection(instanceId, model, [{ id: "agent", value: "plan" }]), + ); }); }); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 1a39f06098ac..91e7251f35fe 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -7,7 +7,6 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, - type ServerSettingsPatch, } from "@t3tools/contracts"; import { type CustomModelDefinition, @@ -350,48 +349,13 @@ export function getCustomModelOptionsByInstance( } /** - * Drop the opencode "plan" agent option from a stored model selection. - * Used when legacy plan mode is turned off so server-side text-generation - * tasks (title, branch, PR) cannot keep dispatching the plan agent. + * Whether stored model options pick the opencode "plan" agent. Shared settings + * pickers keep and show such a value even while this device's legacy plan + * mode is off: another device may have chosen it, and this device's filter + * only applies to picks made here. */ -export function withoutPlanAgentSelection( - selection: ModelSelection | null | undefined, -): ModelSelection | null | undefined { - if (!selection?.options) { - return selection; - } - const options = selection.options.filter( - (option) => !(option.id === "agent" && option.value === "plan"), - ); - if (options.length === selection.options.length) { - return selection; - } - return createModelSelection(selection.instanceId, selection.model, options); -} - -// The dropdown hides the opencode "plan" agent while legacy plan mode is off, -// but the persisted text-generation selections are only healed when the toggle -// flips. Users who already have plan mode off and a stored "plan" selection -// never trip the toggle handler, so resolve the heal once per settings load. -export function resolvePlanAgentHealPatch(input: { - readonly planModeEnabled: boolean; - readonly textGenerationModelSelection: ModelSelection | null | undefined; - readonly sourceControlWriterModelSelection: ModelSelection | null | undefined; -}): ServerSettingsPatch | null { - if (input.planModeEnabled) { - return null; - } - const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection); - const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection); - const patch: ServerSettingsPatch = { - ...(healedText && healedText !== input.textGenerationModelSelection - ? { textGenerationModelSelection: healedText } - : {}), - ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection - ? { sourceControlWriterModelSelection: healedSourceControl } - : {}), - }; - return Object.keys(patch).length > 0 ? patch : null; +export function selectsPlanAgent(options: ModelSelection["options"]): boolean { + return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false; } export function resolveAppModelSelectionState( @@ -433,7 +397,7 @@ export function resolveAppModelSelectionState( model, models: entry.models, modelOptions: selectedEntry ? selection.options : undefined, - planModeEnabled: settings.planModeEnabled, + planModeEnabled: settings.planModeEnabled || selectsPlanAgent(selection.options), }); return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); diff --git a/apps/web/src/planAgentSelectionHeal.tsx b/apps/web/src/planAgentSelectionHeal.tsx deleted file mode 100644 index 4f6760e50fae..000000000000 --- a/apps/web/src/planAgentSelectionHeal.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect } from "react"; - -import { - useClientSettingsHydrated, - usePrimarySettings, - useUpdatePrimarySettings, -} from "./hooks/useSettings"; -import { resolvePlanAgentHealPatch } from "./modelSelection"; - -/** - * Heals persisted text-generation model selections that still reference the - * opencode "plan" agent. The dropdown hides the option while legacy plan mode - * is off, but the toggle handler only runs when the setting flips; users who - * already have plan mode off with a stored "plan" selection need this pass - * whenever the settings load. - */ -export function PlanAgentSelectionHeal() { - const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled); - const textGenerationModelSelection = usePrimarySettings( - (settings) => settings.textGenerationModelSelection, - ); - const sourceControlWriterModelSelection = usePrimarySettings( - (settings) => settings.sourceControlWriterModelSelection, - ); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdatePrimarySettings(); - - useEffect(() => { - // planModeEnabled reads as false until client settings hydrate, so never - // heal before then: we would strip a stored plan selection from a user - // whose plan mode is actually on. - if (!settingsHydrated) { - return; - } - const patch = resolvePlanAgentHealPatch({ - planModeEnabled, - textGenerationModelSelection, - sourceControlWriterModelSelection, - }); - if (patch) { - updateSettings(patch); - } - }, [ - planModeEnabled, - settingsHydrated, - textGenerationModelSelection, - sourceControlWriterModelSelection, - updateSettings, - ]); - - return null; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 6cc11846c570..b5cefd1342ea 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -51,7 +51,6 @@ import { isElectron } from "../env"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; -import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -242,7 +241,6 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? ( ) : null} - {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 032603f28a96..8dda4440e673 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentAuthorizationError, EnvironmentId, PreviewTabId, ThreadId, @@ -618,6 +619,114 @@ describe("environment RPC", () => { }), ); + it.effect("doubles the retry delay for repeated failures and resets it after a value", () => + Effect.gen(function* () { + const domainError = new Error("thread not hydrated yet"); + const subscriptions = yield* Queue.unbounded(); + const failures = yield* Queue.unbounded(); + let attempts = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Effect.sync(() => { + attempts += 1; + return attempts; + }).pipe( + Effect.tap((attempt) => Queue.offer(subscriptions, attempt)), + Effect.map((attempt) => { + if (attempt <= 2) return Stream.fail(domainError); + if (attempt === 3) + return Stream.concat(Stream.make("event"), Stream.fail(domainError)); + return Stream.never; + }), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Queue.offer(failures, undefined), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + expect(yield* Queue.take(subscriptions)).toBe(1); + yield* Queue.take(failures); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(2); + yield* Queue.take(failures); + // The second retry waits 200ms, so 100ms is not enough. + yield* TestClock.adjust("100 millis"); + expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(3); + // Attempt 3 delivered a value before failing, so the delay is back to 100ms. + yield* Queue.take(failures); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(4); + yield* Fiber.interrupt(subscriptionFiber); + }), + ); + + it.effect("waits for the next session after an authorization failure", () => + Effect.gen(function* () { + const subscriptions = yield* Queue.unbounded(); + const failed = yield* Deferred.make(); + let attempts = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Queue.offer(subscriptions, undefined).pipe( + Effect.map(() => { + attempts += 1; + return attempts === 1 + ? Stream.fail( + new EnvironmentAuthorizationError({ + message: "Missing scope", + requiredScope: "orchestration:read", + }), + ) + : Stream.never; + }), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Deferred.succeed(failed, undefined).pipe(Effect.asVoid), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* Queue.take(subscriptions); + yield* Deferred.await(failed); + yield* TestClock.adjust("1 minute"); + expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + yield* Queue.take(subscriptions); + yield* Fiber.interrupt(subscriptionFiber); + expect(attempts).toBe(2); + }), + ); + it.effect.each(["input", "stream"] as const)( "does not classify %s subscription defects as expected failures", (where) => diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 426ea63482ea..4ce45cf3ff1b 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,7 +1,11 @@ -import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import { + EnvironmentAuthorizationError, + ORCHESTRATION_V2_WS_METHODS, + WS_METHODS, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import type * as Duration from "effect/Duration"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -92,6 +96,10 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ }) {} export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); + +/** Ceiling for the doubling delay between same-session expected-failure retries. */ +const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; export type EnvironmentRpcInput = Parameters>[0]; @@ -192,6 +200,12 @@ interface SubscriptionOptions { readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; + /** + * First delay before resubscribing on the same session after an expected + * failure. Each consecutive failure doubles it up to 30 seconds, and the + * first value from a healthy stream resets it. Authorization failures are + * not retried; they wait for the next session or `resubscribe` signal. + */ readonly retryExpectedFailureAfter?: Duration.Input; readonly resubscribe?: Stream.Stream; } @@ -238,6 +252,9 @@ function subscribeDynamicMapped( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; + // Consecutive expected-failure retries on this session. Reset by + // the first value a resubscribed stream delivers. + let expectedFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( @@ -248,7 +265,13 @@ function subscribeDynamicMapped( method: tag, input, }); - const stream = mapStream(session, method(input)); + const stream = mapStream(session, method(input)).pipe( + Stream.onFirst(() => + Effect.sync(() => { + expectedFailureRetries = 0; + }), + ), + ); // An evicted preview host completes its registration stream. // Re-register only after completion; failures still follow the // session recovery policy and browser actions are never replayed. @@ -296,14 +319,27 @@ function subscribeDynamicMapped( const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( Stream.drain, ); - if (options.retryExpectedFailureAfter === undefined) { + const isAuthorizationFailure = cause.reasons.some( + (reason) => + reason._tag === "Fail" && isEnvironmentAuthorizationError(reason.error), + ); + if ( + options.retryExpectedFailureAfter === undefined || + isAuthorizationFailure + ) { return handled; } + const retryDelay = Duration.millis( + Math.min( + Duration.toMillis(options.retryExpectedFailureAfter) * + 2 ** expectedFailureRetries, + MAX_EXPECTED_FAILURE_RETRY_DELAY_MS, + ), + ); + expectedFailureRetries += 1; return handled.pipe( Stream.concat( - Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( - Stream.drain, - ), + Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain), ), Stream.concat(subscribeToSession()), ); From ddcd3102827f520e3dff53b902111744c0ba2ced Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:25:00 -0700 Subject: [PATCH 20/82] chore: docs, dev scripts and CI catch up with orchestration V2 (#15041) Co-authored-by: Claude Opus 5.5 (1M context) --- .../test-t3-app/references/sqlite-fixtures.md | 23 +-- .github/workflows/ci.yml | 1 + AGENTS.md | 21 +-- CONTRIBUTING.md | 3 + .../backend/DesktopBackendConfiguration.ts | 2 +- apps/server/scripts/migrate-dev-db.test.ts | 93 +++++++--- apps/server/scripts/migrate-dev-db.ts | 167 +++++++++++------ .../src/orchestration-v2/Orchestrator.ts | 27 +-- apps/server/src/orchestration-v2/TODO.md | 172 ------------------ .../ApplicationEventSequenceIndexes.ts | 2 +- .../OrchestrationV2/ApplicationEventSource.ts | 2 +- .../OrchestrationV2/EffectCancellation.ts | 2 +- .../Migrations/OrchestrationV2/Foundation.ts | 2 +- .../OrchestrationV2/LegacyV1ImportState.ts | 2 +- .../ProviderSessionBindings.ts | 2 +- .../OrchestrationV2/RecoveryIndexes.ts | 2 +- .../OrchestrationV2/ScheduledTasks.ts | 2 +- .../OrchestrationV2/ShellIndexes.ts | 2 +- .../Migrations/OrchestrationV2/Subagents.ts | 2 +- .../OrchestrationV2/ThreadLaunchWorkflows.ts | 2 +- apps/server/src/storageCleanup.ts | 2 +- apps/server/src/testUtils/shardWeights.json | 92 ++++++---- docs/internals/composer-context-references.md | 2 +- docs/internals/devices.md | 2 +- docs/internals/glossary.md | 24 ++- docs/internals/providers.md | 29 +-- .../no-manual-effect-runtime-in-tests.ts | 6 +- scripts/mobile-showcase-environment.ts | 13 +- vite.config.ts | 21 --- 29 files changed, 305 insertions(+), 417 deletions(-) delete mode 100644 apps/server/src/orchestration-v2/TODO.md diff --git a/.agents/skills/test-t3-app/references/sqlite-fixtures.md b/.agents/skills/test-t3-app/references/sqlite-fixtures.md index 8eca543cf979..9a1225ce3d91 100644 --- a/.agents/skills/test-t3-app/references/sqlite-fixtures.md +++ b/.agents/skills/test-t3-app/references/sqlite-fixtures.md @@ -4,7 +4,9 @@ Load this reference only when inspecting or seeding local T3 state directly. ## Select the correct database -When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata` and the database path is `/userdata/state.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. +When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata` and the database path is `/userdata/statev2.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. + +The server copies the V1 `state.sqlite` into `statev2.sqlite` only when `statev2.sqlite` is missing. After the first start, edits to `state.sqlite` change nothing. Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection. @@ -23,7 +25,7 @@ Inspect current columns before writing a fixture: ```bash node apps/server/scripts/t3-sqlite-state.ts query \ --base-dir \ - --sql "PRAGMA table_info(projection_threads)" + --sql "PRAGMA table_info(orchestration_v2_projection_threads)" ``` Apply a SQL fixture from a file: @@ -38,21 +40,10 @@ Use one statement per invocation for both `query` and `exec`; the helper wraps w ## Seed projection data carefully -The web UI primarily reads these projection tables: - -- `projection_projects` -- `projection_threads` -- `projection_thread_messages` -- `projection_thread_activities` -- `projection_thread_sessions` -- `projection_turns` -- `projection_pending_approvals` -- `projection_thread_proposed_plans` - -Inspect `PRAGMA table_info()` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent. +Clients read projects from `projection_projects` and everything else from the `orchestration_v2_projection_*` tables: threads, runs, messages, turn items, runtime requests, plans, and provider sessions. The older `projection_thread*` tables hold V1 history, which the server imports once per thread at startup; later edits there do not reach the UI. -For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current. +Most V2 rows carry a `payload_json` that must decode against the schemas in `packages/contracts/src/orchestrationV2.ts`. The safest start is a row the app wrote itself: create a thread through the UI, copy its rows, and edit them. Keep identifiers unique, timestamps as ISO strings, and related project, thread, and run IDs consistent. -Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, activity lists, and similar UI fixtures. They do not create a coherent orchestration event history. Do not modify `orchestration_events` unless the test specifically exercises projector internals, and do not use direct projection writes to claim backend business behavior works. +Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, long timelines, and similar UI fixtures. They do not create a coherent event history. Leave the event log (`orchestration_events`) unchanged, and do not use direct projection writes to claim backend business behavior works. Use the app's commands or APIs for behavior tests. Use `node apps/server/src/bin.ts auth ...` for auth state rather than editing `auth_pairing_links` or `auth_sessions`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73245a07dd38..acb6900120ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -487,6 +487,7 @@ jobs: test, test_web, test_server, + transfer-report, rust, mobile_native_changes, mobile_native_static_analysis, diff --git a/AGENTS.md b/AGENTS.md index b926f276aba0..fae5e1c7e21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,17 +88,8 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- Copy from `~/.t3/userdata` (the developer's real data, the most realistic test set) or `~/.t3/dev`. Worktree state lives at `/.t3/userdata`. -- Snapshot the database with `VACUUM INTO`, which is safe even while a server has the source open and yields one consistent file: - - ```bash - mkdir -p .t3/userdata - rm -f .t3/userdata/state.sqlite* # VACUUM INTO refuses to overwrite - bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.t3/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")" - ``` - - A plain `cp` is only safe when no server has the source open, and must bring the `-wal` and `-shm` siblings along. A live file copy is a corrupt copy. - +- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. @@ -108,7 +99,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **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. -- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. +- 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. - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. @@ -120,7 +111,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. - Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. -- One concern per PR. If the description says "also", split it. +- One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. ## Documentation @@ -143,9 +134,9 @@ Most code changes do not need an internal documentation change. Agents can read ## How it works -Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. +Clients send typed WebSocket requests. The server turns them into _commands_. The _orchestrator_ (`apps/server/src/orchestration-v2/Orchestrator.ts`) serializes commands and decides _events_ without doing any I/O. The _event sink_ commits those events, the _projections_ the UI reads, the _command receipt_, and _outbox_ effects in one transaction. The _effect worker_ then runs the effects, such as starting a provider turn or capturing a checkpoint, and feeds results back as commands. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. -Full glossary with file links: `docs/internals/glossary.md` +Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/internals/glossary.md` ## Where code lives diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 511e4b094bc1..ad5fa9d879d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,9 @@ Explain their relationship when it is not obvious. An adjacent cleanup, refactor its own PR unless it is necessary to solve the same problem. A large diff alone does not establish that the PR contains unrelated work. +This rule is for outside contributions. Maintainers, the logins in +[.github/TRIAGE_EXEMPTIONS.td](.github/TRIAGE_EXEMPTIONS.td), may batch related fixes in one PR. + Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions of obvious controls and cosmetic changes. diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index c2012299802d..d1590d1a433d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -644,7 +644,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // The packaged sidecar is a Windows executable and cannot run inside the // Linux WSL backend. Keep the field absent instead of passing an unusable // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. - // See docs/architecture/resource-telemetry.md. + // See docs/internals/resource-telemetry.md. ...buildObservabilityFragment(input.observabilitySettings), }; diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index a3343b0b2e48..694af7bbf305 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -14,24 +14,21 @@ const withDatabase = ( effect: Effect.Effect, ) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); -/** A migrated source db with one thread per lifecycle state. Only - * `stopped-thread` qualifies for the clone. */ +/** A migrated source db with one V2 thread per lifecycle state. Only + * `stopped-thread` and its fork qualify for the clone. */ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( baseDir: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = path.join(baseDir, "userdata"); - const databasePath = path.join(stateDir, "state.sqlite"); + const databasePath = path.join(stateDir, "statev2.sqlite"); yield* fs.makeDirectory(stateDir, { recursive: true }); yield* withDatabase( databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations(); - // The real shared db carries this column from a branch build without a - // matching migration; reproduce that drift so the filter is exercised. - yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) @@ -39,23 +36,56 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL), ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`; + const forkPayload = + '{"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"}}'; + // Excluded threads are newer than the kept family, so only the filters + // can keep them out of a one-family-per-project clone. const threads = [ - ["stopped-thread", "project-kept", "stopped", null, null], - ["running-thread", "project-kept", "running", null, null], - ["settled-thread", "project-kept", "stopped", "2026-08-01", null], - ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'], - ["deleted-project-thread", "project-deleted", "stopped", null, null], + ["stopped-thread", "project-kept", "completed", "{}", "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"], + [ + "limit-thread", + "project-kept", + "completed", + '{"limitRecovery":{"autoResume":true}}', + "2026-08-05", + ], + // Its result never reached the parent, so startup would deliver it. + ["subagent-parent", "project-kept", "completed", "{}", "2026-08-05"], + ["subagent-child", "project-kept", "completed", subagentPayload, "2026-08-05"], + ["deleted-project-thread", "project-deleted", "completed", "{}", "2026-08-05"], ] as const; - for (const [threadId, projectId, status, settledAt, monitorJson] of threads) { - yield* sql`INSERT INTO projection_threads - (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json) - VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`; - yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at) - VALUES (${threadId}, ${status}, '2026-08-01')`; + for (const [threadId, projectId, runStatus, payload, updatedAt] of threads) { + yield* sql`INSERT INTO orchestration_v2_projection_threads + (thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json) + VALUES (${threadId}, ${projectId}, ${threadId}, 'codex', 'full-access', 'default', '2026-08-01', ${updatedAt}, ${payload})`; + yield* sql`INSERT INTO orchestration_v2_projection_runs + (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) + VALUES (${`run-${threadId}`}, ${threadId}, 1, 'codex', ${runStatus}, '2026-08-01', '{}')`; yield* sql`INSERT INTO orchestration_events (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; } + // A provider session shared by two threads names its latest writer. + yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions + (provider_session_id, thread_id, provider, status, updated_at, payload_json) + VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`; + yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings + (provider_session_id, thread_id) + VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`; + yield* sql`INSERT INTO orchestration_v2_projection_context_transfers + (context_transfer_id, source_thread_id, target_thread_id, type, status, updated_at, payload_json) + VALUES ('transfer-1', 'settled-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`; + yield* sql`INSERT INTO scheduled_tasks + (task_id, title, prompt, enabled, schedule_json, project_id, workspace_strategy_json, + model_selection_json, runtime_mode, interaction_mode, created_by, creation_source, + created_at, updated_at, last_run_status, run_count) + VALUES ('task-1', 'Nightly', 'Run it', 1, '{}', 'project-kept', '{}', '{}', + 'full-access', 'default', 'user', 'user', '2026-08-01', '2026-08-01', 'never', 0)`; yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; }), @@ -64,7 +94,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio }); it.layer(NodeServices.layer)("migrate-dev-db", (it) => { - it.effect("keeps only stopped threads from live projects and clears auth state", () => + it.effect("keeps stopped thread families from live projects and clears pending work", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -73,7 +103,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { const source = yield* createFixtureSource(sourceDir); const result = yield* runMigrateDevDb( - { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { baseDir: destDir, source, projects: 5, threadsPerProject: 1 }, { sharedHome: sourceDir }, ); @@ -83,23 +113,32 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const threads = yield* sql<{ thread_id: string }>` - SELECT thread_id FROM projection_threads ORDER BY thread_id`; + SELECT thread_id FROM orchestration_v2_projection_threads ORDER BY thread_id`; const events = yield* sql<{ stream_id: string }>` - SELECT stream_id FROM orchestration_events`; - const [auth] = yield* sql<{ count: number }>` - SELECT COUNT(*) AS count FROM auth_sessions`; - return { threads, events, authCount: auth?.count ?? 0 }; + 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 [leftovers] = yield* sql<{ auth: number; tasks: number; transfers: number }>` + SELECT + (SELECT COUNT(*) FROM auth_sessions) AS auth, + (SELECT COUNT(*) FROM scheduled_tasks) AS tasks, + (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers`; + return { threads, events, sessions, leftovers }; }), ); assert.deepStrictEqual( kept.threads.map((row) => row.thread_id), - ["stopped-thread"], + ["fork-thread", "stopped-thread"], ); assert.deepStrictEqual( kept.events.map((row) => row.stream_id), - ["stopped-thread"], + ["fork-thread", "stopped-thread"], + ); + assert.deepStrictEqual( + kept.sessions.map((row) => row.provider_session_id), + ["session-shared"], ); - assert.equal(kept.authCount, 0); + assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, transfers: 0 }); }), ); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index b9ec9a875354..c0982f731e77 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -6,10 +6,12 @@ * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/statev2.sqlite`. - * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the - * most recently updated projects and, per project, the most recent - * threads that have fully stopped. Working, settled, and monitored - * threads are skipped so the dev server never adopts live work. + * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only + * VACUUM INTO) and prunes it to the most recently updated projects and, + * per project, the most recent threads that have fully stopped, with + * their forks and subagents. Working, settled, and archived threads, and + * threads with pending recovery, are skipped, and scheduled tasks and + * queued effects are dropped, so the dev server never adopts live work. * Auth sessions, pairing links, command receipts, and provider * runtime rows are dropped — pair a fresh browser against dev. * 3. Runs migrations on the result. Because the clone carries the real @@ -19,9 +21,8 @@ * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped). * * The event log (`orchestration_events`) is pruned per stream while - * `sqlite_sequence` and `projection_state` carry over untouched, so new - * events keep appending after the old high-water mark and projection - * cursors never rewind. + * `sqlite_sequence` and the projection cursors carry over untouched, so new events keep appending + * after the old high-water mark and projection cursors never rewind. */ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -31,12 +32,14 @@ import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Command, Flag } from "effect/unstable/cli"; +import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts"; import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -143,7 +146,7 @@ export class MigrateDevDbPhaseError extends Schema.TaggedError/.t3` of the cwd. */ readonly baseDir?: string | undefined; - /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */ + /** Source database. Defaults to `~/.t3/userdata/statev2.sqlite`. */ readonly source?: string | undefined; readonly projects: number; readonly threadsPerProject: number; @@ -232,31 +235,56 @@ const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath: } }); +const RECOVERY_KINDS: ReadonlyArray = [ + "queued-runs", + "runtime", + "subagent-results", + "delegated-completions", +]; + const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; - // The shared db can carry monitor_json from a branch build even though no - // migration in this checkout creates it, so filter it only when present. - const threadColumns = yield* sql<{ name: string }>` - SELECT name FROM pragma_table_info('projection_threads')`; - const monitorFilter = threadColumns.some((column) => column.name === "monitor_json") - ? "AND t.monitor_json IS NULL" - : ""; - - // "Stopped" is the persisted subset of the UI's thread status: the session - // reached status 'stopped' and nothing marks the thread settled or - // monitored. The in-memory working/monitoring liveness never persists, so - // filtering the session status is sufficient. - yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS - SELECT t.thread_id, t.project_id, t.updated_at - FROM projection_threads t - JOIN projection_thread_sessions s ON s.thread_id = t.thread_id - WHERE t.deleted_at IS NULL - AND t.archived_at IS NULL - AND t.settled_at IS NULL - AND (t.settled_override IS NULL OR t.settled_override <> 'settled') - ${monitorFilter} - AND s.status = 'stopped'`).unprepared; + // Threads the server would resume or start work on by itself. Its own + // recovery queries find most of them; usage-limit recovery also depends on + // settings, so any thread whose latest run failed or that has a recovery + // choice counts as live too. + const projections = yield* ProjectionStore.ProjectionStoreV2; + const recoveryThreadIds = yield* Effect.forEach(RECOVERY_KINDS, (kind) => + projections.getRecoveryThreadIds(kind), + ); + yield* sql`CREATE TEMP TABLE live_threads (thread_id TEXT PRIMARY KEY)`; + for (const threadId of new Set(recoveryThreadIds.flat())) { + yield* sql`INSERT INTO live_threads (thread_id) VALUES (${threadId})`; + } + yield* sql`INSERT OR IGNORE INTO live_threads (thread_id) + SELECT r.thread_id FROM orchestration_v2_projection_runs r + WHERE r.status = 'failed' AND r.ordinal = ( + SELECT MAX(latest.ordinal) FROM orchestration_v2_projection_runs latest + WHERE latest.thread_id = r.thread_id) + UNION + SELECT thread_id FROM orchestration_v2_projection_threads + WHERE json_extract(payload_json, '$.limitRecovery') IS NOT NULL`; + + // Forks and subagents read history and results through their lineage, so + // a thread family is cloned or dropped as a whole. A family is stopped when + // its root is visible and unsettled and none of its threads is live. + yield* sql`CREATE TEMP TABLE thread_families AS + SELECT thread_id, updated_at, + COALESCE(json_extract(payload_json, '$.lineage.rootThreadId'), thread_id) AS root_id + FROM orchestration_v2_projection_threads`; + + yield* sql`CREATE TEMP TABLE stopped_families AS + SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at + FROM thread_families f + JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id + WHERE root.deleted_at IS NULL + AND json_extract(root.payload_json, '$.deletedAt') IS NULL + AND json_extract(root.payload_json, '$.archivedAt') IS NULL + AND json_extract(root.payload_json, '$.settledAt') IS NULL + AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled' + GROUP BY f.root_id, root.project_id + HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; // Projects with clonable threads outrank empty-but-recent ones: the point // of the exercise is thread data, not the project list. @@ -265,7 +293,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra FROM projection_projects p LEFT JOIN ( SELECT project_id, MAX(updated_at) AS last_stopped_at - FROM stopped_threads + FROM stopped_families GROUP BY project_id ) q ON q.project_id = p.project_id WHERE p.deleted_at IS NULL @@ -274,42 +302,56 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra LIMIT ${input.projects}`; yield* sql`CREATE TEMP TABLE kept_threads AS - SELECT thread_id FROM ( - SELECT - st.thread_id, - ROW_NUMBER() OVER ( - PARTITION BY st.project_id - ORDER BY st.updated_at DESC - ) AS recency_rank - FROM stopped_threads st - JOIN kept_projects kp ON kp.project_id = st.project_id - ) - WHERE recency_rank <= ${input.threadsPerProject}`; + SELECT f.thread_id FROM thread_families f + WHERE f.root_id IN ( + SELECT root_id FROM ( + SELECT + sf.root_id, + ROW_NUMBER() OVER ( + PARTITION BY sf.project_id + ORDER BY sf.updated_at DESC + ) AS recency_rank + FROM stopped_families sf + JOIN kept_projects kp ON kp.project_id = sf.project_id + ) + WHERE recency_rank <= ${input.threadsPerProject} + )`; + + // Every V2 table and every V1 table the lazy importer reads is keyed by + // thread_id, so one sweep covers both and new tables need no change here. + // Provider sessions are shared between threads and pruned by binding below. + const threadTables = yield* sql<{ name: string }>` + SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c + WHERE m.type = 'table' AND c.name = 'thread_id' + AND m.name <> 'orchestration_v2_projection_provider_sessions'`; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; - yield* sql`DELETE FROM projection_threads - WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`; - for (const table of [ - "projection_thread_messages", - "projection_thread_activities", - "projection_thread_sessions", - "projection_turns", - "projection_pending_approvals", - "projection_thread_proposed_plans", - "checkpoint_diff_blobs", - ]) { + yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions + WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) + AND provider_session_id NOT IN ( + SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings + WHERE thread_id IN (SELECT thread_id FROM kept_threads) + )`; + for (const { name } of threadTables) { yield* sql.unsafe( - `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, + `DELETE FROM "${name}" WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, ).unprepared; } + yield* sql`DELETE FROM orchestration_v2_projection_context_transfers + WHERE source_thread_id NOT IN (SELECT thread_id FROM kept_threads) + OR target_thread_id NOT IN (SELECT thread_id FROM kept_threads)`; yield* sql`DELETE FROM orchestration_events WHERE (aggregate_kind = 'thread' 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))`; + // Pending work the dev server would otherwise pick up and run. + yield* sql`DELETE FROM scheduled_tasks`; + yield* sql`DELETE FROM orchestration_v2_effect_outbox`; + yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`; yield* sql`DELETE FROM orchestration_command_receipts`; yield* sql`DELETE FROM provider_session_runtime`; yield* sql`DELETE FROM auth_sessions`; @@ -320,7 +362,8 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra const keptProjects = yield* sql<{ title: string; threads: number }>` SELECT p.title, - (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads + (SELECT COUNT(*) FROM orchestration_v2_projection_threads t + WHERE t.project_id = p.project_id) AS threads FROM projection_projects p ORDER BY p.updated_at DESC`; const [events] = yield* sql<{ count: number }>` @@ -362,7 +405,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); const sourcePath = path.resolve( - input.source ?? path.join(sharedHome, "userdata", "state.sqlite"), + input.source ?? path.join(sharedHome, "userdata", "statev2.sqlite"), ); const baseDir = @@ -458,7 +501,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`, ); const result = yield* pruneSnapshot(input).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + Effect.provide( + ProjectionStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: snapshotPath })), + ), + ), wrapPhase("prune", snapshotPath), ); @@ -512,7 +559,9 @@ export const migrateDevDbCommand = Command.make( ), threadsPerProject: Flag.Int("threads-per-project").pipe( Flag.withDefault(10), - Flag.withDescription("How many recent stopped threads to keep per project."), + Flag.withDescription( + "How many recent stopped threads, with their forks and subagents, to keep per project.", + ), ), baseDir: Flag.String("base-dir").pipe( Flag.optional, @@ -520,7 +569,7 @@ export const migrateDevDbCommand = Command.make( ), source: Flag.String("source").pipe( Flag.optional, - Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."), + Flag.withDescription("Source database. Defaults to ~/.t3/userdata/statev2.sqlite."), ), }, ({ projects, threadsPerProject, baseDir, source }) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c52087778d63..28296f9d7b78 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8029,32 +8029,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ); - /* - * TODO(interrupt-hardening): before shipping, make these interrupt - * semantics explicit in tests and policy. - * - * Current behavior: - * - emit a `run_interrupt_request` item as user intent; - * - call the provider interrupt RPC; - * - keep the run active and continue ingesting provider chunks; - * - let RunExecutionService emit `run_interrupt_result` only if the - * provider later reports terminal status `interrupted`. - * - * Known scenarios we do not fully harden yet: - * - provider accepts interrupt, then emits more chunks before terminal; - * - provider accepts interrupt, then completes normally instead; - * - provider accepts interrupt but never terminalizes; - * - user queues, steers, or starts another message while the interrupted - * provider turn is still active. - * - * Likely policy: - * - queue should wait behind the still-active provider turn; - * - explicit steer may target the active turn if provider steering is - * supported; - * - starting a new root turn before provider terminalization should be - * an explicit policy decision because it can weaken native-item - * correlation. - */ + // Open interrupt edge cases are tracked in https://github.com/pingdotgg/t3code/issues/15013. yield* emitEvent({ type: "turn-item.updated", threadId: command.threadId, diff --git a/apps/server/src/orchestration-v2/TODO.md b/apps/server/src/orchestration-v2/TODO.md deleted file mode 100644 index a4ac59fc8c5f..000000000000 --- a/apps/server/src/orchestration-v2/TODO.md +++ /dev/null @@ -1,172 +0,0 @@ -# Orchestration V2 TODO - -This file tracks remaining backend-oriented V2 work. Architecture-level intent lives in -[`docs/orchestration-v2`](../../../../docs/orchestration-v2); this file is the local -implementation checklist for `apps/server/src/orchestration-v2`. - -## Current Baseline - -- V2 commands/events/projections are server-owned and replayable. -- Message dispatch supports start, steer, queue, queue reorder, promote queued message to steer, - interrupt, and provider switch command shapes. -- Checkpoint rollback is currently a full revert: filesystem checkpoint restore, provider thread - rollback, stale checkpoint marking, and later run/node `rolled_back` projection state. -- Codex same-provider fork is lazy: `thread.fork` records lineage and pending transfer, and first - dispatch resolves native Codex fork. Earlier source-point forks pass the source turn's native id - to `thread/fork` as `lastTurnId`; the fork-then-rollback fallback remains only for source turns - without a native turn reference and only on legacy-history threads. -- Codex provider conversation rollback supports only legacy-history threads. The adapter probes - `historyMode` and fails explicitly on paginated threads. Closing the gap means paging - `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`. -- Native Codex fork-from-earlier-run has a real replay-backed test fixture: - `testkit/fixtures/thread_fork_native_prior_turn`. -- Merge-back from a fork into its source thread records a `merge_back` context transfer, materializes - a `fork_delta_summary` context handoff, and injects that handoff into the next source-thread run. - -## Projection Hardening - -Target docs: - -- [`core-graph-and-data-model.md`](../../../../docs/orchestration-v2/core-graph-and-data-model.md) -- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md) -- [`testing-strategy.md`](../../../../docs/orchestration-v2/testing-strategy.md) - -TODO: - -- [x] Add end-to-end projection assertions for forked threads, not only provider-context behavior. - The fork projection should show inherited user-visible items through the source point plus an - explicit fork marker. -- [x] Decide and implement the projection representation for inherited fork history: - referenced lineage overlay vs physically duplicated projection items. Prefer referenced overlay - unless product requirements need independent editable history. -- [x] Add local visible projection assertions to non-fork replay fixtures: - `visibleTurnItems` mirrors canonical local `turnItems` for simple, multi-turn, queue, - steering, interrupt, rollback, planning, tool, and web-search fixtures. -- [ ] Ensure projections render rollback state consistently: - rolled-back runs/nodes/items, stale checkpoints, and active provider thread cursor. -- [ ] Add projection tests for interrupt edge cases: - provider emits chunks after interrupt requested, provider ignores/delays interrupt, interrupt is - immediately followed by queue/steer/start. -- [ ] Add projection tests for queue/steer flows: - queued message visibility, queue reorder, promote queued message to steer, and post-interrupt - dispatch visibility. - -## Context Transfer And Merge-Back - -Target docs: - -- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md) -- [`provider-switching-and-context.md`](../../../../docs/orchestration-v2/provider-switching-and-context.md) - -TODO: - -- [x] Implement merge-back from a fork into its source thread: - source fork point as `basePoint`, fork latest stable point as `sourcePoint`, and source-thread - next user message as the consuming run. -- [x] Materialize delta context artifacts for merge-back and persist them as auditable - `ContextHandoff` records. -- [x] Add replay-backed integration coverage for merge-back: - fork, explore in fork, merge back, then assert source provider receives only the fork delta plus - the new user message. -- [ ] Implement portable context handoff for cross-provider forks and same-thread provider switches. -- [ ] Define explicit failure states for unresolved context transfers: - missing source point, unsupported provider capability, context too large, source projection not - stable, and adapter resolution failure. - -## Capability And Policy Model - -Target docs: - -- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md) -- [`feature-lifecycles.md`](../../../../docs/orchestration-v2/feature-lifecycles.md) - -TODO: - -- [ ] Audit capability use against the documented nested `OrchestrationV2ProviderCapabilities` - shape and fill any behavior gaps. -- [ ] Keep orchestration decisions capability/policy driven. Shared runtime code should not branch on - provider name except at adapter registration/resolution boundaries. -- [ ] Make optional adapter methods impossible to call without going through a policy wrapper or a - capability-checked branch. -- [ ] Add typed capability/policy errors for: - native fork unavailable, rollback unavailable, steering unavailable, interrupt unavailable, - context handoff unavailable, and weak terminal status. -- [ ] Add capability-aware tests using real adapter/test layers at provider boundaries only. Do not mock - core orchestration policy. - -## Checkpoint And Rollback - -Target docs: - -- [`feature-lifecycles.md`](../../../../docs/orchestration-v2/feature-lifecycles.md) -- [`core-graph-and-data-model.md`](../../../../docs/orchestration-v2/core-graph-and-data-model.md) - -TODO: - -- [ ] Document current `checkpoint.rollback` command semantics in contracts/docs as full revert: - filesystem restore plus provider conversation rollback. -- [ ] Decide whether we need separate commands for conversation-only rollback and filesystem-only - restore. Do not add them until a real product flow needs them. -- [ ] Add tests for rollback after fork and rollback inside fork: - source rollback should not corrupt child lineage, and fork rollback should not mutate source - provider state. -- [ ] Validate rollback behavior when no active provider thread exists. Current behavior fails; decide - whether a filesystem-only rollback fallback is useful or too surprising. - -## Provider Switching And Second Adapter - -Target docs: - -- [`provider-switching-and-context.md`](../../../../docs/orchestration-v2/provider-switching-and-context.md) -- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md) -- [`testing-strategy.md`](../../../../docs/orchestration-v2/testing-strategy.md) - -TODO: - -- [x] Add Claude replay fixture definitions for recorded fixtures, with TODO fixture slots that - reference the corresponding Codex transcripts and V2 docs. -- [ ] Promote Claude `simple` from the replay adapter to a real `ClaudeAdapterV2` replay test: - live and replay both consume an injected Agent SDK `query()` async iterable. -- [ ] Record Claude `multi_turn` from real usage and prove native session/thread continuation. -- [ ] Record Claude `tool_call_read_only` from real usage and prove read-only tool projection without - approvals. -- [ ] Keep unrecorded Claude fixtures out of `testkit/fixtures/index.ts` until each has a real - transcript and real adapter assertions. -- [ ] Add the second adapter once these vv0 Claude slices are stable enough to validate - cross-provider behavior. -- [ ] Use the second adapter to test: - cross-provider fork, same-thread provider switch, returning to a previous provider thread with - delta handoff, and unsupported capability fallback paths. - -## Subagents - -Target docs: - -- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md) -- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md) - -TODO: - -- [x] Add provider-native subagent observation for Codex and Claude replay-backed fixtures. -- [ ] Model native subagents and app-owned cross-provider subagents as related thread/subthread graph - entries with different creator/lifecycle policy. -- [ ] Preserve native provider subagent refs where available, but do not make the app graph depend on - provider-native ids as primary ids. -- [ ] Add deeper tests for subagent wait, close, result transfer, pending approvals, failed/stopped - tasks, and fork-from-subagent behavior. - -FOOD FOR THOUGHT: - -- Custom t3code tools/mcp_server that lets agents spawn subagents of other providers powered by the T3 Orchestrator - -## Debugger-Only Work - -- Keep debugger UI useful but temporary. Backend semantics and projections should be the source of - truth. -- [x] Wire the debugger thread tree to persisted V2 shell state through websocket RPC instead of - debugger-local thread discovery state. -- Continue exposing lightweight controls for new backend surfaces: - fork from response, new thread, full revert from user message checkpoint, merge-back, and provider - switch. -- Avoid adding mock backend behavior for debugger convenience. In-memory debugger state is fine for - layout affordances, but backend behavior must route through V2 commands/projections. diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts index 0cb5b781cc5e..7872d416190f 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Index setup composed by migration 050. +// Index setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts index 69cc856a8f34..1365d886a2d9 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts @@ -30,7 +30,7 @@ interface ProjectProjectionRow { const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -// Event-store setup and V1 project baseline composed by migration 050. +// Event-store setup and V1 project baseline composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts index 2d2185d8caa9..e04363684ab6 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts index a04a576c8de7..3ee1cde819bf 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; /** - * Production-facing V2 persistence setup composed by migration 050. + * Production-facing V2 persistence setup composed by migration 055. * * The original V2 schema used a single `provider` column for both configured * instance routing and driver identity. Keep those columns in place for diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts index 4e3d94c57f1c..915aa31227ab 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts @@ -5,7 +5,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; * Tracks the incremental import of v1 materialized thread state into the v2 * event model. Shells are imported synchronously at startup; full transcripts * are hydrated on demand and by a low-priority background pass. This table - * setup is composed by migration 050. + * setup is composed by migration 055. */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts index 10eb0132b6ed..769da3c4ca8f 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts index 43db54a55676..4bd8f6251459 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Index setup composed by migration 050. +// Index setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts index 2d2cbfba93c1..f35830314516 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts index 577611201432..5a9ab2a5d6cd 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Index setup composed by migration 050. +// Index setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts index 65641532ab38..a874987634c3 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts index 5763312dd2fd..1e5557c60821 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql` diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 960e2459fb30..ec7a10e403cc 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -371,7 +371,7 @@ export const make = Effect.gen(function* () { return; yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderCommandReactor recreates the checkout + // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 443acb03658d..999edad92545 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -1,40 +1,62 @@ { - "integration/orchestrationEngine.integration.test.ts": 1.9, - "integration/providerService.integration.test.ts": 0.5, - "src/bin.test.ts": 0.8, - "src/checkpointing/CheckpointStore.test.ts": 0.6, - "src/device/sshDeviceScript.test.ts": 2.9, - "src/git/GitManager.test.ts": 18.7, - "src/orchestration/Layers/CheckpointReactor.test.ts": 7.1, - "src/orchestration/Layers/OrchestrationEngine.test.ts": 1.4, - "src/orchestration/Layers/ProjectionPipeline.test.ts": 0.5, - "src/orchestration/Layers/ProviderCommandReactor.test.ts": 3.8, - "src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 2.4, - "src/persistence/Layers/OrchestrationEventStore.test.ts": 2.2, - "src/process/externalLauncher.test.ts": 2.2, - "src/project/AgentSessionScanner.test.ts": 4.3, - "src/provider/CodexChatGptAuth.test.ts": 5.9, - "src/provider/Drivers/AntigravityDriver.test.ts": 2, - "src/provider/Layers/AntigravityAdapter.test.ts": 0.5, - "src/provider/Layers/ClaudeAdapter.test.ts": 0.7, - "src/provider/Layers/CodexCollabRuntime.integration.test.ts": 3.4, - "src/provider/Layers/CursorAdapter.test.ts": 8, - "src/provider/Layers/CursorProvider.test.ts": 1.4, - "src/provider/Layers/GrokAdapter.test.ts": 9, - "src/provider/Layers/GrokProvider.test.ts": 0.7, - "src/provider/Layers/ProviderService.test.ts": 0.5, - "src/provider/acp/AcpJsonRpcConnection.test.ts": 6.2, - "src/provider/acp/XAiAcpExtension.test.ts": 0.6, + "integration/transferBudgetV2.integration.test.ts": 0.7, + "src/auth/SessionStore.test.ts": 0.5, + "src/checkpointing/CheckpointStore.test.ts": 0.7, + "src/cli/project.test.ts": 0.7, + "src/device/sshDeviceScript.test.ts": 3, + "src/git/GitManager.test.ts": 22.7, + "src/git/detachStackFrame.memory.test.ts": 0.5, + "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 9.1, + "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 52.3, + "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 1.8, + "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.1, + "src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts": 0.6, + "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 0.6, + "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 0.8, + "src/orchestration-v2/FoundationPersistence.test.ts": 2.6, + "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 6, + "src/orchestration-v2/ProjectionStore.test.ts": 0.7, + "src/orchestration-v2/ProviderSessionManager.test.ts": 0.9, + "src/orchestration-v2/ProviderTurnStartService.memory.test.ts": 6.5, + "src/orchestration-v2/SelectionRestart.integration.test.ts": 2.5, + "src/orchestration-v2/SteeringCompletion.integration.test.ts": 2.7, + "src/orchestration-v2/ThreadLaunchService.test.ts": 2, + "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 0.5, + "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 0.5, + "src/orchestration-v2/runtimeLayer.test.ts": 0.9, + "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 0.6, + "src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts": 53.4, + "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 1.4, + "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 1.9, + "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4, + "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7, + "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3, + "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9, + "src/persistence/Layers/Sqlite.test.ts": 0.5, + "src/process/externalLauncher.test.ts": 2.3, + "src/project/AgentSessionScanner.test.ts": 4.2, + "src/project/ManagedProjectFolders.test.ts": 0.5, + "src/provider/CodexChatGptAuth.test.ts": 5.4, + "src/provider/Drivers/AntigravityDriver.test.ts": 2.3, + "src/provider/Layers/GrokProvider.test.ts": 0.9, + "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5, + "src/provider/OpenCodeServerLedger.test.ts": 1.7, + "src/provider/acp/AcpClientTerminals.test.ts": 1.3, + "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2, + "src/provider/acp/AcpRegistryProbe.test.ts": 1, + "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 4.3, + "src/provider/cursorSdk.test.ts": 0.7, + "src/provider/opencode2/OpenCode2Server.test.ts": 1.2, "src/provider/opencodeRuntime.environment.test.ts": 1.1, - "src/pullRequest/PullRequestService.test.ts": 3.1, - "src/server.test.ts": 10.2, - "src/serverSettings.test.ts": 0.5, + "src/pullRequest/PullRequestService.test.ts": 5.1, + "src/rpcInitialItems.memory.test.ts": 5.3, + "src/serverSettings.test.ts": 1, "src/serviceLauncher.test.ts": 6.2, - "src/terminal/Manager.test.ts": 1.6, - "src/textGeneration/CursorTextGeneration.test.ts": 0.8, - "src/textGeneration/GrokTextGeneration.test.ts": 1.2, + "src/sourceControl/PrTemplateDetection.test.ts": 0.5, + "src/terminal/Manager.test.ts": 1.8, + "src/textGeneration/CodexTextGeneration.test.ts": 0.5, "src/usage/usageTranscriptStreaming.test.ts": 0.8, - "src/vcs/GitVcsDriver.test.ts": 3.7, - "src/vcs/GitVcsDriverCore.test.ts": 6.5, - "src/workspace/WorkspaceEntries.test.ts": 1.9 + "src/vcs/GitVcsDriver.test.ts": 4.5, + "src/vcs/GitVcsDriverCore.test.ts": 6.4, + "src/workspace/WorkspaceEntries.test.ts": 2.1 } diff --git a/docs/internals/composer-context-references.md b/docs/internals/composer-context-references.md index 855f327d6165..3f71261aa762 100644 --- a/docs/internals/composer-context-references.md +++ b/docs/internals/composer-context-references.md @@ -123,7 +123,7 @@ trailing `` form is parsed only when reading messages sent by The composer sends `message.text` as canonical prose with reference links and `message.context.records` built from the draft (`buildMessageContext` in `apps/web/src/lib/composerContextRecords.ts`). Expired terminal excerpts are dropped from both. -The server projects provider text at turn start (`ProviderCommandReactor`), so the persisted +The server projects provider text at turn start (`projectComposerContextForProvider`), so the persisted message stays readable and the provider receives markers plus one envelope. Review comments and preview annotations enter the draft through store mutators. A mounted composer diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 0d37f4c379f9..7a082c17e150 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -60,7 +60,7 @@ a shim directory to the provider's PATH. The CLI installs on the environment server even when that server cannot run simulators. Hosts start on demand. That environment is fixed when the provider subprocess spawns, so -[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts) +[`prepareMcpSession`](../../apps/server/src/orchestration-v2/ProviderSessionManager.ts) starts agent-device only when device support and agent access have both been enabled, the session has the `device` capability, and the machine can run at least one platform. Starting it later from `device_open` would leave the diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 019c9d53023a..88c2b6057d7e 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -13,23 +13,21 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Workspace root | The project's base filesystem directory on the environment. | | Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | | Thread | The durable conversation and work history for a project. It survives provider process exits. | -| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | +| Turn | One user-to-agent cycle, a V2 run. Provider work can end before checkpoint and diff work settles. | | Activity | A non-message timeline item, such as a tool action, approval, or failure. | | T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | ## Orchestration -| Term | Meaning | -| ----------------------- | -------------------------------------------------------------------------------------------- | -| Command | A request to change domain state. Accepting it does not mean its side effects have finished. | -| Event | A persisted fact produced by a command. | -| Decider | The pure logic that turns a command and current state into events. | -| Projection / read model | A view of current state derived from persisted events. | -| Projector | The logic that applies events to a read model. | -| Reactor | A worker that performs follow-up work in response to recorded intent or runtime signals. | -| Command receipt | A durable record of a command's result, used to make retries idempotent. | -| Runtime receipt | A test-only signal that an asynchronous milestone completed. | -| Quiesced | The relevant follow-up workers have finished, beyond the provider turn merely ending. | +| Term | Meaning | +| ----------------------- | --------------------------------------------------------------------------------------------------------- | +| Command | A request to change domain state. Accepting it does not mean its side effects have finished. | +| Event | A persisted fact produced by a command. | +| Orchestrator | The service that serializes commands and decides their events from current state, without I/O. | +| Projection / read model | A persisted view of current state, committed in the same transaction as the events that change it. | +| Command receipt | A durable record of a command's result, used to make retries idempotent. | +| Outbox effect | Side-effect intent committed with the events, such as starting a provider turn or capturing a checkpoint. | +| Effect worker | The worker that runs outbox effects after commit and feeds their results back as commands. | ## Providers and checkpoints @@ -51,7 +49,7 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Term | Meaning | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pull request link | A persisted thread association identified by host, repository, and number. Links can cross projects within an environment and carry a server-maintained snapshot. | -| Pull request sync | The reactor that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. | +| Pull request sync | The worker that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. | | Current pull request | The link used by single-review controls and older clients. Open work takes precedence; a completed single chain points at its top layer. Unrelated terminal links use the latest update. | ## Composer context diff --git a/docs/internals/providers.md b/docs/internals/providers.md index c1def5f3528a..a68980e54845 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -3,7 +3,7 @@ Orchestration records intent and state without knowing which provider runs a thread. Provider protocols, account ownership, permissions, and capabilities belong at the [adapter boundary](../../apps/server/src/orchestration-v2/ProviderAdapter.ts). Normalize there -instead of spreading provider checks through reactors and clients. +instead of spreading provider checks through orchestration and clients. A driver kind identifies an integration; an instance identifies one configuration and account lifecycle. Route work by instance, so two accounts using the same driver do not share mutable @@ -11,16 +11,23 @@ session or catalog state. ## Process and account isolation -T3-managed OpenCode chat uses one server per thread. Its MCP registrations are directory-scoped, while -T3's MCP connection is thread-scoped. Sharing a chat server between threads in one directory would -let them replace each other's connection. Catalog and text-generation work can share the -[instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes -after an idle period. External OpenCode servers remain externally owned and can require an -external restart to pick up configuration changes. - -OpenCode also stores persistent approval grants per directory. Automatic full-access replies use -`once` so they cannot widen a supervised thread's permissions on a shared external server. -See the [adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). +The `opencode` driver probes the installed version and runs the 1.x or 2.x runtime. OpenCode's MCP +registrations are directory-scoped, while T3's MCP connection is thread-scoped, so threads in one +directory must not share one T3 MCP entry. + +- **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's + connection. Catalog and text-generation work can share the + [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes + after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). +- **2.x** serves every directory from one + [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread + registers its own `t3-code-` MCP entry, and session permission rules deny every other + thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts). + +External OpenCode servers remain externally owned and can require an external restart to pick up +configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic +full-access replies use `once` so they cannot widen a supervised thread's permissions on a shared +server. On 2.x, a session-wide approval also replies `once` and becomes T3's own rule on that session. Pi runs the user's own `pi` install in RPC mode and owns native extension, package, and project trust discovery. T3 injects only its namespaced MCP bridge, so a Pi session behaves as it does in diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index ae90cb2f29bd..5a5d13b696f7 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -19,9 +19,9 @@ const EFFECT_RUNTIME_METHODS = new Set([ "runSyncWith", ]); -// Existing manual runners are tracked as debt through the `maxOccurrences` -// option, set per file in the lint config. The rule permits no net-new -// occurrences in those files, while every other test file must have zero. +// The lint config can set `maxOccurrences` per file to track existing manual +// runners as debt. The rule permits no net-new occurrences in those files, +// while every other test file must have zero. const readMaxOccurrences = (options: ReadonlyArray): number => { const [first] = options; return typeof first === "object" && diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 9153819ecbdf..044c92029c51 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -427,7 +427,11 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } -const SEEDED_PROJECTION_TABLES = [ +// V1 tables this seed owns. `projection_projects` is not listed: V2 still +// stores projects there, so the seed upserts its own rows instead. V2 clients +// do not read the V1 thread rows; moving the seed to V2 is tracked in +// https://github.com/pingdotgg/t3code/issues/15013. +const SEEDED_V1_TABLES = [ "projection_pending_approvals", "projection_thread_proposed_plans", "projection_thread_activities", @@ -435,10 +439,11 @@ const SEEDED_PROJECTION_TABLES = [ "projection_thread_sessions", "projection_turns", "projection_threads", - "projection_projects", "projection_state", ] as const; +const SEEDED_PROJECTION_TABLES = [...SEEDED_V1_TABLES, "projection_projects"] as const; + const SEEDED_THREAD_COLUMNS = ["snoozed_until", "snoozed_at"] as const; function hasSeedableSchema(dbPath: string): boolean { @@ -491,11 +496,11 @@ function seedDatabase( const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); - for (const table of SEEDED_PROJECTION_TABLES) { + for (const table of SEEDED_V1_TABLES) { database.exec(`DELETE FROM ${table}`); } const insertProject = database.prepare( - `INSERT INTO projection_projects ( + `INSERT OR REPLACE INTO projection_projects ( project_id, title, workspace_root, default_model_selection_json, scripts_json, created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`, diff --git a/vite.config.ts b/vite.config.ts index 5961c106d640..97729f5784b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -333,27 +333,6 @@ export default defineConfig({ "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], }, }, - // Legacy manual Effect runners tracked as debt: no net-new occurrences. - // Lower a ceiling when you migrate a file, and delete its entry at zero. - ...Object.entries({ - "apps/server/src/orchestration/Layers/CheckpointReactor.test.ts": 42, - "apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts": 5, - "apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts": 4, - "apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts": 66, - "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 29, - "apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts": 2, - "apps/server/src/orchestration/commandInvariants.test.ts": 5, - "apps/server/src/orchestration/projector.test.ts": 20, - "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1, - "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1, - "apps/server/src/provider/Layers/CursorProvider.test.ts": 1, - "apps/server/src/provider/Layers/ProviderService.test.ts": 2, - "apps/server/src/provider/Layers/ProviderSessionReaper.test.ts": 12, - "apps/server/src/provider/acp/CursorAcpSupport.test.ts": 1, - }).map(([file, maxOccurrences]) => { - const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }]; - return { files: [file], rules: { "t3code/no-manual-effect-runtime-in-tests": rule } }; - }), ], options: { reportUnusedDisableDirectives: "error", From 8283b481a34f5cc6f984f290e7cb93cc35b4dc56 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 02:34:54 -0300 Subject: [PATCH 21/82] fix(server): Claude V2 turns start on Windows with the default binary path (#15021) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../Adapters/ClaudeAdapterV2.test.ts | 140 +++++++++++------- .../Adapters/ClaudeAdapterV2.ts | 7 +- 2 files changed, 90 insertions(+), 57 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a4164c936fdf..f14cbd3e8724 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,8 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { Tool } from "effect/unstable/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; @@ -55,6 +57,7 @@ import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -1064,72 +1067,97 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); +// Opens a session with the given configured binary path, runs one turn, and +// returns the executable paths the SDK was asked to spawn. +const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( + binaryPath: string, +) { + const executablePaths: Array = []; + const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( + { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: [], + enabled: true, + config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, + }, + {}, + ).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-claude-binary-path-", + }), + ), + Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { + allocateSessionId: Effect.succeed("native-thread-claude-binary-path"), + open: (input) => + Effect.sync(() => { + executablePaths.push(input.options.pathToClaudeCodeExecutable); + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }), + ); + const threadId = ThreadId.make("thread-claude-binary-path"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-binary-path"), + 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, + }); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-binary-path"), + text: "hello", + attachments: [], + }), + ); + return executablePaths; +}); + describe("ClaudeAdapterV2 executable path", () => { it.effect("expands ~ in the configured binary path for the SDK", () => Effect.scoped( Effect.gen(function* () { const path = yield* Path.Path; - const executablePaths: Array = []; - const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( - { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - displayName: undefined, - environment: [], - enabled: true, - config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath: "~/bin/claude" }, - }, - {}, - ).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-claude-binary-home-", - }), - ), - Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { - allocateSessionId: Effect.succeed("native-thread-claude-binary-home"), - open: (input) => - Effect.sync(() => { - executablePaths.push(input.options.pathToClaudeCodeExecutable); - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }), - ); - const threadId = ThreadId.make("thread-claude-binary-home"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-binary-home"), - 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, - }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-binary-home"), - text: "hello", - attachments: [], - }), - ); + const executablePaths = yield* captureSdkExecutablePaths("~/bin/claude"); assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); }), ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + + it.effect("follows a bare claude on Windows to the npm package executable", () => + Effect.scoped( + Effect.gen(function* () { + const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; + const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const executablePaths = yield* captureSdkExecutablePaths("claude").pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), + ); + + assert.deepEqual(executablePaths, [packageExe]); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); }); describe("ClaudeAdapterV2 resume compaction", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2cf91c619594..6e61738ec1f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -87,6 +87,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; @@ -7666,9 +7667,13 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; + const binaryPath = yield* resolveClaudeSdkExecutablePath( + expandHomePath(config.binaryPath), + claudeEnvironment, + ); return makeClaudeAdapterV2({ instanceId, - settings: { ...config, enabled, binaryPath: expandHomePath(config.binaryPath) }, + settings: { ...config, enabled, binaryPath }, environment: claudeEnvironment, attachmentsDir: serverConfig.attachmentsDir, fileSystem, From d1034d62b28e19c19240085566a39a92c5f9b466 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 22:52:42 -0700 Subject: [PATCH 22/82] fix(web): diff panel opens on all branch changes, not just uncommitted (#15005) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/review/ReviewSheet.tsx | 4 +- .../src/features/review/reviewModel.test.ts | 35 +- .../mobile/src/features/review/reviewModel.ts | 24 +- .../features/threads/git/GitOverviewSheet.tsx | 2 +- apps/server/src/git/GitManager.ts | 7 +- apps/server/src/vcs/GitVcsDriver.ts | 3 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 185 +++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 331 ++++++++++++------ .../src/vcs/VcsStatusBroadcaster.test.ts | 100 +++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 20 +- apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/DiffPanel.tsx | 20 +- apps/web/src/components/GitActionsControl.tsx | 10 +- .../components/settings/SettingsPanels.tsx | 2 +- apps/web/src/diffPanelStore.test.ts | 21 +- apps/web/src/diffPanelStore.ts | 3 +- packages/contracts/src/git.ts | 11 + packages/shared/src/git.ts | 1 + 18 files changed, 611 insertions(+), 176 deletions(-) diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 08888dd469d2..20c87b496ab3 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -126,8 +126,8 @@ function ReviewHeader( id: "sections", inline: true, items: [ - sectionAction(props.sectionMenu.workingTree, "Working tree"), - sectionAction(props.sectionMenu.branchChanges, "Branch changes"), + sectionAction(props.sectionMenu.branchChanges, "Changes"), + sectionAction(props.sectionMenu.workingTree, "Uncommitted"), sectionAction(props.sectionMenu.latestTurn, "Latest turn"), ], }, diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 2af0fb4ba063..9c2cc9b567c9 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -62,7 +62,7 @@ describe("buildReviewSectionItems", () => { { id: "working-tree", kind: "working-tree", - title: "Dirty worktree", + title: "Uncommitted", baseRef: "HEAD", headRef: null, diff: "diff --git a/a.ts b/a.ts", @@ -72,7 +72,7 @@ describe("buildReviewSectionItems", () => { { id: "branch-range", kind: "branch-range", - title: "Against main", + title: "Changes vs main", baseRef: "main", headRef: "feature", diff: "diff --git a/a.ts b/a.ts", @@ -105,10 +105,28 @@ describe("buildReviewSectionItems", () => { isLoading: false, diff: expect.stringContaining("loaded.ts"), }); - expect(getDefaultReviewSectionId(items)).toBe("turn:2"); + expect(getDefaultReviewSectionId(items)).toBe("git:branch-range"); }); - it("shows dirty worktree while git preview is loading", () => { + it("falls back to the first turn without git sections", () => { + const items = buildReviewSectionItems({ + checkpoints: [ + makeCheckpoint({ + runId: RunId.make("run-1"), + checkpointTurnCount: 1, + completedAt: "2026-04-01T00:00:00.000Z", + }), + ], + gitSections: [], + turnDiffById: {}, + loadingTurnIds: {}, + loadingGitSections: false, + }); + + expect(getDefaultReviewSectionId(items)).toBe("turn:1"); + }); + + it("holds the Changes place while git preview is loading", () => { const items = buildReviewSectionItems({ checkpoints: [], gitSections: [], @@ -119,15 +137,14 @@ describe("buildReviewSectionItems", () => { expect(items).toEqual([ expect.objectContaining({ - id: "git:working-tree", - kind: "working-tree", - title: "Dirty worktree", - subtitle: "Tracked, staged, and untracked worktree changes", + id: "git:branch-range", + kind: "branch-range", + title: "Changes", diff: null, isLoading: true, }), ]); - expect(getDefaultReviewSectionId(items)).toBe("git:working-tree"); + expect(getDefaultReviewSectionId(items)).toBe("git:branch-range"); }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index aa935bd28710..86ded9a6264e 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -9,9 +9,9 @@ import * as Order from "effect/Order"; export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; -const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; -const DIRTY_WORKTREE_TITLE = "Dirty worktree"; -const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes"; +const CHANGES_SECTION_ID = "git:branch-range"; +const CHANGES_TITLE = "Changes"; +const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files"; export interface ReviewSectionItem { readonly id: string; @@ -124,7 +124,7 @@ const readyCheckpointOrder = Order.make( function gitSubtitle(section: ReviewDiffPreviewSource): string | null { if (section.kind === "working-tree") { - return DIRTY_WORKTREE_SUBTITLE; + return UNCOMMITTED_SUBTITLE; } if (section.baseRef) { return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; @@ -443,15 +443,16 @@ export function buildReviewSectionItems(input: { truncated: section.truncated, isLoading: false, })); - const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID); + // Changes is the default section, so it holds the place while git sources load. + const hasChangesItem = gitItems.some((item) => item.id === CHANGES_SECTION_ID); const visibleGitItems = - input.loadingGitSections && !hasDirtyWorktreeItem + input.loadingGitSections && !hasChangesItem ? [ { - id: DIRTY_WORKTREE_SECTION_ID, - kind: "working-tree", - title: DIRTY_WORKTREE_TITLE, - subtitle: DIRTY_WORKTREE_SUBTITLE, + id: CHANGES_SECTION_ID, + kind: "branch-range", + title: CHANGES_TITLE, + subtitle: null, diff: null, isLoading: true, } satisfies ReviewSectionItem, @@ -462,10 +463,11 @@ export function buildReviewSectionItems(input: { return [...turnItems, ...visibleGitItems]; } +/** Prefers Changes, then the first section (a turn when the project is not a git repo). */ export function getDefaultReviewSectionId( sections: ReadonlyArray, ): string | null { - return sections[0]?.id ?? null; + return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null; } export function buildReviewParsedDiff( diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 831ef1145cec..17b1b3ea377c 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -282,7 +282,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { { const params = { environmentId, threadId }; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d2a244e40f05..2b88cd79f8e6 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1000,7 +1000,7 @@ export const make = Effect.gen(function* () { const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); const normalizeStatusCacheKey = canonicalizeExistingPath; - const nonRepositoryStatusDetails = { + const nonRepositoryStatusDetails: GitVcsDriver.GitStatusDetails = { isRepo: false, hasOriginRemote: false, isDefaultBranch: false, @@ -1012,10 +1012,10 @@ export const make = Effect.gen(function* () { aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, - } satisfies GitVcsDriver.GitStatusDetails; + }; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore - .statusDetailsLocal(cwd, { includeDivergence: false }) + .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true }) .pipe( Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), ); @@ -1031,6 +1031,7 @@ export const make = Effect.gen(function* () { refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, + ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}), } satisfies VcsStatusLocalResult; }); const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d7e511afa8c9..99220a8bf78f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -76,6 +76,7 @@ export interface GitStatusDetails { upstreamRef: string | null; hasWorkingTreeChanges: boolean; workingTree: VcsStatusResult["workingTree"]; + branchChanges?: VcsStatusResult["branchChanges"]; hasUpstream: boolean; aheadCount: number; behindCount: number; @@ -85,6 +86,8 @@ export interface GitStatusDetails { export interface GitLocalStatusOptions { /** Skip revision walks and return zero divergence counts for local-only consumers. */ readonly includeDivergence?: boolean; + /** Also read the diff panel's Changes totals. Failures leave them out. */ + readonly includeBranchChanges?: boolean; } export interface GitRemoteStatusDetails { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 32dc3441dd6a..fec77d110dbb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type ReviewDiffPreviewResult, type WorktreeSubmodules, } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; @@ -1128,6 +1129,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + const mergeBase = yield* git(cwd, ["rev-parse", "HEAD"]); yield* writeTextFile(cwd, "untracked.txt", "untracked content\n"); const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`); const stats = paths.map((path) => `1\t0\t${path}\0`).join(""); @@ -1138,10 +1140,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => { if (ChildProcess.isStandardCommand(command)) { - if ( - command.args.includes("--numstat") && - command.args.includes(`${initialBranch}...HEAD`) - ) { + if (command.args.includes("--numstat") && command.args.includes(mergeBase)) { return Effect.succeed(makeSuccessfulHandle(stats)); } if (command.args.includes("ls-files") && command.args.includes("--others")) { @@ -1566,6 +1565,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(branch.files, [ { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 }, + { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 }, { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 }, ]); assert.deepStrictEqual(dirty.files, [ @@ -1740,7 +1740,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("loads merge-base and head contents for branch diff expansion", () => + it.effect("loads merge-base and disk contents for Changes expansion", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); @@ -1749,6 +1749,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n"); yield* git(cwd, ["add", "README.md"]); yield* git(cwd, ["commit", "-m", "change readme"]); + yield* writeTextFile(cwd, "README.md", "# dirty change\nunchanged context\n"); const contents = yield* driver.getReviewDiffFileContents( makeReviewDiffFileContentsInput(cwd, { @@ -1759,12 +1760,184 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); assert.strictEqual(contents.oldContents, "# test\n"); - assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n"); + assert.strictEqual(contents.newContents, "# dirty change\nunchanged context\n"); + }), + ); + + it.effect("Changes combines commits, uncommitted edits, and untracked files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const sourceFiles = ( + preview: ReviewDiffPreviewResult, + kind: "working-tree" | "branch-range", + ) => preview.sources.find((source) => source.kind === kind)!.files; + yield* git(cwd, ["checkout", "-b", "feature/combined"]); + yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n"); + yield* git(cwd, ["commit", "-am", "commit edit"]); + + // A fully committed branch has no uncommitted work, but Changes still shows it. + const clean = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + assert.deepStrictEqual(sourceFiles(clean, "working-tree"), []); + assert.deepStrictEqual(sourceFiles(clean, "branch-range"), [ + { path: "README.md", previousPath: null, additions: 1, deletions: 0 }, + ]); + + yield* writeTextFile(cwd, "README.md", "# test\ncommitted\ndirty\n"); + yield* writeTextFile(cwd, "new.txt", "new\n"); + const dirty = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + const changes = dirty.sources.find((source) => source.kind === "branch-range")!; + assert.deepStrictEqual(changes.files, [ + { path: "README.md", previousPath: null, additions: 2, deletions: 0 }, + { path: "new.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.strictEqual(changes.diff.match(/^diff --git a\/README\.md /gm)?.length, 1); + assert.include(changes.diff, "+dirty"); + assert.deepStrictEqual(sourceFiles(dirty, "working-tree"), [ + { path: "README.md", previousPath: null, additions: 1, deletions: 0 }, + { path: "new.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + }), + ); + + it.effect("Changes on the default branch compares with its remote copy", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* writeTextFile(cwd, "unpushed.txt", "unpushed\n"); + yield* git(cwd, ["add", "unpushed.txt"]); + yield* git(cwd, ["commit", "-m", "unpushed"]); + + // Without a remote there is no base, so Changes equals Uncommitted. + const local = yield* driver.getReviewDiffPreview({ cwd }); + const localChanges = local.sources.find((source) => source.kind === "branch-range")!; + assert.isNull(localChanges.baseRef); + assert.deepStrictEqual(localChanges.files, []); + + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD~1:refs/heads/main"]); + yield* git(cwd, ["fetch", "origin"]); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.strictEqual(changes.baseRef, "origin/main"); + assert.deepStrictEqual(changes.files, [ + { path: "unpushed.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + }), + ); + + it.effect("Changes accepts an explicit base on a detached HEAD and rejects a bad one", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "--detach"]); + yield* writeTextFile(cwd, "detached.txt", "detached\n"); + yield* git(cwd, ["add", "detached.txt"]); + yield* git(cwd, ["commit", "-m", "detached work"]); + + const explicit = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + assert.deepStrictEqual( + explicit.sources.find((source) => source.kind === "branch-range")!.files, + [{ path: "detached.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + const implicit = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + implicit.sources.find((source) => source.kind === "branch-range")!.files, + [], + ); + const error = yield* driver + .getReviewDiffPreview({ cwd, baseRef: "missing-base" }) + .pipe(Effect.flip); + assert.strictEqual(error.operation, "GitVcsDriver.resolveReviewMergeBase"); + }), + ); + + it.effect("Changes compares with the empty tree before the first commit", () => + Effect.gen(function* () { + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(upstream); + yield* git(upstream, ["branch", "-M", "main"]); + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + // An unborn main whose remote copy exists must not fail on merge-base. + yield* git(cwd, ["init", "-b", "main"]); + yield* git(cwd, ["remote", "add", "origin", upstream]); + yield* git(cwd, ["fetch", "origin"]); + yield* writeTextFile(cwd, "new.txt", "one\ntwo\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.isNull(changes.baseRef); + assert.deepStrictEqual(changes.files, [ + { path: "new.txt", previousPath: null, additions: 2, deletions: 0 }, + ]); + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: null, + insertions: 2, + deletions: 0, + }); + }), + ); + + it.effect("Changes does not show newer base commits as deletions", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/rebased"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature work"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "base moves"]); + yield* git(cwd, ["checkout", "feature/rebased"]); + + for (const rebase of [false, true]) { + if (rebase) yield* git(cwd, ["rebase", initialBranch]); + const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "branch-range")!.files, + [{ path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + } }), ); }); describe("repository status", () => { + it.effect("reads Changes totals with untracked files when requested", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/totals"]); + yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n"); + yield* git(cwd, ["commit", "-am", "commit edit"]); + yield* writeTextFile(cwd, "nested/untracked.txt", "one\ntwo\n"); + + const status = yield* driver.statusDetailsLocal(pathService.join(cwd, "nested"), { + includeBranchChanges: true, + }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: "main", + insertions: 3, + deletions: 0, + }); + assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).branchChanges); + }), + ); + it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 61c84424219b..971d0e7a9a95 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -62,6 +62,16 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would // otherwise leak into the patch and leave every parsed file unnamed. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +// Shared by review previews and status totals, so the Changes row matches the Changes view. +const REVIEW_DIFF_ARGS = [ + "diff", + "--find-renames", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, +]; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -1518,9 +1528,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return remoteName; }); + // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy. const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, refName: string, + options?: { readonly allowRemoteOfCurrent?: boolean }, ) { const configuredBaseBranch = yield* runGitStdout( "GitVcsDriver.resolveBaseBranchForNoUpstream.config", @@ -1552,7 +1564,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : remotePrefix && candidate.startsWith(remotePrefix) ? candidate.slice(remotePrefix.length) : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { + if (normalizedCandidate.length === 0) { + continue; + } + if (normalizedCandidate === refName) { + if ( + options?.allowRemoteOfCurrent && + primaryRemoteName && + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } continue; } @@ -1923,6 +1949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } files.sort((a, b) => a.path.localeCompare(b.path)); + const branchChanges = options?.includeBranchChanges + ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined; + return { isRepo: true, hasOriginRemote: hasPrimaryRemote, @@ -1935,6 +1967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* insertions, deletions, }, + ...(branchChanges ? { branchChanges } : {}), hasUpstream: upstreamRef !== null, aheadCount, behindCount, @@ -2413,6 +2446,145 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return env; }); + // Before the first commit, review diffs compare with the empty tree instead of HEAD. + const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) { + const stdout = yield* runGitStdout("GitVcsDriver.review.emptyTree", cwd, [ + "hash-object", + "-t", + "tree", + (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", + ]); + return stdout.trim(); + }); + + // Lists untracked files and adds them to a temporary index, so a diff against any commit + // shows them as new. Returns null when the list is too big to read. Needs a Scope. + const prepareUntrackedReviewIndex = Effect.fn("prepareUntrackedReviewIndex")(function* ( + cwd: string, + pathArgs: ReadonlyArray, + onlyPath?: string, + ) { + const untracked = yield* executeGit( + "GitVcsDriver.review.listUntracked", + cwd, + ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ).pipe( + Effect.catchIf( + (error) => error.outputLength === undefined, + () => Effect.succeed(null), + ), + ); + if (untracked === null) return null; + const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( + (candidate) => onlyPath === undefined || candidate === onlyPath, + ); + if (paths.length === 0) return { env: undefined }; + const env = yield* prepareReviewIndex(cwd, paths).pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.prepareReviewIndex", + cwd, + command: "git diff", + detail: "Could not prepare the review index.", + cause, + }), + ), + }), + ); + return { env }; + }); + + // The diff panel's Changes view compares the working tree with merge-base(base, HEAD). + // With no usable base it compares with HEAD, so Changes equals Uncommitted. + const resolveReviewMergeBase = Effect.fn("resolveReviewMergeBase")(function* ( + cwd: string, + branch: string | null, + explicitBaseRef?: string, + ) { + const baseRef = + explicitBaseRef ?? + (branch + ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe( + Effect.orElseSucceed(() => null), + ) + : null); + if (baseRef === null) return { baseRef, mergeBase: "HEAD" }; + const args = ["merge-base", baseRef, "HEAD"]; + const result = yield* executeGit("GitVcsDriver.resolveReviewMergeBase", cwd, args, { + allowNonZeroExit: true, + }); + const mergeBase = result.stdout.trim(); + if (result.exitCode !== 0 || mergeBase.length === 0) { + // Before the first commit there is nothing to compare with, so Changes equals Uncommitted. + const head = yield* executeGit( + "GitVcsDriver.resolveReviewMergeBase.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (explicitBaseRef === undefined && head.exitCode !== 0) { + return { baseRef: null, mergeBase: "HEAD" }; + } + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.resolveReviewMergeBase", cwd, args }), + detail: `Could not find a common commit between '${baseRef}' and HEAD.`, + exitCode: result.exitCode, + }); + } + return { baseRef, mergeBase }; + }); + + // Totals for the thread panel's Changes row. Same base and untracked files as the Changes view. + const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( + cwd: string, + branch: string | null, + ) { + const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch); + const untracked = yield* prepareUntrackedReviewIndex(cwd, []); + if (untracked === null) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.readBranchChangeTotals", + command: "git ls-files", + cwd, + detail: "Too many untracked files to count.", + }); + } + const readNumstat = (ref: string) => + executeGit( + "GitVcsDriver.readBranchChangeTotals", + cwd, + [...REVIEW_DIFF_ARGS, "--numstat", "-z", ref, "--"], + { + allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + env: untracked.env, + }, + ); + let result = yield* readNumstat(mergeBase); + if (result.exitCode !== 0 && mergeBase === "HEAD" && isUnbornHeadStderr(result.stderr)) { + result = yield* readNumstat(yield* readEmptyTreeHash(cwd)); + } + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.readBranchChangeTotals", + command: "git diff --numstat", + cwd, + detail: "Could not read Changes totals.", + exitCode: result.exitCode, + }); + } + let insertions = 0; + let deletions = 0; + for (const file of parseReviewNumstat(result.stdout)) { + insertions += file.additions; + deletions += file.deletions; + } + return { baseRef, insertions, deletions }; + }, Effect.scoped); + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { @@ -2439,21 +2611,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const cwd = repository.worktreeRoot; - const branch = repository.currentBranch; - const baseRef = - input.baseRef ?? - (branch - ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null)) - : null); + // A per-file request only reads its own source. + const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD"; + const review = + input.file?.sourceKind === "working-tree" + ? { baseRef: input.baseRef ?? null, mergeBase: null } + : yield* resolveReviewMergeBase(cwd, repository.currentBranch, input.baseRef); const diffArgs = [ - "diff", - "--find-renames", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--minimal", - ...PATCH_RENDER_PREFIX_ARGS, + ...REVIEW_DIFF_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), ]; const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( @@ -2469,12 +2635,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) { - const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [ - "hash-object", - "-t", - "tree", - (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", - ])).trim(); + const emptyTree = yield* readEmptyTreeHash(cwd); const stdout = yield* runGitStdoutWithOptions( "GitVcsDriver.getReviewDiffPreview.unbornStat", cwd, @@ -2491,6 +2652,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* exitCode: result.exitCode, }); }); + // One commit argument diffs that commit against the working tree. const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( ref: string | null, env?: NodeJS.ProcessEnv, @@ -2506,54 +2668,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); return { ...patch, files: stat.files }; }); - const readDirty = Effect.gen(function* () { - if (input.file?.sourceKind === "branch-range") return yield* readTrackedDiff(null); - const untracked = yield* executeGit( - "GitVcsDriver.review.listUntracked", - cwd, - ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], - { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ).pipe( - Effect.catchIf( - (error) => error.outputLength === undefined, - () => Effect.succeed(null), - ), - ); - if (untracked === null) { - const tracked = yield* readTrackedDiff("HEAD"); - return { ...tracked, files: undefined, stdoutTruncated: true }; - } - const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( - (candidate) => !input.file || candidate === input.file.path, - ); - if (paths.length === 0) return yield* readTrackedDiff("HEAD"); - const env = yield* prepareReviewIndex(cwd, paths).pipe( - Effect.catchTags({ - PlatformError: (cause) => - Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.prepareReviewIndex", - cwd, - command: "git diff", - detail: "Could not prepare the review index.", - cause, - }), - ), - }), - ); - return yield* readTrackedDiff("HEAD", env); + const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () { + const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path); + // With no base both sources diff HEAD, so read it once. + const [dirty, base] = + review.mergeBase === dirtyRef + ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe( + Effect.map((result) => [result, result] as const), + ) + : yield* Effect.all( + [ + readTrackedDiff(dirtyRef, untracked?.env), + readTrackedDiff(review.mergeBase, untracked?.env), + ], + { concurrency: 2 }, + ); + if (untracked !== null) return [dirty, base] as const; + // Too many untracked files to list: show tracked changes and mark totals incomplete. + const incomplete = (ref: string | null, result: typeof dirty) => + ref === null ? result : { ...result, files: undefined, stdoutTruncated: true }; + return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const; }).pipe(Effect.scoped); - const [dirtyTrackedResult, baseResult] = yield* Effect.all( - [ - readDirty, - readTrackedDiff( - baseRef && branch && input.file?.sourceKind !== "working-tree" - ? `${baseRef}...HEAD` - : null, - ), - ], - { concurrency: 2 }, - ); const dirtyFiles = dirtyTrackedResult.files; const baseFiles = baseResult.files; const dirtyDiff = dirtyTrackedResult.stdout; @@ -2574,14 +2709,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff, dirtyFiles ?? []), - hashDiff(baseDiff, baseFiles), + hashDiff(baseDiff, baseFiles ?? []), ]); const sources: ReviewDiffPreviewSource[] = [ { id: "working-tree", kind: "working-tree", - title: "Dirty worktree", + title: "Uncommitted", baseRef: "HEAD", headRef: null, diff: dirtyDiff, @@ -2592,11 +2727,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { id: "branch-range", kind: "branch-range", - title: baseRef ? `Against ${baseRef}` : "Against base branch", - baseRef, - headRef: branch ?? "HEAD", + title: review.baseRef ? `Changes vs ${review.baseRef}` : "Changes", + baseRef: review.baseRef, + // For display only. The new side is the working tree. + headRef: repository.currentBranch ?? "HEAD", diff: baseDiff, - files: baseFiles, + ...(baseFiles === undefined ? {} : { files: baseFiles }), diffHash: baseDiffHash, truncated: baseResult.stdoutTruncated, }, @@ -2712,54 +2848,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return new TextDecoder("utf-8").decode(bytes); }); + // Both views compare a commit with the working tree: HEAD for Uncommitted, + // merge-base(base, HEAD) for Changes. The new side always comes from disk. const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( input: ReviewDiffFileContentsInput, ) { - if (input.sourceKind === "working-tree") { - const repositoryRoot = yield* runGitStdout( - "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", - input.cwd, - ["rev-parse", "--show-toplevel"], - ).pipe(Effect.map((value) => value.trim())); - if (repositoryRoot.length === 0) { - return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); - } - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" - ? Effect.succeed("") - : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath), - input.changeType === "deleted" - ? Effect.succeed("") - : readWorkingTreeReviewFile(input, repositoryRoot), - ], - { concurrency: 2 }, - ); - return { oldContents, newContents }; - } - - if (!input.baseRef || !input.headRef) { - return yield* reviewDiffFileError( - input, - "Branch diff file expansion requires both base and head refs.", - ); - } - const mergeBase = yield* runGitStdout( - "GitVcsDriver.getReviewDiffFileContents.mergeBase", + const repositoryRoot = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", input.cwd, - ["merge-base", input.baseRef, input.headRef], + ["rev-parse", "--show-toplevel"], ).pipe(Effect.map((value) => value.trim())); - if (mergeBase.length === 0) { - return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base."); + if (repositoryRoot.length === 0) { + return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); } + const oldRevision = + input.sourceKind === "working-tree" + ? (input.baseRef ?? "HEAD") + : (yield* resolveReviewMergeBase(input.cwd, null, input.baseRef ?? undefined)).mergeBase; const [oldContents, newContents] = yield* Effect.all( [ input.changeType === "new" ? Effect.succeed("") - : readReviewFileAtRevision(input, mergeBase, input.oldPath), + : readReviewFileAtRevision(input, oldRevision, input.oldPath), input.changeType === "deleted" ? Effect.succeed("") - : readReviewFileAtRevision(input, input.headRef, input.newPath), + : readWorkingTreeReviewFile(input, repositoryRoot), ], { concurrency: 2 }, ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ac21a61ddf78..24a7657d0e27 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -78,6 +78,8 @@ function makeTestLayer(state: { remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; backgroundWorkEnabled?: boolean; + /** Runs before each remote status read, e.g. to hold a fetch open. */ + beforeRemoteStatus?: Effect.Effect; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -90,11 +92,15 @@ function makeTestLayer(state: { return state.currentLocalStatus; }), remoteStatus: (_input, options) => - Effect.sync(() => { - state.remoteStatusCalls += 1; - state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); - return state.currentRemoteStatus; - }), + Effect.suspend(() => state.beforeRemoteStatus ?? Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + return state.currentRemoteStatus; + }), + ), + ), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -847,6 +853,90 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + // A push from a terminal moves ahead; a PR merged on the host moves ahead-of-default. + it.effect.each([ + ["a push", { ...baseRemoteStatus, aheadCount: 1, aheadOfDefaultCount: 0 }], + ["a merged pull request", { ...baseRemoteStatus, aheadOfDefaultCount: 2 }], + ] as const)("re-reads local status when a fetch reflects %s", ([, initialRemote]) => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: initialRemote as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const localUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) + : event._tag === "localUpdated" + ? Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + yield* Deferred.await(snapshotDeferred); + + // The next fetch moves the base, so the Changes totals shrink. + const pushedLocal: VcsStatusLocalResult = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + }; + state.currentLocalStatus = pushedLocal; + state.currentRemoteStatus = { ...baseRemoteStatus, aheadOfDefaultCount: 0 }; + yield* TestClock.adjust(Duration.minutes(1)); + + assert.deepStrictEqual(yield* Deferred.await(localUpdatedDeferred), { + _tag: "localUpdated", + local: pushedLocal, + } satisfies VcsStatusStreamEvent); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("an explicit refresh reads local totals after the fetch", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const fetchStarted = yield* Deferred.make(); + const finishFetch = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(fetchStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFetch)), + ); + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(fetchStarted); + + // The fetch moves the base while it runs, so totals read before it ends would be stale. + const fetchedLocal: VcsStatusLocalResult = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + }; + state.currentLocalStatus = fetchedLocal; + yield* Deferred.succeed(finishFetch, undefined); + + const status = yield* Fiber.join(refresh); + assert.deepStrictEqual(status.branchChanges, fetchedLocal.branchChanges); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 40f0e1bc8685..118e2bd29807 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -462,9 +462,22 @@ export const make = Effect.gen(function* () { if (options?.refreshUpstream !== false) { yield* workflow.invalidateRemoteStatus(cwd); } + const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; const remote = yield* workflow.remoteStatus({ cwd }, options); const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; + // Local status holds the Changes totals, which compare against remote refs. A fetch can + // move them with no local trigger (a push from a terminal, a PR merged on the host), so + // re-read local status on the first fetch and whenever divergence moves. + if ( + remote && + (!previousRemote || + previousRemote.aheadCount !== remote.aheadCount || + previousRemote.behindCount !== remote.behindCount || + previousRemote.aheadOfDefaultCount !== remote.aheadOfDefaultCount) + ) { + yield* refreshLocalStatusCore(cwd); + } return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); @@ -480,10 +493,9 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ); + // Local after remote: the fetch can move the base that the Changes totals compare with. + const remote = yield* workflow.remoteStatus({ cwd }); + const local = yield* workflow.localStatus({ cwd }); const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d819312a4685..233878f5de8e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2222,10 +2222,10 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { const explicitThreadRef = explicitDiffOpenRef.current; explicitDiffOpenRef.current = null; - // Generic openings always show the checkout, including tab fallbacks and thread changes. + // Generic openings always show Changes, including tab fallbacks and thread changes. // A timeline click instead opens the specific turn/file the user requested. if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); + useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); } }, [activeThreadRef, diffOpen]); const rightPanelState = useRightPanelStore((state) => @@ -5023,7 +5023,7 @@ export default function ChatView(props: ChatViewProps) { ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); + useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); @@ -5378,7 +5378,7 @@ export default function ChatView(props: ChatViewProps) { if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) { return; } - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); + useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); onDiffPanelOpen?.(); }, [ turnDiffSummaries, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 7746910f31f1..32f5bfabcbaf 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -233,8 +233,8 @@ export default function DiffPanel({ const selectedScopeLabel = selectedRunId === null ? selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes" + ? "Uncommitted" + : "Changes" : selectedTurn?.runId === latestTurn?.runId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; @@ -246,8 +246,8 @@ export default function DiffPanel({ const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` : selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes"; + ? "Uncommitted" + : "Changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -676,11 +676,11 @@ export default function DiffPanel({ - - Working tree - - Branch changes + Changes + + + Uncommitted Latest turn @@ -1004,8 +1004,8 @@ export default function DiffPanel({ selectedTurn ? "Loading checkpoint diff..." : selectedGitScope === "unstaged" - ? "Loading working tree diff..." - : "Loading branch diff..." + ? "Loading uncommitted changes..." + : "Loading changes..." } /> ) : ( diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b1bab01c38c8..9d9d82c6273f 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1192,6 +1192,8 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; + // Matches the diff panel's Changes view. Older servers only report uncommitted totals. + const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); @@ -1936,12 +1938,8 @@ export default function GitActionsControl({ Changes - - +{gitStatusForActions?.workingTree.insertions ?? 0} - - - -{gitStatusForActions?.workingTree.deletions ?? 0} - + +{changesTotals?.insertions ?? 0} + -{changesTotals?.deletions ?? 0} ) : null} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2d3442bdbcee..8c5a3a935c5a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2673,7 +2673,7 @@ export function GeneralSettingsPanel() { { }), ); - it("defaults each thread to working tree changes without requiring git status", () => { + it("defaults each thread to Changes without requiring git status", () => { expect( selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ kind: "unstaged" }); + ).toEqual({ kind: "branch", baseRef: null }); }); - it("defaults to working tree changes before a thread is selected", () => { + it("defaults to Changes before a thread is selected", () => { expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, null)).toEqual({ - kind: "unstaged", + kind: "branch", + baseRef: null, }); }); + it("keeps a custom base when a generic open selects Changes again", () => { + const store = useDiffPanelStore.getState(); + store.selectBranchBaseRef(THREAD_REF, "origin/release"); + store.selectGitScope(THREAD_REF, "branch"); + store.selectTurn(THREAD_REF, RunId.make("turn-1")); + store.selectGitScope(THREAD_REF, "branch"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/release" }); + }); + it("preserves an explicit branch selection", () => { useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch"); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 1111e68fb819..534eee5932e8 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -10,7 +10,8 @@ export type DiffPanelSelection = | { kind: "unstaged" } | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; -const DEFAULT_SELECTION: DiffPanelSelection = { kind: "unstaged" }; +// "branch" is the Changes view: everything this checkout changed since its base. +const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; interface DiffPanelStoreState { byThreadKey: Record; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2efd79f5baa2..0698529d10c0 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -235,6 +235,17 @@ const VcsStatusLocalShape = { insertions: NonNegativeInt, deletions: NonNegativeInt, }), + /** + * Totals for the diff panel's Changes view: merge-base with the base branch to the + * working tree, untracked files included. Absent on older servers. + */ + branchChanges: Schema.optional( + Schema.Struct({ + baseRef: Schema.NullOr(TrimmedNonEmptyStringSchema), + insertions: NonNegativeInt, + deletions: NonNegativeInt, + }), + ), }; const VcsStatusRemoteShape = { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index e866ac05812e..d5866d32e5d1 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -373,6 +373,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { refName: status.refName, hasWorkingTreeChanges: status.hasWorkingTreeChanges, workingTree: status.workingTree, + ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}), }; } From 5bf19d12b0fd54fc9999579869bcf264bc555874 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 23:06:09 -0700 Subject: [PATCH 23/82] fix(server): threads stay working while Claude starts a wake turn (#15055) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 64 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 31 +++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f14cbd3e8724..e5839c824219 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2004,6 +2004,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000107", text: WAKE_ASSISTANT_TEXT, }); + // The CLI opens the wake turn with `init`, seconds before its first output. + const wakeTurnInit = claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000110", + session_id: WAKE_NATIVE_SESSION, + }); const wakeResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000104", result: WAKE_RESULT_TEXT, @@ -4641,6 +4648,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("starts the wake run when Claude opens the wake turn, before its output", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 0); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + // The run attaches while Claude still thinks: only the notification + // and `init` are buffered, so the run waits for the turn's output. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("does not offer a continuation for notification-only opaque work", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6e61738ec1f0..1aa14ae6fdd6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1739,6 +1739,13 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { ); } +// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn +// that turn is a wake, and `init` comes 20-110 ms after the notification that +// caused it but seconds before its first output (model thinking time). +function isClaudeTurnStartMessage(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, ): ReadonlyArray { @@ -5095,12 +5102,14 @@ export function makeClaudeAdapterV2( // replay them to the turn that was still starting when they // arrived; the offer gate below keeps them from requesting a // continuation on their own. + const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = isPendingTaskNotification || isPendingSubagentNotification || isNestedSubagentNotification || isKnownSubagentTaskStarted || isNewSubagentTaskStarted || + isWakeTurnStart || message.type === "assistant" || message.type === "user" || message.type === "result" || @@ -5157,12 +5166,14 @@ export function makeClaudeAdapterV2( } // A terminal task notification can clear the Waiting roster without // Claude dequeuing it into a native model turn. Buffer it for replay, - // but do not open an opaque-task continuation until native user, - // assistant, or result output proves that Claude actually began the - // wake turn. Subagent notifications retain their existing immediate - // offer because their projected lifecycle owns the continuation. - // Only root output proves it: a background subagent keeps streaming - // its own frames while the root is idle. + // but do not open an opaque-task continuation until the turn's `init`, + // or native user, assistant, or result output, proves that Claude + // actually began the wake turn. `init` comes first, so the thread + // shows working while Claude thinks instead of looking finished. + // Subagent notifications retain their existing immediate offer + // because their projected lifecycle owns the continuation. Only + // root frames prove it: a background subagent keeps streaming its + // own frames while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -5175,6 +5186,7 @@ export function makeClaudeAdapterV2( if ( !isPendingSubagentNotification && !isNativeOpaqueWakeFrame && + !isWakeTurnStart && message.type !== "result" ) { return; @@ -7135,8 +7147,13 @@ export function makeClaudeAdapterV2( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } + // A drained `init` means Claude began the wake turn, so its output + // may still be on the way: stay open for it. const hasNativeWakeFrame = drained.some( - (entry) => entry.type === "user" || entry.type === "assistant", + (entry) => + entry.type === "user" || + entry.type === "assistant" || + isClaudeTurnStartMessage(entry), ); if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { const completedAt = yield* DateTime.now; From 5c0429b1578cff3307c35c51a305e2749a6dcb6e Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 03:07:55 -0300 Subject: [PATCH 24/82] feat(web): mod+alt+enter on an existing thread sends and opens a new thread (#15050) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 15 ++++++++++++++- .../settings/KeybindingsSettings.logic.ts | 1 + apps/web/src/composer-logic.test.ts | 8 ++++++++ apps/web/src/composer-logic.ts | 1 + docs/user/keybindings.md | 11 ++++++----- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 5 +++++ 7 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 233878f5de8e..8e105a49f37f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8511,6 +8511,11 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftThreadContexts(composerDraftTarget, [...records]), resetCursor: (options) => composerRef.current?.resetCursorState(options), }); + } else if ( + submissionIntent === "background" && + currentRouteThreadKeyRef.current === routeThreadKey + ) { + handleNewThreadInActiveProject(); } return; } @@ -8743,7 +8748,9 @@ export default function ChatView(props: ChatViewProps) { } beginLocalDispatch({ preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), - submissionIntent, + // Only a draft has a background submission to hide behind its hero. + submissionIntent: + submissionIntent === "background" && !isLocalDraftThread ? "foreground" : submissionIntent, }); setWorktreeSetupRef( multipleModelSelections === null && baseBranchForWorktree @@ -9316,6 +9323,12 @@ export default function ChatView(props: ChatViewProps) { }), ); } + } else if ( + submissionIntent === "background" && + currentRouteThreadKeyRef.current === routeThreadKey + ) { + // An existing thread keeps running; open a fresh composer like a draft does. + handleNewThreadInActiveProject(); } } } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index dc31de55d1f8..4897007d8fda 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -314,6 +314,7 @@ export function buildKeybindingCommandOptions( export function commandLabel(command: KeybindingCommand): string { if (command === "composer.sendAlternate") return "Composer: Opposite Queue or Steer Action"; if (command === "composer.sendBackground") return "Composer: Start in Background"; + if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread"; if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message as Steer"; if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message"; if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 2da0c9671c73..6d81f3be8507 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -124,6 +124,14 @@ describe("composerSubmissionIntentForKey", () => { event: { ...modEnter, altKey: true }, }), ).toBe("background"); + expect( + composerSubmissionIntentForKey({ + ...input, + platform, + sendShortcut, + event: { ...modEnter, altKey: true }, + }), + ).toBe("background"); expect( composerSubmissionIntentForKey({ ...input, diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 99567b5a4a68..09c5e714aeb3 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -59,6 +59,7 @@ export function composerSubmissionIntentForKey(input: { }); if (command === "composer.sendAlternate" && input.isRunning) return "alternate"; if (command === "composer.sendBackground" && input.isDraftThread) return "background"; + if (command === "composer.sendAndNewThread" && !input.isDraftThread) return "background"; if (command !== null || event.key !== "Enter" || event.shiftKey || event.altKey) return null; if ( composerRequiresModifier(input.sendShortcut, input.prompt ?? "") && diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 43bd23333f66..63c9577767af 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -11,11 +11,12 @@ inserts a new line. This applies to the web and desktop composer at desktop widt **Follow-up behavior** chooses Queue or Steer while the agent runs. Use `mod+Enter` to do the opposite for one message, even when the send shortcut -requires a modifier. In a new thread, `mod+Alt+Enter` starts the thread in the -background and opens a fresh composer. Change either shortcut in -**Settings → Keybindings** under **Composer: Opposite Queue or Steer Action** or -**Composer: Start in Background**. These bindings take priority over the send -shortcut. Click the send button to use the configured follow-up behavior. +requires a modifier. `mod+Alt+Enter` sends, keeps that thread running in the +background, and opens a fresh new-thread composer. Change these +shortcuts in **Settings → Keybindings** under **Composer: Opposite Queue or +Steer Action**, **Composer: Start in Background**, or **Composer: Send and Start +New Thread**. These bindings take priority over the send shortcut. Click the +send button to use the configured follow-up behavior. When an active turn has queued messages, `mod+Shift+Enter` sends the first as a steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 06ac4fb83820..e827ba8270fd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -87,6 +87,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.stash", "composer.sendAlternate", "composer.sendBackground", + "composer.sendAndNewThread", "composer.host", "composer.effort", "composer.mode", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 447d42560365..8cb3fee23e02 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -53,6 +53,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ command: "composer.sendBackground", when: "composerFocus && draftThreadRoute", }, + { + key: "mod+alt+enter", + command: "composer.sendAndNewThread", + when: "composerFocus && !draftThreadRoute", + }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, From db514607f4026cd2bbb7b96674168bb227ac043a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 2 Oct 2026 23:16:07 -0700 Subject: [PATCH 25/82] fix(chat): sending on an older thread no longer jumps to the top (#15059) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/lib/threadActivity.test.ts | 65 +++++++++++++++-- apps/mobile/src/lib/threadActivity.ts | 22 +++--- .../chat/MessagesTimeline.logic.test.ts | 70 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 27 ++++--- 4 files changed, 162 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4f0c140ac229..16fd407f537b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => { ]); }); + it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { + const imported = (item: T, id: string) => ({ + ...item, + id: TurnItemId.make(id), + runId: null, + }); + const presented = (start: OrchestrationV2TurnItem) => + deriveThreadFeedPresentation( + buildThreadFeed( + [ + imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"), + imported( + { + ...assistantMessage("2026-06-20T00:00:02.000Z"), + messageId: MessageId.make("update"), + }, + "imported-update", + ), + imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"), + imported( + { + ...assistantMessage("2026-06-20T00:00:08.000Z"), + messageId: MessageId.make("answer"), + }, + "imported-answer", + ), + start, + ].map((item, position) => projected(item, position)), + ), + { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null }, + new Set(), + new Set(), + "2026-06-20T00:01:00.000Z", + ) + .slice(0, 4) + .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); + + // A sent prompt and an automatic wake both start V2 work below the import. + expect( + presented({ + ...userMessage("2026-06-20T00:01:00.000Z"), + id: TurnItemId.make("new-prompt"), + messageId: MessageId.make("new-prompt"), + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + expect( + presented({ + ...base("wake", "2026-06-20T00:01:00.000Z", 4), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + }); + it("keeps a provider-native subagent's runless tool call live while it works", () => { const startedAt = "2026-06-20T00:00:01.000Z"; const { exitCode: _exitCode, ...completedCommand } = command(); @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => { expect(presented.some((entry) => entry.type === "thinking")).toBe(false); }); - it("keeps a runless tail settled while a normal thread waits for its sent run", () => { + it("keeps a runless tail folded while a normal thread waits for its sent run", () => { // Right after a send the local clock runs before the server creates the - // run, and the latest run may still be queued: neither is runless work. + // run, and the latest run may still be queued: neither is runless work, + // so the settled tail must not reopen and shift the feed. const startedAt = "2026-06-20T00:00:05.000Z"; const feed = buildThreadFeed([ projected({ ...userMessage(), runId: null }, 0), @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => { new Set(), startedAt, ); - const toggle = presented.find((entry) => entry.type === "work-toggle"); - expect(toggle).toMatchObject({ live: false, shimmer: false }); - expect(presented.at(-1)?.type).toBe("thinking"); + expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); } }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 259b76c9b173..3a77324a7ac8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -972,13 +972,14 @@ export function failedFeedRunIds( } /** - * A thread without runs (a provider-native subagent) folds each prompt's - * response like a run; `isWorking` keeps its latest response open. + * A prompt without a run (a provider-native subagent, or a turn imported from + * V1) folds its response like a run. `runlessWorkActive` keeps the latest + * runless response open; V2 work must not reopen imported turns. */ function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, - isWorking: boolean, + runlessWorkActive: boolean, ): ReadonlyMap { const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds( RunId, { entries: ThreadFeedEntry[]; startBoundary: string | null } >(); - // Fold state is keyed by run, so each prompt of a runless thread lends its - // response a stable key of its own. + // Fold state is keyed by run, so each runless prompt lends its response a + // stable key of its own. Decide per prompt, not per thread: a V1 thread's + // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; - runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null; + runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds( for (const [runId, group] of groupsByRunId) { if ( runId === activeRunId || - (isWorking && runId === runlessKey) || + (runlessWorkActive && runId === runlessKey) || interruptedRunIds.has(runId) || failedRunIds.has(runId) || group.entries.some((entry) => entry.type === "message" && entry.message.streaming) @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation( const activeTailGroup = sourceFeed.at(-1); const activeRunId = unsettledRunId(latestRun); const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; - const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking); + const foldsByAnchorId = deriveThreadFeedRunFolds( + sourceFeed, + latestRun, + isWorking && runlessWorkActive, + ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedRunIds.has(fold.runId)) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7c2fb5bd8893..f87cb75b34b8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2230,6 +2230,76 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { + const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`; + const message = ( + id: string, + role: "user" | "assistant", + second: number, + runId: string | null = null, + ) => ({ + id, + kind: "message" as const, + createdAt: at(second), + message: { + id: id as never, + role, + text: id, + runId: runId as never, + createdAt: at(second), + updatedAt: at(second), + streaming: false, + }, + }); + const rows = (tail: ReadonlyArray>) => + deriveMessagesTimelineRows({ + timelineEntries: [ + message("imported-prompt", "user", 0), + message("imported-update", "assistant", 4), + { + id: "imported-command", + kind: "work", + createdAt: at(5), + entry: { + id: "imported-command", + createdAt: at(5), + runId: null, + label: "Ran git", + command: "git status", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + message("imported-answer", "assistant", 8), + ...tail, + ], + latestRun: { + runId: "run-1" as never, + status: "running", + startedAt: at(20), + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: at(20), + turnDiffSummaries: [], + supportsConversationRollback: false, + }).map((row) => + row.kind === "message" ? `${row.message.role}:${row.message.id}` : row.kind, + ); + + // V2 work starts from a sent prompt, or with no new prompt (a wake or a resume). + expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 4)).toEqual([ + "user:imported-prompt", + "turn-fold", + "assistant:imported-answer", + "user:new-prompt", + ]); + const withoutPrompt = rows([]); + expect(withoutPrompt).toContain("turn-fold"); + expect(withoutPrompt).not.toContain("assistant:imported-update"); + }); + it("shows a provider-native subagent's runless tools as live work while it works", () => { const entries = (commandStatus: "inProgress" | "completed") => [ { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 88da32d28fc3..09784aaf9dbd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -850,15 +850,17 @@ function failedTimelineRunIds( /** * Settled turns fold activity before their terminal assistant message behind * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures - * and work still in progress stay visible. A thread without runs (a - * provider-native subagent) folds each prompt's response the same way. + * and work still in progress stay visible. A prompt without a run (a + * provider-native subagent, or a turn imported from V1) folds its response + * the same way. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestRun: TimelineLatestRun | null; unfoldedRunIds: ReadonlySet; - isWorking: boolean; + /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ + runlessWorkActive: boolean; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -888,8 +890,9 @@ function deriveTurnFolds(input: { const groupsByRunId = new Map(); const runlessFailedKeys = new Set(); - // Fold state is keyed by run, so each prompt of a runless thread lends its - // response a stable key of its own. + // Fold state is keyed by run, so each runless prompt lends its response a + // stable key of its own. Decide per prompt, not per thread: a V1 thread's + // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { @@ -898,7 +901,13 @@ function deriveTurnFolds(input: { pendingBoundary = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } : null; - runlessKey = input.latestRun === null ? RunId.make(`runless:${entry.id}`) : null; + const boundaryRunId = + entry.kind === "message" + ? entry.message.runId + : entry.kind === "work" + ? entry.entry.runId + : null; + runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = timelineEntryFoldRunId(entry, runlessKey); @@ -940,7 +949,7 @@ function deriveTurnFolds(input: { input.unfoldedRunIds.has(runId) || interruptedRunIds.has(runId) || runlessFailedKeys.has(runId) || - (input.isWorking && runId === runlessKey) + (input.runlessWorkActive && runId === runlessKey) ) { continue; } @@ -1229,12 +1238,13 @@ export function deriveMessagesTimelineRows(input: { unsettledRunId, isWorking: input.isWorking, }); + const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; const foldsByAnchorEntryId = deriveTurnFolds({ timelineEntries: timelineEntries, terminalAssistantMessageIds, latestRun: input.latestRun ?? null, unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), - isWorking: input.isWorking, + runlessWorkActive, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1252,7 +1262,6 @@ export function deriveMessagesTimelineRows(input: { } } } - const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; const runIdIsActiveResponse = (runId: RunId | null | undefined) => runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); const workEntryIsInActiveRun = (entry: WorkLogEntry) => From fed41fa88bb27cb4325cb208d571393850bc63c2 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:39:17 -0700 Subject: [PATCH 26/82] chore: add bmdavis419 to triage exemptions (#15062) --- .github/TRIAGE_EXEMPTIONS.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/TRIAGE_EXEMPTIONS.td b/.github/TRIAGE_EXEMPTIONS.td index 18c6756bdbbd..484b9f76ecce 100644 --- a/.github/TRIAGE_EXEMPTIONS.td +++ b/.github/TRIAGE_EXEMPTIONS.td @@ -1,6 +1,7 @@ # Explicit contribution-triage exemptions; independent of VOUCHED.td. # Syntax: github:username (one entry per line; no denouncements). # Keep entries sorted alphabetically. Only the trusted upstream main copy applies. +github:bmdavis419 github:juliusmarminge github:maria-rcks github:markflorkowski From 6108ef3d3d0ef2a598088a1bb64980580e935830 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 01:43:08 -0700 Subject: [PATCH 27/82] fix(server): runs no longer get stuck (#15048) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/EffectOutbox.ts | 24 +- apps/server/src/orchestration-v2/EventSink.ts | 91 +++-- .../FoundationPersistence.test.ts | 159 ++++++++ .../ProviderSessionManager.test.ts | 350 +++++++++++++++++- .../ProviderSessionManager.ts | 172 +++++++-- .../ProviderTurnStartService.test.ts | 59 ++- .../ProviderTurnStartService.ts | 4 +- .../RunExecutionService.test.ts | 161 +++++++- .../orchestration-v2/RunExecutionService.ts | 54 +-- 9 files changed, 959 insertions(+), 115 deletions(-) diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 4841a5762480..9f63d8c568b6 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -278,9 +278,16 @@ export const layer: Layer.Layer = La available, Array.from({ length: Math.min(64, Math.max(0, Math.floor(count))) }, () => undefined), ).pipe(Effect.asVoid); + // Each thread runs its effects one at a time, in enqueue (rowid) order. An earlier + // effect waiting out a retry backoff still blocks later ones, so a turn + // cannot start while a failed rollback is about to restore files. A claim + // that skips restart continuations is not blocked by them either. // Title generation is correlated metadata work, so it has its own // per-thread lane and cannot delay provider lifecycle effects. - const claimableCandidatePredicate = (availableBefore?: string) => + const claimableCandidatePredicate = ( + availableBefore?: string, + excludeRestartContinuations = false, + ) => sql` ${ availableBefore === undefined @@ -292,7 +299,18 @@ export const layer: Layer.Layer = La SELECT 1 FROM orchestration_v2_effect_outbox AS active WHERE active.thread_id = candidate.thread_id - AND active.status = 'running' + AND ( + active.status = 'running' + OR ( + active.status = 'pending' + AND active.rowid < candidate.rowid + AND ${ + excludeRestartContinuations + ? sql`active.effect_type != 'provider-runtime.continue'` + : sql`1 = 1` + } + ) + ) AND ( ( candidate.effect_type = 'thread-title.generate' @@ -492,7 +510,7 @@ export const layer: Layer.Layer = La WHERE effect_id = ( SELECT candidate.effect_id FROM orchestration_v2_effect_outbox AS candidate - WHERE ${claimableCandidatePredicate(nowIso)} + WHERE ${claimableCandidatePredicate(nowIso, excludeRestartContinuations)} AND ${excludeRestartContinuations ? sql`candidate.effect_type != 'provider-runtime.continue'` : sql`1 = 1`} ORDER BY candidate.available_at ASC, candidate.created_at ASC, candidate.effect_id ASC LIMIT 1 diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index c61ce6ba8b7b..71b6f0ea994f 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -17,6 +17,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -224,6 +225,40 @@ const baseLayer: Layer.Layer< ); } }); + const publishStoredEvents = (events: ReadonlyArray) => + eventStore.publishCommitted(events).pipe(Effect.andThen(publishLiveEvents(events))); + + // Transactions commit one at a time, but each writer publishes after its + // commit. If a writer is descheduled in between, a later commit reaches + // subscribers first, and clients drop any event at or below the newest + // sequence they have applied. So a writer takes this lane as the last step + // of its transaction and holds it until it has published. Publishing never + // waits, so a writer that holds the transaction while it waits for the + // lane is not blocked for long. + const publishLane = yield* Semaphore.make(1); + const commitThenPublish = ( + transaction: Effect.Effect, + publish: (committed: A) => Effect.Effect, + ) => + Effect.suspend(() => { + let holdsLane = false; + const takeLane = publishLane.take(1).pipe( + Effect.andThen( + Effect.sync(() => { + holdsLane = true; + }), + ), + Effect.uninterruptible, + ); + return sql + .withTransaction(Effect.tap(transaction, () => takeLane)) + .pipe( + Effect.tap(publish), + Effect.ensuring( + Effect.suspend(() => (holdsLane ? publishLane.release(1) : Effect.void)), + ), + ); + }); // A user can answer after terminal normalization reads the pending request. // Recheck inside the write transaction so stale cleanup cannot erase answers. @@ -332,7 +367,7 @@ const baseLayer: Layer.Layer< "orchestration_v2.thread_id": input.events[0]?.threadId ?? null, }); - const storedEvents = yield* sql.withTransaction( + return yield* commitThenPublish( Effect.gen(function* () { const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true @@ -347,13 +382,14 @@ const baseLayer: Layer.Layer< yield* effectOutbox.enqueue(input.effects); return committed; }), + (storedEvents) => + Effect.gen(function* () { + if (input.effects.length > 0) { + yield* effectOutbox.notifyAvailable(input.effects.length); + } + yield* publishStoredEvents(storedEvents); + }), ); - if (input.effects.length > 0) { - yield* effectOutbox.notifyAvailable(input.effects.length); - } - yield* eventStore.publishCommitted(storedEvents); - yield* publishLiveEvents(storedEvents); - return storedEvents; }); const writeIfRunCurrentEffect = Effect.fn("orchestrationV2.EventSink.writeIfRunCurrent")( @@ -365,7 +401,7 @@ const baseLayer: Layer.Layer< "orchestration_v2.thread_id": input.threadId, }); - const result = yield* sql.withTransaction( + return yield* commitThenPublish( Effect.gen(function* () { const rows = yield* sql<{ readonly status: string; @@ -403,12 +439,8 @@ const baseLayer: Layer.Layer< yield* applyStoredEvents(storedEvents); return { committed: true as const, storedEvents }; }), + (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), ); - if (result.committed) { - yield* eventStore.publishCommitted(result.storedEvents); - yield* publishLiveEvents(result.storedEvents); - } - return result; }, ); @@ -424,7 +456,7 @@ const baseLayer: Layer.Layer< "orchestration_v2.expected_last_run_ordinal": input.expectedLastRunOrdinal, }); - const result = yield* sql.withTransaction( + return yield* commitThenPublish( Effect.gen(function* () { const rows = yield* sql<{ readonly active_attempt_id: string | null; @@ -464,12 +496,8 @@ const baseLayer: Layer.Layer< yield* applyStoredEvents(storedEvents); return { committed: true as const, storedEvents }; }), + (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), ); - if (result.committed) { - yield* eventStore.publishCommitted(result.storedEvents); - yield* publishLiveEvents(result.storedEvents); - } - return result; }); const existingCommandResult = (commandId: CommandId) => @@ -490,7 +518,7 @@ const baseLayer: Layer.Layer< const commitCommandEffect = Effect.fn("orchestrationV2.EventSink.commitCommand")(function* ( input: Parameters[0], ) { - const result = yield* sql.withTransaction( + const result = yield* commitThenPublish( Effect.gen(function* () { const reserved = yield* commandReceipts.insertIfAbsent({ commandId: input.commandId, @@ -538,15 +566,15 @@ const baseLayer: Layer.Layer< }); return { receipt, storedEvents, committed: true as const, cancelledEffectIds }; }), + (result) => + Effect.gen(function* () { + yield* effectOutbox.signalCancellations(result.cancelledEffectIds); + if (result.committed && input.effects.length > 0) { + yield* effectOutbox.notifyAvailable(input.effects.length); + } + if (result.committed) yield* publishStoredEvents(result.storedEvents); + }), ); - yield* effectOutbox.signalCancellations(result.cancelledEffectIds); - if (result.committed && input.effects.length > 0) { - yield* effectOutbox.notifyAvailable(input.effects.length); - } - if (result.committed) { - yield* eventStore.publishCommitted(result.storedEvents); - yield* publishLiveEvents(result.storedEvents); - } return { receipt: result.receipt, storedEvents: result.storedEvents, @@ -593,7 +621,7 @@ const baseLayer: Layer.Layer< const commitProjectCommandEffect = Effect.fn("orchestrationV2.EventSink.commitProjectCommand")( function* (input: Parameters[0]) { - const result = yield* sql.withTransaction( + const result = yield* commitThenPublish( Effect.gen(function* () { const reserved: CommandReceiptStore.ProjectCommandReceiptV2 = { commandId: input.commandId, @@ -613,10 +641,9 @@ const baseLayer: Layer.Layer< yield* commandReceipts.upsert(receipt); return { receipt, event }; }), + (result) => + result.event === undefined ? Effect.void : eventStore.publishCommitted([result.event]), ); - if (result.event !== undefined) { - yield* eventStore.publishCommitted([result.event]); - } return { receipt: result.receipt, committed: result.event !== undefined }; }, ); diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 22b93178711c..dc7eacc59ba5 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -51,7 +51,9 @@ import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; +import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -2303,6 +2305,76 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { }).pipe(Effect.provide(Layer.fresh(effectOutboxProvided))), ); + it.effect("keeps later thread effects behind an earlier effect waiting to retry", () => + Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + const workerId = "retry-order-worker"; + const commandId = CommandId.make("command:foundation-retry-order"); + const threadId = ThreadId.make("thread:foundation-retry-order"); + yield* outbox.enqueue([ + { + id: "effect:foundation-retry-order:z-rollback", + commandId, + threadId, + request: { + type: "provider-thread.rollback", + providerThreadId: ProviderThreadId.make("provider-thread:foundation-retry-order"), + checkpointId: CheckpointId.make("checkpoint:foundation-retry-order"), + scopeId: CheckpointScopeId.make("scope:foundation-retry-order"), + }, + }, + ]); + const rollback = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.isTrue(Option.isSome(rollback)); + if (Option.isNone(rollback)) return; + yield* outbox.retry({ + effectId: rollback.value.id, + workerId, + error: "rollback failed once", + delayMs: 60_000, + }); + + // A turn the user starts during the rollback's backoff must not run first, + // even when its timestamp ties and its id sorts first. + yield* outbox.enqueue([ + { + id: "effect:foundation-retry-order:a-start", + commandId: CommandId.make("command:foundation-retry-order:start"), + threadId, + request: { type: "provider-turn.start", runId: RunId.make("run:foundation-retry-order") }, + }, + { + id: "effect:foundation-retry-order:b-title", + commandId: CommandId.make("command:foundation-retry-order:title"), + threadId, + request: { type: "thread-title.generate", kind: { type: "regenerate" } }, + }, + ]); + const title = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.equal(Option.getOrUndefined(title)?.id, "effect:foundation-retry-order:b-title"); + const blocked = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.isTrue(Option.isNone(blocked)); + const nextClaimable = yield* outbox.nextClaimableAt; + assert.isTrue(Option.isSome(nextClaimable)); + if (Option.isSome(nextClaimable)) { + assert.equal( + DateTime.formatIso(nextClaimable.value), + (yield* outbox.get(rollback.value.id)).pipe(Option.getOrThrow).availableAt, + ); + } + + yield* outbox.cancelUnsettled({ + threadId, + effectTypes: ["provider-thread.rollback"], + reason: "Test cleanup.", + }); + const unblocked = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.equal(Option.getOrUndefined(unblocked)?.id, "effect:foundation-retry-order:a-start"); + yield* outbox.succeed({ effectId: "effect:foundation-retry-order:a-start", workerId }); + yield* outbox.succeed({ effectId: "effect:foundation-retry-order:b-title", workerId }); + }).pipe(Effect.provide(Layer.fresh(effectOutboxProvided))), + ); + it.effect("executes a retry at its durable deadline instead of the liveness interval", () => Effect.gen(function* () { const outbox = yield* EffectOutbox.EffectOutboxV2; @@ -3222,3 +3294,90 @@ it.live("keeps claiming new work after repeated idle periods", () => }).pipe(Effect.provide(workerLayer), Effect.scoped); }).pipe(Effect.provide(TestLayer)), ); + +it.effect("publishes live events in commit order across concurrent writers", () => + Effect.gen(function* () { + const firstCommitted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + // The first writer's post-commit wakeup stands in for any scheduler yield + // between its commit and its publish. + const pausingOutbox = Layer.effect( + EffectOutbox.EffectOutboxV2, + Effect.gen(function* () { + const delegate = yield* EffectOutbox.EffectOutboxV2; + return EffectOutbox.EffectOutboxV2.of({ + ...delegate, + notifyAvailable: (count) => + Deferred.succeed(firstCommitted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + Effect.andThen(delegate.notifyAvailable(count)), + ), + }); + }), + ).pipe(Layer.provide(effectOutboxProvided)); + const eventSinkLayer = EventSink.layerFromStores.pipe( + Layer.provide( + Layer.mergeAll( + storesProvided, + pausingOutbox, + commandReceiptStoreProvided, + ProjectStore.layer.pipe(Layer.provide(databaseLayer)), + TurnItemPositionStore.layer.pipe(Layer.provide(databaseLayer)), + ), + ), + ); + + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const first = makeThread(ThreadId.make("thread:foundation-publish-order:first"), now); + const second = makeThread(ThreadId.make("thread:foundation-publish-order:second"), now); + const published = yield* eventSink + .stream({ afterSequence: yield* eventSink.latestSequence() }) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })); + + const firstWrite = yield* eventSink + .writeWithEffects({ + events: [ + threadCreatedEvent({ id: "event:foundation-publish-order:first", thread: first, now }), + ], + effects: [ + { + id: "effect:foundation-publish-order:first", + commandId: CommandId.make("command:foundation-publish-order:first"), + threadId: first.id, + request: { type: "terminal.cleanup" }, + }, + ], + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(firstCommitted); + // The second writer commits after the first. It may run as far as it can + // before the first writer resumes. + const secondWrite = yield* eventSink + .write({ + events: [ + threadCreatedEvent({ + id: "event:foundation-publish-order:second", + thread: second, + now, + }), + ], + }) + .pipe( + Effect.provideService(Scheduler.MaxOpsBeforeYield, Number.POSITIVE_INFINITY), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(firstWrite); + yield* Fiber.join(secondWrite); + + const sequences = Array.from(yield* Fiber.join(published), (stored) => stored.sequence); + assert.deepEqual( + sequences, + [...sequences].sort((left, right) => left - right), + ); + }).pipe(Effect.provide(eventSinkLayer)); + }).pipe(Effect.provide(databaseLayer)), +); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index a567a4ab4841..696a40d8d176 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -20,6 +20,7 @@ import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -80,6 +81,52 @@ const FailingReleaseEventSinkLayer = Layer.effect( }), ).pipe(Layer.provide(TestEventSinkLayer)); +interface FlakyReleaseWrites { + /** Which release writes fail right now. */ + readonly failing: Ref.Ref<"none" | "session" | "session-and-requests">; + /** Receives one item per failed write. */ + readonly failures: Queue.Queue; + /** Holds runtime request writes: completes `paused`, then waits for `resume`. */ + readonly pauseRequestWrites?: { + readonly paused: Deferred.Deferred; + readonly resume: Deferred.Deferred; + }; +} + +// Fails release writes with a defect, the way a failed SQL commit surfaces. +const makeFlakyReleaseEventSinkLayer = (flaky: FlakyReleaseWrites) => + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const delegate = yield* EventSink.EventSinkV2; + return EventSink.EventSinkV2.of({ + ...delegate, + write: (input) => + Effect.gen(function* () { + const failing = yield* Ref.get(flaky.failing); + const fails = input.events.some( + (event) => + (failing !== "none" && + event.type === "provider-session.updated" && + (event.payload.status === "stopped" || event.payload.status === "error")) || + (failing === "session-and-requests" && event.type === "runtime-request.updated"), + ); + const pause = flaky.pauseRequestWrites; + if ( + pause !== undefined && + input.events.some((event) => event.type === "runtime-request.updated") + ) { + yield* Deferred.succeed(pause.paused, undefined); + yield* Deferred.await(pause.resume); + } + if (!fails) return yield* delegate.write(input); + yield* Queue.offer(flaky.failures, undefined); + return yield* Effect.die(new Error("simulated commit failure")); + }), + }); + }), + ).pipe(Layer.provide(TestEventSinkLayer)); + const CodexCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexCapabilities, @@ -359,15 +406,19 @@ function makeTestLayer(input: { readonly initialProviderItemIdentityVersion?: 2; }) => Effect.Effect; readonly failReleaseEventWrites?: boolean; + readonly flakyReleaseWrites?: FlakyReleaseWrites; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly beforeUnload?: Effect.Effect; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { - const configuredEventSinkLayer = input.failReleaseEventWrites - ? FailingReleaseEventSinkLayer - : TestEventSinkLayer; + const configuredEventSinkLayer = + input.flakyReleaseWrites !== undefined + ? makeFlakyReleaseEventSinkLayer(input.flakyReleaseWrites) + : input.failReleaseEventWrites + ? FailingReleaseEventSinkLayer + : TestEventSinkLayer; const registryLayer = ProviderAdapterRegistry.makeSingleLayer( makeProviderAdapter(input.state, { failEventStream: input.failEventStream ?? false, @@ -2299,6 +2350,299 @@ it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on r yield* effect.pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1000 }))); }), ); +it.effect("ProviderSessionManagerV2 retries release records that failed to persist", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("session"), + failures: yield* Queue.unbounded(), + }; + 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 threadId = ThreadId.make("thread-provider-session-manager-release-retry"); + 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 })], + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + yield* eventSink.write({ events: pendingRequest.events }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + yield* Queue.take(flaky.failures); + // The failed session write does not keep the approval answerable. + const afterClose = yield* projectionStore.getThreadProjection(threadId); + assert.equal(afterClose.runtimeRequests.at(-1)?.responseCapability.type, "not_resumable"); + assert.equal(afterClose.providerSessions.at(-1)?.status, "ready"); + + // The first retry fails as well, and the retries continue. + yield* TestClock.adjust("1 second"); + yield* Queue.take(flaky.failures); + yield* Ref.set(flaky.failing, "none"); + const stopped = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "provider-session.updated", + }) + .pipe(Stream.runHead, Effect.forkScoped); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(stopped); + + const afterRetry = yield* projectionStore.getThreadProjection(threadId); + assert.equal(afterRetry.providerSessions.at(-1)?.status, "stopped"); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 release retries leave a replacement session alone", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("none"), + failures: yield* Queue.unbounded(), + }; + 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 threadId = ThreadId.make("thread-provider-session-manager-release-replacement"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const writePendingRequest = Effect.gen(function* () { + const now = yield* DateTime.now; + const request = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + now, + }); + yield* eventSink.write({ events: request.events }); + return request.requestId; + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId, now: yield* DateTime.now }), + ], + }); + const oldRequestId = yield* writePendingRequest; + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + yield* Ref.set(flaky.failing, "session-and-requests"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + yield* Queue.take(flaky.failures); + yield* Queue.take(flaky.failures); + yield* Ref.set(flaky.failing, "none"); + + // A replacement opens with the same id before the retry runs. + yield* TestClock.adjust("500 millis"); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const newRequestId = yield* writePendingRequest; + const replacementStatus = (yield* projectionStore.getThreadProjection( + threadId, + )).providerSessions.at(-1)?.status; + const settled = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "runtime-request.updated", + }) + .pipe(Stream.runHead, Effect.forkScoped); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(settled); + + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = (id: typeof oldRequestId) => + projection.runtimeRequests.find((candidate) => candidate.id === id); + assert.equal(request(oldRequestId)?.responseCapability.type, "not_resumable"); + assert.equal(request(newRequestId)?.responseCapability.type, "live"); + assert.equal(projection.providerSessions.at(-1)?.status, replacementStatus); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 keeps each failed release's cleanup", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("none"), + failures: yield* Queue.unbounded(), + }; + 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 firstThreadId = ThreadId.make("thread-provider-session-manager-release-each-a"); + const secondThreadId = ThreadId.make("thread-provider-session-manager-release-each-b"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: firstThreadId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: firstThreadId, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: secondThreadId, now }), + ], + }); + const secondThreadRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId: secondThreadId, + providerSessionId, + providerThread: makeProviderThread({ + idAllocator, + threadId: secondThreadId, + providerSessionId, + now, + }), + now, + }); + yield* eventSink.write({ events: secondThreadRequest.events }); + const failRelease = (failedWrites: number) => + Effect.gen(function* () { + yield* Ref.set(flaky.failing, "session-and-requests"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + yield* Effect.repeat(Queue.take(flaky.failures), { times: failedWrites - 1 }); + yield* Ref.set(flaky.failing, "none"); + }); + + // The first session serves both threads. Its replacement serves one. + yield* manager.open({ + threadId: firstThreadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: secondThreadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + // Both the session write and the second thread's request write fail. + yield* failRelease(2); + yield* TestClock.adjust("500 millis"); + yield* manager.open({ + threadId: firstThreadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + // Only the session write fails: the first thread has no requests. + yield* failRelease(1); + + const settled = yield* eventSink + .stream({ + threadId: secondThreadId, + afterSequence: yield* eventSink.latestSequence({ threadId: secondThreadId }), + eventType: "runtime-request.updated", + }) + .pipe(Stream.runHead, Effect.forkScoped); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(settled); + + const projection = yield* projectionStore.getThreadProjection(secondThreadId); + assert.equal(projection.runtimeRequests.at(-1)?.responseCapability.type, "not_resumable"); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 settles a request the event pump persists during release", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("none"), + failures: yield* Queue.unbounded(), + pauseRequestWrites: { + paused: yield* Deferred.make(), + resume: yield* Deferred.make(), + }, + }; + const pause = flaky.pauseRequestWrites!; + 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 threadId = ThreadId.make("thread-provider-session-manager-release-pump"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + // The runtime creates the request a moment after the release starts. + const createdAt = DateTime.add(now, { seconds: 1 }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now: createdAt, + }), + now: createdAt, + }); + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); + // The event pump holds the request permit while it persists the request. + yield* Deferred.await(pause.paused); + const closed = yield* manager + .close(providerSessionId) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(pause.resume, undefined); + yield* Fiber.join(closed); + + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + assert.equal(request?.responseCapability.type, "not_resumable"); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + it.effect("ProviderSessionManagerV2 terminalizes a pending input transcript item on release", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 786eb5a6bf0a..c1039c2a0207 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -17,11 +17,13 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -361,6 +363,9 @@ export const layerWithOptions = ( ); const layerScope = yield* Effect.scope; const sessions = yield* Ref.make(new Map()); + // One retry per released entry, so a later release with the same id + // cannot drop cleanup for threads only the earlier session served. + const releaseRecordRetries = yield* FiberSet.make(); const nextSubscriberId = yield* Ref.make(0); const sessionOpen = yield* makeKeyedSerialExecutor(); // Orders a thread's attach against a detach unloading it on the same session. @@ -608,6 +613,8 @@ export const layerWithOptions = ( const writeReleasedRuntimeRequestEvents = (input: { readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; + /** Requests created later belong to a replacement session with the same id. */ + readonly releasedAt: DateTime.Utc; }) => Effect.gen(function* () { const providerSessionId = input.entry.runtime.providerSessionId; @@ -629,7 +636,8 @@ export const layerWithOptions = ( (request) => request.status === "pending" && request.responseCapability.type === "live" && - request.responseCapability.providerSessionId === providerSessionId, + request.responseCapability.providerSessionId === providerSessionId && + DateTime.isLessThanOrEqualTo(request.createdAt, input.releasedAt), ); for (const request of releasedRequests) { @@ -708,6 +716,125 @@ export const layerWithOptions = ( } }); + // Records a released session as stopped and resolves the live runtime + // requests it left. Each write runs even if the other fails. Once a + // replacement session opens with the same id, it owns the session status, + // so only the requests are settled. + const writeReleaseRecords = (input: { + readonly entry: LiveSessionEntry; + readonly reason: ProviderSessionReleaseReason; + readonly detail?: string; + readonly releasedAt: DateTime.Utc; + readonly replaced: boolean; + }) => + Effect.all( + [ + input.replaced + ? Effect.succeed(Exit.void) + : Effect.exit(writeReleasedSessionEvents(input)), + Effect.exit( + writeReleasedRuntimeRequestEvents(input).pipe( + input.entry.requestEventPermit.withPermits(1), + ), + ), + ], + { concurrency: 1 }, + ).pipe(Effect.flatMap(Exit.asVoidAll)); + + // The session already left the live map, so a later release finds + // nothing to do. Without a retry the UI would keep a ready session and + // answerable approvals until a server restart. Each attempt holds the + // session's open lock, so it sees a replacement that opened meanwhile. + const retryReleaseRecords = ( + input: Omit[0], "replaced">, + ) => { + const providerSessionId = input.entry.runtime.providerSessionId; + const attempt = Effect.gen(function* () { + const exit = yield* Effect.exit( + sessionOpen.withLock( + providerSessionId, + Effect.gen(function* () { + const replaced = (yield* Ref.get(sessions)).has(sessionKey(providerSessionId)); + yield* writeReleaseRecords({ ...input, replaced }); + }), + ), + ); + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) return yield* exit; + yield* Effect.logWarning("orchestration-v2.provider-session-release-records-failed", { + providerSessionId, + cause: exit.cause, + }); + // A failed SQL commit is a defect, so every failure but interruption + // is retried. + return yield* Effect.fail(exit.cause); + }); + return attempt.pipe( + Effect.retry({ + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + ), + }), + Effect.delay("1 second"), + FiberSet.run(releaseRecordRetries), + ); + }; + + const logReleaseFailure = + (providerSessionId: ProviderSessionId) => + (release: Effect.Effect) => + release.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.provider-session-release-failed", { + providerSessionId, + cause, + }), + ), + ); + + // Removes the live entry and reads the request cleanup cutoff while + // holding the entry's request permit. A request the event pump is + // persisting for this runtime lands before the cutoff, and once the + // entry is gone the pump persists no more for it. A replacement's + // requests come after its own open. + const removeLiveEntry = (input: { + readonly providerSessionId: ProviderSessionId; + readonly onlyIfIdleGeneration?: number; + }): Effect.Effect, DateTime.Utc]> => + Effect.gen(function* () { + const key = sessionKey(input.providerSessionId); + const candidate = (yield* Ref.get(sessions)).get(key); + if (candidate === undefined) { + return [Option.none(), yield* DateTime.now] as const; + } + const removed = yield* Effect.zip( + Ref.modify(sessions, (current) => { + const existing = current.get(key); + if (existing !== candidate) { + return [existing === undefined ? "gone" : "changed", current] as const; + } + if ( + input.onlyIfIdleGeneration !== undefined && + (existing.busyCount > 0 || existing.idleGeneration !== input.onlyIfIdleGeneration) + ) { + return ["kept", current] as const; + } + const updated = new Map(current); + updated.delete(key); + return ["removed", updated] as const; + }), + DateTime.now, + ).pipe(candidate.requestEventPermit.withPermits(1)); + const [outcome, releasedAt] = removed; + // Another entry took this id while the permit was held; release it instead. + if (outcome === "changed") return yield* removeLiveEntry(input); + return [ + outcome === "removed" ? Option.some(candidate) : Option.none(), + releasedAt, + ] as const; + }); + const releaseEntry = (input: { readonly providerSessionId: ProviderSessionId; readonly reason: ProviderSessionReleaseReason; @@ -717,23 +844,8 @@ export const layerWithOptions = ( readonly gracefulSubscribers?: boolean; }) => Effect.acquireUseRelease( - Ref.modify(sessions, (current) => { - const key = sessionKey(input.providerSessionId); - const existing = current.get(key); - if (existing === undefined) { - return [Option.none(), current] as const; - } - if ( - input.onlyIfIdleGeneration !== undefined && - (existing.busyCount > 0 || existing.idleGeneration !== input.onlyIfIdleGeneration) - ) { - return [Option.none(), current] as const; - } - const updated = new Map(current); - updated.delete(key); - return [Option.some(existing), updated] as const; - }), - (entry) => + removeLiveEntry(input), + ([entry, releasedAt]) => Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => @@ -794,21 +906,25 @@ export const layerWithOptions = ( Effect.forkDetach, ); } - yield* writeReleasedSessionEvents({ + const records = { entry, reason: input.reason, ...(input.detail === undefined ? {} : { detail: input.detail }), - }); - yield* writeReleasedRuntimeRequestEvents({ - entry, - reason: input.reason, - }).pipe(entry.requestEventPermit.withPermits(1)); + releasedAt, + }; + const recorded = yield* Effect.exit( + writeReleaseRecords({ ...records, replaced: false }), + ); + if (Exit.isFailure(recorded)) { + yield* retryReleaseRecords(records); + return yield* recorded; + } if (Option.isSome(closeExit) && Exit.isFailure(closeExit.value)) { return yield* Effect.failCause(closeExit.value.cause); } }), }), - (entry) => + ([entry]) => Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => @@ -1495,7 +1611,7 @@ export const layerWithOptions = ( providerSessionId: entry.runtime.providerSessionId, reason: "manual_shutdown", gracefulSubscribers: true, - }).pipe(Effect.ignore); + }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); return; } const cause = Exit.isFailure(exit) @@ -1516,7 +1632,7 @@ export const layerWithOptions = ( providerSessionId: entry.runtime.providerSessionId, reason: "runtime_error", detail: Cause.pretty(cause), - }).pipe(Effect.ignore); + }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); }), ), Effect.forkIn(layerScope), @@ -1705,7 +1821,7 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, reason: "runtime_error", detail: "Failed to persist the provider-session attachment.", - }).pipe(Effect.ignore), + }).pipe(logReleaseFailure(input.providerSessionId)), ), ); yield* startEventPump(entry); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 746fbe235d99..fdb45f193179 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -30,6 +30,7 @@ import * as ProviderAuthService from "../provider/Services/ProviderAuthService.t import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { ProviderAdapterEventStreamError } from "./ProviderAdapter.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -168,6 +169,8 @@ function makeLocalCommandHarness(input: { readonly interruptOpen?: boolean; readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; + /** Loads the thread and starts the run, then fails every later state read. */ + readonly failReadsAfterRunning?: boolean; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -411,9 +414,40 @@ function makeLocalCommandHarness(input: { ), ), ) - : Effect.die("A local command must not open a native session."), + : input.failReadsAfterRunning === true + ? Effect.succeed({ + driver: providerThread.driver, + providerSession: { + id: providerSessionId, + driver: providerThread.driver, + providerInstanceId: newInstanceId, + status: "ready", + cwd: "/tmp/native-account-command", + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + ensureThread: () => Effect.succeed(providerThread), + } as never) + : Effect.die("A local command must not open a native session."), + ); + const startRootRun = vi.fn< + (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect + >(() => + input.failReadsAfterRunning === true + ? Effect.void + : Effect.die("A local command must not start a native turn."), + ); + const failReadIfRunning = Effect.suspend(() => + input.failReadsAfterRunning === true && + projection.runs.find((candidate) => candidate.id === runId)?.status === "running" + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ threadId, cause: "database unavailable" }), + ) + : Effect.void, ); - const startRootRun = vi.fn(() => Effect.die("A local command must not start a native turn.")); const tryHandlePromptCommand = vi.fn(() => input.logoutFailure === undefined ? Effect.succeed(true) @@ -471,7 +505,7 @@ function makeLocalCommandHarness(input: { ), }), getRuntimeRecoveryProjection: () => - Effect.succeed({ + Effect.as(failReadIfRunning, { ...projection, hasConversation: projection.messages.some( (m) => @@ -703,6 +737,25 @@ effectIt.effect( }), ); +effectIt.effect("does not mistake a failed state read for a superseded run", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", failReadsAfterRunning: true }); + + yield* harness.start; + + expect(harness.projection().runs.at(-1)?.status).toBe("running"); + const controls = harness.startRootRun.mock.calls[0]?.[0]; + expect(controls).toBeDefined(); + if (controls === undefined) return; + // "false" would skip the provider turn or the terminal write and leave the + // run active. A read failure must reach the caller instead. + const startCheck = yield* Effect.flip(controls.shouldStartProviderTurn!()); + const finalizeCheck = yield* Effect.flip(controls.shouldFinalizeRun!()); + expect(startCheck._tag).toBe("ProjectionStoreReadError"); + expect(finalizeCheck._tag).toBe("ProjectionStoreReadError"); + }), +); + effectIt.effect("does not overwrite a run interrupted while its thread loads", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 781491ac7fd6..ce2088d911d4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -124,13 +124,14 @@ export const layer: Layer.Layer< }) => { // Guards and background routing need live execution state, not a fresh // allocation of every completed message and tool output in the thread. + // `false` means the run moved on or is gone. A failed read is an error, + // so the caller fails the start or the run instead of skipping it. const isCurrentAttemptInStatus = (expectedStatus: OrchestrationV2Run["status"]) => projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( Effect.map((current) => { const run = current.runs.find((candidate) => candidate.id === input.runId); return run?.activeAttemptId === input.attemptId && run.status === expectedStatus; }), - Effect.catchCause(() => Effect.succeed(false)), ); return { isCurrentAttemptInStatus, @@ -157,7 +158,6 @@ export const layer: Layer.Layer< (run.status === "starting" || run.status === "running") ); }), - Effect.catchCause(() => Effect.succeed(false)), ), hasUnpairedRunInterruptRequest: () => projectionStore diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 370c30058346..3d6dca6151ab 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -50,6 +50,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, } from "./ProviderAdapter.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; @@ -590,6 +591,113 @@ it.effect("rechecks run ownership immediately before calling the provider", () = }).pipe(Effect.provide(RunExecutionTestLayer)), ); +it.effect("fails the run when its ownership check cannot be read before calling the provider", () => + Effect.gen(function* () { + const guardCalls = yield* Ref.make(0); + const providerStarts = yield* Ref.make(0); + const writes = yield* Ref.make>([]); + const threadId = ThreadId.make("thread:run-execution-start-guard-read"); + const runId = RunId.make("run:run-execution-start-guard-read"); + const attemptId = RunAttemptId.make("attempt:run-execution-start-guard-read"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const testLayer = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: (input) => + Ref.update(writes, (current) => [...current, ...input.events]).pipe( + Effect.as({ committed: true, storedEvents: [] }), + ), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:run-execution-start-guard-read"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make("session:run-execution-start-guard-read"), + session: { + events: Stream.never, + startTurn: () => Ref.update(providerStarts, (count) => count + 1), + } as unknown as ProviderAdapterV2SessionRuntime, + run: { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run, + rootNode: { + id: NodeId.make("node:run-execution-start-guard-read"), + } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-start-guard-read"), + } as OrchestrationV2CheckpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-start-guard-read"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + // The preparation check passes; the check right before the provider + // call cannot read the run. + shouldStartProviderTurn: () => + Ref.getAndUpdate(guardCalls, (calls) => calls + 1).pipe( + Effect.flatMap((calls) => + calls === 0 + ? Effect.succeed(true) + : Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "database unavailable", + }), + ), + ), + ), + // The failure is settled by the guarded write, not by another read. + shouldFinalizeRun: () => + Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "database unavailable", + }), + ), + message: { + messageId: MessageId.make("message:run-execution-start-guard-read"), + text: "Start while the store is down.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(testLayer)); + + assert.equal(yield* Ref.get(guardCalls), 2); + assert.equal(yield* Ref.get(providerStarts), 0); + const runUpdate = (yield* Ref.get(writes)).find((event) => event.type === "run.updated"); + assert.equal( + runUpdate?.type === "run.updated" ? runUpdate.payload.status : undefined, + "failed", + ); + }), +); + it.effect( "dispatches only attachment-free compact commands through the native compaction path", () => @@ -3165,6 +3273,25 @@ it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)( }), ); +it.effect("records a finished run as failed when its ownership check cannot be read", () => + Effect.gen(function* () { + const { observed } = yield* captureRootRunTermination({ + key: "finalize-guard-read-failure", + shouldFinalizeRun: () => + Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: ThreadId.make("thread:finalize-guard-read-failure"), + cause: "database unavailable", + }), + ), + events: (ids) => Stream.make(rootTerminalEvent(ids, "completed")), + }); + // The fallback settles through the guarded write instead of the same + // failing read, so the run does not stay running. + assert.include(observed, "run:failed"); + }), +); + it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () => Effect.gen(function* () { const { observed } = yield* captureRootRunTermination({ @@ -3254,7 +3381,7 @@ it.effect("keeps completed runs completed when pull request refresh fails", () = function captureRootRunTermination(input: { readonly key: string; - readonly shouldFinalizeRun: () => Effect.Effect; + readonly shouldFinalizeRun: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly seedOpenSubagent?: boolean; readonly events?: ( @@ -3278,6 +3405,17 @@ function captureRootRunTermination(input: { const ingestionDone = yield* Deferred.make(); const captureTurnItem = (payload: OrchestrationV2TurnItem) => Ref.update(writtenItems, (current) => [...current, payload]); + const captureFinalEvents = (events: ReadonlyArray) => + Effect.gen(function* () { + for (const event of events) { + if (event.type === "turn-item.updated") { + yield* captureTurnItem(event.payload); + } + if (event.type === "run.updated") { + yield* Ref.update(observed, (current) => [...current, `run:${event.payload.status}`]); + } + } + }); const testLayer = RunExecutionService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -3292,22 +3430,11 @@ function captureRootRunTermination(input: { } return []; }), - writeWithEffects: (payload) => - Effect.gen(function* () { - for (const event of payload.events) { - if (event.type === "turn-item.updated") { - yield* captureTurnItem(event.payload); - } - if (event.type === "run.updated") { - yield* Ref.update(observed, (current) => [ - ...current, - `run:${event.payload.status}`, - ]); - } - } - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), + writeWithEffects: (payload) => captureFinalEvents(payload.events).pipe(Effect.as([])), + writeIfRunCurrent: (payload) => + captureFinalEvents(payload.events).pipe( + Effect.as({ committed: true, storedEvents: [] }), + ), }), IdAllocator.layer, Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 71211c126545..6aff5da24f1e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -28,6 +28,7 @@ import * as Context from "effect/Context"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -47,6 +48,7 @@ import type { } from "./ProviderAdapter.ts"; import { ProviderAdapterTurnStartError } from "./ProviderAdapter.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import type { ProjectionStoreV2Error } from "./ProjectionStore.ts"; import { makeProviderFailure, makeProviderFailureTurnItem } from "./ProviderFailure.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; @@ -511,8 +513,8 @@ export interface RunExecutionServiceV2StartRootRunInput { >; readonly relatedThreadIds?: ReadonlyArray; readonly relatedProviderThreadIds?: ReadonlyArray; - readonly shouldStartProviderTurn?: () => Effect.Effect; - readonly shouldFinalizeRun?: () => Effect.Effect; + readonly shouldStartProviderTurn?: () => Effect.Effect; + readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly message: ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; @@ -557,7 +559,7 @@ export const layer: Layer.Layer< readonly checkpointScope: OrchestrationV2CheckpointScope; readonly providerThread: OrchestrationV2ProviderThread; readonly attempt: OrchestrationV2RunAttempt; - readonly shouldFinalizeRun?: () => Effect.Effect; + readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; readonly terminal: ProviderTerminalEvent; @@ -1286,15 +1288,12 @@ export const layer: Layer.Layer< checkpointScope: input.checkpointScope, providerThread, attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { shouldFinalizeRun: input.shouldFinalizeRun }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: - input.hasUnpairedRunInterruptRequest, - }), + // The failure may be the ownership + // read itself, so check in the write. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running", + }, openRunOwnedSubagents: openSubagents, terminal: makeFailedTerminalEvent( makeProviderFailure({ @@ -1328,10 +1327,13 @@ export const layer: Layer.Layer< Effect.forkDetach, ); - if ( - input.shouldStartProviderTurn !== undefined && - !(yield* input.shouldStartProviderTurn()) - ) { + // A failed read fails the start below, so the run is recorded as + // failed instead of staying active with no provider turn. + const shouldStart = + input.shouldStartProviderTurn === undefined + ? Exit.succeed(true) + : yield* Effect.exit(input.shouldStartProviderTurn()); + if (Exit.isSuccess(shouldStart) && !shouldStart.value) { yield* Fiber.interrupt(providerEventFiber); return; } @@ -1373,7 +1375,7 @@ export const layer: Layer.Layer< }), )) : input.session.startTurn(turnInput); - yield* startTurn.pipe( + yield* Effect.andThen(shouldStart, startTurn).pipe( Effect.catchCause((cause) => Effect.logError("orchestration V2 provider turn start failed", { runId: input.run.id, @@ -1392,20 +1394,18 @@ export const layer: Layer.Layer< checkpointScope: input.checkpointScope, providerThread, attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { shouldFinalizeRun: input.shouldFinalizeRun }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: - input.hasUnpairedRunInterruptRequest, - }), + // Checked in the write transaction, not by another + // read that can fail like the one before the start. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running", + }, openRunOwnedSubagents: openSubagents, terminal: makeFailedTerminalEvent( makeProviderFailure({ cause: Cause.squash(cause), - class: "provider_error", + // A failed ownership read is not the provider's fault. + class: Exit.isFailure(shouldStart) ? "unknown" : "provider_error", }), latestItemOrdinal + 1, ), From 56914128c1ff9dcf6585c380b7b25845d6ca5f98 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 02:01:12 -0700 Subject: [PATCH 28/82] fix(usage): Codex Fast and Ultrafast now cost what they bill (#15101) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/usage/UsageService.test.ts | 93 +++++++++++ apps/server/src/usage/UsageService.ts | 20 ++- .../src/usage/antigravityUsageReader.ts | 2 +- apps/server/src/usage/cursorUsageReader.ts | 2 +- apps/server/src/usage/opencodeUsageReader.ts | 2 +- .../server/src/usage/usageAggregation.test.ts | 5 +- apps/server/src/usage/usagePricing.test.ts | 49 +++++- apps/server/src/usage/usagePricing.ts | 158 ++++++++++++------ apps/server/src/usage/usageScanCache.test.ts | 17 +- apps/server/src/usage/usageScanCache.ts | 62 +++++-- .../src/usage/usageTranscriptReader.test.ts | 19 ++- .../server/src/usage/usageTranscriptReader.ts | 9 +- .../usage/usageTranscriptStreaming.test.ts | 2 +- .../server/src/usage/usageTranscripts.test.ts | 27 ++- apps/server/src/usage/usageTranscripts.ts | 50 ++++-- 15 files changed, 399 insertions(+), 118 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 29fcecdc234e..127679d0a449 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -34,6 +34,7 @@ import * as UsageService from "./UsageService.ts"; const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ @@ -788,6 +789,98 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "upgrades a v4 cache: reprices live Codex tiers, keeps deleted rollouts, leaves v4 intact", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const rollout = (sessionId: string, outputTokens: number) => + [ + { type: "session_meta", payload: { id: sessionId } }, + { type: "turn_context", payload: { model: "gpt-6-astra" } }, + { + type: "event_msg", + payload: { + type: "thread_settings_applied", + thread_settings: { service_tier: "ultrafast" }, + }, + }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n"; + const live = NodePath.join(sessions, "live.jsonl"); + const deleted = NodePath.join(sessions, "deleted.jsonl"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile(live, rollout("live", 10)); + await NodeFSP.writeFile(deleted, rollout("deleted", 20)); + }); + + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); + yield* (yield* UsageService.make).readSummary(WINDOW); + + // Rewrite the cache as a v4 server left it: every Codex record at + // speed 0 (standard), and no tier in the reducer state. + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + for (const file of Object.values(document.files)) { + file.r = file.r.map((row) => [...row.slice(0, 10), 0]); + delete file.cs.speed; + } + const text = encodeUnknownJsonString({ ...document, version: 4 }); + await NodeFSP.writeFile(legacyPath, text); + await NodeFSP.rm(cachePath); + await NodeFSP.rm(deleted); + return text; + }); + + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + // The live rollout re-parses at the ultrafast rate (10 x 6); the + // deleted one keeps its saved v4 usage at the standard rate (20 x 1). + assert.strictEqual(totalOutputTokens(summary), 30); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 80, + ); + // A v4 server sharing this state directory still finds its own cache. + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), + legacy, + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-v4-upgrade-test", + home, + settings, + ratesDocument: { + "gpt-6-astra": { + input_cost_per_token: 0, + output_cost_per_token: 1, + input_cost_per_token_ultrafast: 0, + output_cost_per_token_ultrafast: 6, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index a3b07d64d71b..026641ae4d07 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -64,7 +64,9 @@ import { decodeScanCache, dedupeWithinFile, encodeScanCache, + LEGACY_SCAN_CACHE_FILE_NAME, pruneScanCache, + SCAN_CACHE_FILE_NAME, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -168,7 +170,8 @@ export const make = Effect.gen(function* () { }; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); - const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); + const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsagePricing["status"] = "unavailable"; @@ -362,10 +365,17 @@ export const make = Effect.gen(function* () { */ const ensureScanCacheLoaded = yield* Effect.cached( Effect.gen(function* () { - const document = yield* fileSystem.readFileString(scanCachePath).pipe( - Effect.flatMap((raw) => decodeScanCacheFile(raw)), - Effect.catchCause(() => Effect.succeed(null)), - ); + const readDocument = (filePath: string) => + fileSystem.readFileString(filePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + let document = yield* readDocument(scanCachePath); + if (document === null) { + document = yield* readDocument(legacyScanCachePath); + // Write the migrated cache to its own file on the next scan. + cacheDirty = document !== null; + } if (document === null) return; for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); const sources = decodeCachedSources(document); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index 54c00b804b7a..34a88114f42e 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -240,7 +240,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise 0 ? cost : null, - fast: false, + speed: "standard", dedupeKey: id ? `opencode:${id}` : null, }; } diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 75435de08ff7..7ae0712c12ba 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -12,7 +12,8 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, - fastMultiplier: 1, + fast: null, + ultrafast: null, }, ], ]); @@ -32,7 +33,7 @@ function record(overrides: Partial = {}): UsageRecord { reasoningTokens: 0, }, reportedCostUsd: null, - fast: false, + speed: "standard", dedupeKey: null, ...overrides, }; diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index db4f68c2cb42..badd50a4901f 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { cursorRateModel } from "./cursorUsageReader.ts"; +import type { UsageSpeed } from "./usageTranscripts.ts"; import { cacheSavingsUsd, createOverrideRateTable, @@ -23,11 +24,15 @@ describe("usage pricing", () => { outputTokens: 1_000_000, reasoningTokens: 500_000, }; - const record = (model: string, reportedCostUsd: number | null = null, fast = false) => ({ + const record = ( + model: string, + reportedCostUsd: number | null = null, + speed: UsageSpeed = "standard", + ) => ({ model, totals, reportedCostUsd, - fast, + speed, }); it("uses custom token rates ahead of public and provider-reported costs", () => { @@ -117,20 +122,46 @@ describe("usage pricing", () => { const overrides = createOverrideRateTable({ "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 }, }); - const cost = (model: string, fast: boolean, custom?: typeof overrides) => - priceUsage(table, record(model, null, fast), custom).costUsd; + const cost = (model: string, speed: UsageSpeed, custom?: typeof overrides) => + priceUsage(table, record(model, null, speed), custom).costUsd; - expect(cost("claude-opus-5-5", true)).toBeCloseTo(2 * cost("claude-opus-5-5", false)); - expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, true))).toBeCloseTo( + expect(cost("claude-opus-5-5", "fast")).toBeCloseTo(2 * cost("claude-opus-5-5", "standard")); + expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, "fast"))).toBeCloseTo( 2 * cacheSavingsUsd(table, record("claude-opus-5-5")), ); // No published fast tier, and custom prices, both stay at the standard rate. - expect(cost("claude-fable-5-1", true)).toBe(cost("claude-fable-5-1", false)); - expect(cost("claude-opus-5-5", true, overrides)).toBe( - cost("claude-opus-5-5", false, overrides), + expect(cost("claude-fable-5-1", "fast")).toBe(cost("claude-fable-5-1", "standard")); + expect(cost("claude-opus-5-5", "fast", overrides)).toBe( + cost("claude-opus-5-5", "standard", overrides), ); }); + it("prices Codex priority and ultrafast requests at their published tier rates", () => { + const table = parseRateTable({ + "gpt-6-astra": { + ...rate(1e-5, 1e-6), + input_cost_per_token_priority: 2e-5, + output_cost_per_token_priority: 1e-4, + cache_read_input_token_cost_priority: 2e-6, + input_cost_per_token_ultrafast: 6e-5, + output_cost_per_token_ultrafast: 3e-4, + // No ultrafast cache rate: keeps the standard 10:1 input-to-cache ratio. + }, + "gpt-6-sol": rate(2e-6, 2e-7), + }); + const cost = (model: string, speed: UsageSpeed) => + priceUsage(table, record(model, null, speed)).costUsd; + const standard = cost("gpt-6-astra", "standard"); + + expect(cost("gpt-6-astra", "fast")).toBeCloseTo(2 * standard); + expect(cost("gpt-6-astra", "ultrafast")).toBeCloseTo(6 * standard); + expect(cacheSavingsUsd(table, record("gpt-6-astra", null, "ultrafast"))).toBeCloseTo( + 6 * cacheSavingsUsd(table, record("gpt-6-astra")), + ); + // A tier the model does not publish bills at the standard rate. + expect(cost("gpt-6-sol", "ultrafast")).toBe(cost("gpt-6-sol", "standard")); + }); + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 78f6cf2c5cd9..63190eebbf37 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -9,33 +9,39 @@ */ import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; -/** - * The subset of a LiteLLM entry we price against. All values are USD per token. - * - * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, - * `*_priority`, `*_batches`). We deliberately price at the base tier: the - * transcripts don't record which tier served a request, so anything else would - * be a guess dressed up as precision. - */ -export interface ModelRate { +/** Token rates for one billing speed. All values are USD per token. */ +export interface TokenRates { readonly inputCostPerToken: number; readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; +} + +/** + * The subset of a LiteLLM entry we price against: standard rates, plus rates + * for each faster speed the model publishes. A request at a speed with no + * published rates bills at the standard rates. + * + * LiteLLM also publishes `*_above_272k_tokens`, `*_flex`, and `*_batches` + * variants. Transcripts don't record those, so we don't price them. + */ +export interface ModelRate extends TokenRates { /** - * Multiple of the rates above billed for a fast-mode request, from LiteLLM's - * `provider_specific_entry.fast`. `1` when the model publishes no fast tier. + * From LiteLLM's `provider_specific_entry.fast` multiple (Claude fast mode), + * or else its `*_priority` rates (Codex `priority`). */ - readonly fastMultiplier: number; + readonly fast: TokenRates | null; + /** From LiteLLM's `*_ultrafast` rates (Codex `ultrafast`). */ + readonly ultrafast: TokenRates | null; } export type RateTable = ReadonlyMap; /** * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates - * apply as entered, fast-mode requests included. + * apply as entered, at every speed. */ export function createOverrideRateTable( overrides: Readonly>, @@ -50,31 +56,68 @@ export function createOverrideRateTable( (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, cacheCreationCostPerToken: (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, - fastMultiplier: 1, + fast: null, + ultrafast: null, }, ]), ); } -/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ -interface LiteLlmEntry { - readonly input_cost_per_token?: unknown; - readonly output_cost_per_token?: unknown; - readonly cache_read_input_token_cost?: unknown; - readonly cache_creation_input_token_cost?: unknown; - readonly provider_specific_entry?: unknown; -} +/** One raw LiteLLM entry. Field names carry a tier suffix, e.g. `_priority`. */ +type LiteLlmEntry = Readonly>; function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +/** + * Reads one rate set, `suffix` selecting a tier such as `_priority`. Returns + * `null` without both an input and an output rate. + * + * Anthropic bills cache reads at a discount and cache writes at a premium. + * When the standard tier omits them, cached input is priced as plain input + * rather than as free. A faster tier that omits them keeps the standard tier's + * cache-to-input ratio. + */ +function readTokenRates( + entry: LiteLlmEntry, + suffix: string, + standard?: TokenRates, +): TokenRates | null { + const input = finiteNumber(entry[`input_cost_per_token${suffix}`]); + const output = finiteNumber(entry[`output_cost_per_token${suffix}`]); + if (input === null || output === null) return null; + const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") => + finiteNumber(entry[`${name}${suffix}`]) ?? + (standard !== undefined && standard.inputCostPerToken > 0 + ? (standard[field] / standard.inputCostPerToken) * input + : input); + return { + inputCostPerToken: input, + outputCostPerToken: output, + cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"), + cacheCreationCostPerToken: cacheRate( + "cache_creation_input_token_cost", + "cacheCreationCostPerToken", + ), + }; +} + +function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates { + return { + inputCostPerToken: rates.inputCostPerToken * multiple, + outputCostPerToken: rates.outputCostPerToken * multiple, + cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple, + cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple, + }; +} + /** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */ -function fastMultiplier(entry: LiteLlmEntry): number { - const specific = entry.provider_specific_entry; - if (typeof specific !== "object" || specific === null) return 1; +function fastMultiplier(entry: LiteLlmEntry): number | null { + const specific = entry["provider_specific_entry"]; + if (typeof specific !== "object" || specific === null) return null; const fast = finiteNumber((specific as Record)["fast"]); - return fast !== null && fast > 0 ? fast : 1; + return fast !== null && fast > 0 ? fast : null; } /** @@ -94,21 +137,19 @@ export function parseRateTable(document: unknown): RateTable { for (const [name, raw] of Object.entries(document as Record)) { if (typeof raw !== "object" || raw === null) continue; const entry = raw as LiteLlmEntry; - const input = finiteNumber(entry.input_cost_per_token); - const output = finiteNumber(entry.output_cost_per_token); - if (input === null || output === null) continue; + const standard = readTokenRates(entry, ""); + if (standard === null) continue; const key = normalizeRateKey(name); if (key.length === 0) continue; + const multiple = fastMultiplier(entry); table.set(key, { - inputCostPerToken: input, - outputCostPerToken: output, - // Anthropic bills cache reads at a discount and cache writes at a - // premium. When a model omits them, cached input is priced as plain - // input rather than as free. - cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, - cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, - fastMultiplier: fastMultiplier(entry), + ...standard, + fast: + multiple === null + ? readTokenRates(entry, "_priority", standard) + : scaleTokenRates(standard, multiple), + ultrafast: readTokenRates(entry, "_ultrafast", standard), }); } @@ -131,16 +172,29 @@ export function parseRateTable(document: unknown): RateTable { return table; } -function sameRate(a: ModelRate, b: ModelRate): boolean { +function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean { + if (a === null || b === null) return a === b; return ( a.inputCostPerToken === b.inputCostPerToken && a.outputCostPerToken === b.outputCostPerToken && a.cacheReadCostPerToken === b.cacheReadCostPerToken && - a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && - a.fastMultiplier === b.fastMultiplier + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken ); } +function sameRate(a: ModelRate, b: ModelRate): boolean { + return ( + sameTokenRates(a, b) && + sameTokenRates(a.fast, b.fast) && + sameTokenRates(a.ultrafast, b.ultrafast) + ); +} + +/** The rates a request at `speed` bills at. */ +function ratesAt(rate: ModelRate, speed: UsageSpeed): TokenRates { + return (speed === "standard" ? null : rate[speed]) ?? rate; +} + function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } @@ -186,7 +240,7 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { /** The parts of a transcript record that decide its price. */ export type PricedRecord = Pick< UsageRecord, - "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" + "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd" >; export interface PricedUsage { @@ -214,14 +268,13 @@ export function priceUsage( const rate = override ?? lookupRate(table, record.rateModel ?? model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; - const standardCostUsd = - totals.uncachedInputTokens * rate.inputCostPerToken + - totals.cachedInputTokens * rate.cacheReadCostPerToken + - totals.cacheCreationTokens * rate.cacheCreationCostPerToken + - totals.outputTokens * rate.outputCostPerToken; - + const rates = ratesAt(rate, record.speed); return { - costUsd: standardCostUsd * (record.fast ? rate.fastMultiplier : 1), + costUsd: + totals.uncachedInputTokens * rates.inputCostPerToken + + totals.cachedInputTokens * rates.cacheReadCostPerToken + + totals.cacheCreationTokens * rates.cacheCreationCostPerToken + + totals.outputTokens * rates.outputCostPerToken, costSource: "modelPriced", }; } @@ -238,9 +291,6 @@ export function cacheSavingsUsd( const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; - return ( - record.totals.cachedInputTokens * - (rate.inputCostPerToken - rate.cacheReadCostPerToken) * - (record.fast ? rate.fastMultiplier : 1) - ); + const rates = ratesAt(rate, record.speed); + return record.totals.cachedInputTokens * (rates.inputCostPerToken - rates.cacheReadCostPerToken); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 6455b1eb7770..82ff1e36dfc1 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -24,7 +24,7 @@ function record(overrides: Partial = {}): UsageRecord { reasoningTokens: 0, }, reportedCostUsd: null, - fast: false, + speed: "standard", dedupeKey: "msg_1:", ...overrides, }; @@ -61,7 +61,7 @@ describe("scan cache round trip", () => { [ "/a.jsonl", 100, - [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", fast: true })], + [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", speed: "fast" })], ], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); @@ -79,11 +79,14 @@ describe("scan cache round trip", () => { size: 80, mtimeMs: 400, provider: "codex", - records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + records: [ + record({ provider: "codex", model: "gpt-6-astra", dedupeKey: null, speed: "ultrafast" }), + ], tailRecords: [], position: position({ codexState: { - model: "gpt-5.2-codex", + model: "gpt-6-astra", + speed: "ultrafast", sessionId: "session-c", lastUsageSignature: '{"input_tokens":1}', sawSessionMeta: true, @@ -128,8 +131,8 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); }); - it("drops an entry whose fast flag is not 0 or 1", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ fast: true })]]])); + it("drops an entry whose speed is not a known index", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ speed: "fast" })]]])); const row = encoded.files["/a.jsonl"]!.r[0]!; const poisoned = { ...encoded, @@ -139,7 +142,7 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); }); - it("rejects a document from the previous cache version", () => { + it("rejects a document from before records carried a speed", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); const previous = { ...encoded, version: 3 }; diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 79cca5cff8e2..3c6826a67986 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -17,14 +17,33 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; +import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. // v4: records carry Claude fast mode, which v3 rows never captured. -const USAGE_SCAN_CACHE_VERSION = 4 as const; +// v5: Codex records carry their service tier. v4 rows store speed the same +// way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries. +const USAGE_SCAN_CACHE_VERSION = 5 as const; +const SPEED_COMPATIBLE_SINCE_VERSION = 4; + +/** + * Each cache version writes its own file in the state directory. An older + * server sharing that directory cannot read a newer cache and would replace + * it, dropping saved usage for deleted transcripts. Separate files keep both. + * A v5 server reads the legacy (v4) file once, when its own file is missing. + */ +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v5.json"; +export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; + +/** Serialised as the index into this list. */ +const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; + +function isSpeed(value: unknown): value is UsageSpeed { + return SPEEDS.some((speed) => speed === value); +} export interface CachedFile { readonly size: number; @@ -59,7 +78,7 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, - fast: 0 | 1, + speed: number, ]; interface SerializedFile { @@ -111,7 +130,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, - record.fast ? 1 : 0, + SPEEDS.indexOf(record.speed), ]; const files: Record = {}; @@ -147,7 +166,14 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof document !== "object" || document === null) return cache; const root = document as Partial; - if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + const version = root.version; + if ( + typeof version !== "number" || + version < SPEED_COMPATIBLE_SINCE_VERSION || + version > USAGE_SCAN_CACHE_VERSION + ) { + return cache; + } if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; if (typeof root.files !== "object" || root.files === null) return cache; @@ -180,8 +206,9 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, - fast, + speedIndex, ] = row as SerializedRecord; + const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; if ( @@ -193,7 +220,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(cacheCreation) || !Number.isFinite(output) || !Number.isFinite(reasoning) || - (fast !== 0 && fast !== 1) + speed === undefined ) { return null; } @@ -211,7 +238,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoningTokens: reasoning, }, reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, - fast: fast === 1, + speed, dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } @@ -242,7 +269,11 @@ export function decodeScanCache(document: unknown): ScanCache { ) { continue; } - const codexState = decodeCodexState(entry.cs); + // v4 Codex records predate service tiers, so they all priced as standard. + // Keep them, because the rollout may be gone, but make a live rollout + // re-parse whole: no file has size -1, and a zero position cannot resume. + const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION; + const codexState = legacyCodex ? null : decodeCodexState(entry.cs); if (codexState === undefined) continue; const provider: UsageProviderKind = entry.p; @@ -251,17 +282,14 @@ export function decodeScanCache(document: unknown): ScanCache { if (records === null || tailRecords === null) continue; cache.set(path, { - size: entry.s, + size: legacyCodex ? -1 : entry.s, mtimeMs: entry.m, provider, records, tailRecords, - position: { - resumeOffset: entry.o, - guardLength: entry.gl, - guardHash: entry.gh, - codexState, - }, + position: legacyCodex + ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null } + : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState }, }); } @@ -279,6 +307,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { const state = value as Partial; if ( typeof state.model !== "string" || + !isSpeed(state.speed) || typeof state.sessionId !== "string" || (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || typeof state.sawSessionMeta !== "boolean" || @@ -290,6 +319,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { } return { model: state.model, + speed: state.speed, sessionId: state.sessionId, lastUsageSignature: state.lastUsageSignature ?? null, sawSessionMeta: state.sawSessionMeta, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 6c95a36df19b..e55c01ad4919 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -76,6 +76,14 @@ function codexModelLine(model: string): string { })}\n`; } +function codexTierLine(serviceTier: string): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "thread_settings_applied", thread_settings: { service_tier: serviceTier } }, + })}\n`; +} + function codexUsageLine(outputTokens: number, secondsOffset: number): string { return `${JSON.stringify({ type: "event_msg", @@ -111,19 +119,24 @@ describe("readTranscriptRecords resume", () => { it("carries the Codex reducer state across the resume boundary", async () => { const path = NodePath.join(dir, "rollout.jsonl"); - await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"), + ); const first = await readTranscriptRecords(path, "codex"); assert.isNotNull(first); assert.strictEqual(first.records.length, 0); - // The appended usage event has no turn_context or session_meta of its own; - // model and session must come from the state captured before the boundary. + // The appended usage event has no turn_context, thread settings, or + // session_meta of its own; model, tier, and session must come from the + // state captured before the boundary. await NodeFSP.appendFile(path, codexUsageLine(9, 5)); const second = await readTranscriptRecords(path, "codex", first.position); assert.isNotNull(second); assert.isTrue(second.resumed); assert.strictEqual(second.records.length, 1); assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.speed, "ultrafast"); assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index faa686990777..4d170237568d 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -108,6 +108,7 @@ const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { id: true, session_id: true, model: true, + thread_settings: { service_tier: true }, forked_from_id: true, source: { subagent: { thread_spawn: { parent_thread_id: true } } }, info: { last_token_usage: true }, @@ -245,9 +246,10 @@ async function guardMatches( * still match, so only appended lines are read; otherwise the whole file is * re-parsed from the start and `resumed` reports `false`. * - * Codex carries the active model on `turn_context` lines that hold no usage of - * their own, so those still have to pass through the reducer to keep model - * attribution correct. + * Codex carries the active model on `turn_context` lines and the service tier + * on `thread_settings_applied` lines. Neither holds usage of its own, but both + * still have to pass through the reducer to keep attribution and pricing + * correct. */ export async function readTranscriptRecords( filePath: string, @@ -283,6 +285,7 @@ export async function readTranscriptRecords( if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && + !line.includes('"thread_settings_applied"') && !line.includes('"session_meta"') ) { return; diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index 2187fd4aa431..fd23593e5523 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -122,7 +122,7 @@ describe("large usage records", () => { reasoningTokens: 0, }, reportedCostUsd: 0.25, - fast: true, + speed: "fast", dedupeKey: "m1:r-m1", }, ]); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index ace3b7d18cf7..8d668a139822 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -53,15 +53,15 @@ describe("parseClaudeLine", () => { reasoningTokens: 0, }); expect(record?.dedupeKey).toBe("msg_1:"); - expect(record?.fast).toBe(false); + expect(record?.speed).toBe("standard"); }); it("marks fast-mode requests", () => { const line = (speed: string) => parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); - expect(line("fast")?.fast).toBe(true); - expect(line("standard")?.fast).toBe(false); + expect(line("fast")?.speed).toBe("fast"); + expect(line("standard")?.speed).toBe("standard"); }); it("gives every content block of one message the same dedupe key", () => { @@ -148,6 +148,27 @@ describe("parseCodexLine", () => { expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); }); + it("carries the service tier from the latest thread settings", () => { + const settings = (thread_settings: Record) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:42.000Z", + payload: { type: "thread_settings_applied", thread_settings }, + }); + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const speedAfter = (line: string, output: number) => { + parseCodexLine(line, state); + return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; + }; + + expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); + expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); + expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); + // Codex omits the field when no tier was requested. + expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); + }); + // A forked/subagent rollout opens with the parent's history copied in and // every line re-stamped to the fork instant, then the ancestors' session // metas. Counting those again multiplied usage ~1.85x on real data (#5758). diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index c3903ef47194..eb32e664f436 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -8,6 +8,13 @@ */ import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; +/** + * Billing speed of a request. Faster speeds bill at a model-specific premium. + * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its + * own, more expensive tier. + */ +export type UsageSpeed = "standard" | "fast" | "ultrafast"; + export interface UsageRecord { readonly provider: UsageProviderKind; readonly timestampMs: number; @@ -20,11 +27,8 @@ export interface UsageRecord { readonly sessionId: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; - /** - * Whether the request ran in fast mode, which bills at a model-specific - * multiple of the standard rate. Only Claude Code records this. - */ - readonly fast: boolean; + /** Only Claude Code and Codex record a speed; other providers are `standard`. */ + readonly speed: UsageSpeed; /** * Key for cross-file de-duplication, or `null` when the record is inherently * unique and needs no dedup. @@ -159,7 +163,7 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null { reasoningTokens: 0, }, reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, - fast: usageRecord["speed"] === "fast", + speed: usageRecord["speed"] === "fast" ? "fast" : "standard", dedupeKey, }; } @@ -171,12 +175,14 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null { /** * Rolling state for a single Codex rollout file. * - * Codex `token_count` events carry no model, so the model is carried forward - * from the most recent `turn_context`. Sessions that switch models mid-run - * attribute correctly from the switch onward. + * Codex `token_count` events carry no model or service tier, so both are + * carried forward: the model from the most recent `turn_context`, the tier from + * the most recent `thread_settings_applied`. Sessions that switch either + * mid-run attribute correctly from the switch onward. */ export interface CodexScanState { model: string; + speed: UsageSpeed; sessionId: string; lastUsageSignature: string | null; sawSessionMeta: boolean; @@ -188,6 +194,7 @@ export interface CodexScanState { export function initialCodexScanState(): CodexScanState { return { model: "", + speed: "standard", sessionId: "", lastUsageSignature: null, sawSessionMeta: false, @@ -265,6 +272,14 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR return null; } + if (payloadType === "thread_settings_applied") { + const settings = payloadRecord["thread_settings"]; + if (typeof settings === "object" && settings !== null) { + state.speed = codexSpeed((settings as Record)["service_tier"]); + } + return null; + } + if (payloadType !== "token_count") return null; const info = payloadRecord["info"]; @@ -323,13 +338,24 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR totals, // Codex does not report cost in the rollout. reportedCostUsd: null, - fast: false, + speed: state.speed, // Events surviving the fork-copy suppression above are unique to this // rollout, so they need no global dedup. dedupeKey: null, }; } +/** + * Maps a Codex `service_tier` to its billing speed. Codex omits the field when + * no tier was requested, which bills as standard, as do `default` and + * `standard`. `fast` is accepted as an alias of `priority`. + */ +function codexSpeed(serviceTier: unknown): UsageSpeed { + if (serviceTier === "priority" || serviceTier === "fast") return "fast"; + if (serviceTier === "ultrafast") return "ultrafast"; + return "standard"; +} + /* -------------------------------------------------------------------------- */ /* Grok Build */ /* -------------------------------------------------------------------------- */ @@ -457,7 +483,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { sessionId, totals: grokTotalsToUsage(topLevel), reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), - fast: false, + speed: "standard", // No prompt id means we cannot tell two same-second updates apart. dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, }, @@ -504,7 +530,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { sessionId, totals, reportedCostUsd, - fast: false, + speed: "standard", dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, }); } From a5b34b25378fbbf90ce36ee101a11528ccf11c7e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 02:07:12 -0700 Subject: [PATCH 29/82] fix(clients): a dev server left running no longer says the thread is waiting (#15114) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 9 +++-- .../src/state/threadExecution.test.ts | 30 +++++++++++++++-- .../src/state/threadExecution.ts | 33 ++++++++++++++----- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8e105a49f37f..fe72ec446d34 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6859,7 +6859,7 @@ export default function ChatView(props: ChatViewProps) { // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, // and the informational parked-thread banner last — it must never cover another. - // Background work (subagent fleets, workflow runs, watch loops) can outlive + // Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive // the turn; once it settles, the composer stop button is gone, so this // banner is the only visible stop affordance. The interrupt path also // accepts a completed run while its provider still has background work. @@ -6907,9 +6907,14 @@ export default function ChatView(props: ChatViewProps) { id: `background-work:${activeThread.id}`, variant: "default", priority: "activity", + // A dev server can run for hours after the agent is done, so only work + // that will wake the agent pulses. icon: (
- - - - - - +
- - - - - + + + + + + {breakdownModels.length === 0 ? ( - ) : ( - breakdownModels.map((model) => ( - - - - - - - )) + breakdownModels.map((model, index) => { + const key = `${model.provider}:${model.model}`; + const value = metric === "tokens" ? model.totalTokens : model.costUsd; + return ( + + + + + + + + ); + }) )}
ModelCostShareTokens
#ModelCostShareTokens
+ No activity in this window.
- - - {model.model} - - - {isModelCostUnknown(model) ? ( - Unpriced - ) : ( - formatUsd(model.costUsd) - )} - - {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
{index + 1} + {/* The button's overlay makes the whole row open the model. + Focus shows as the row's hover fill, not a ring. */} + +
+
0 && breakdownPeak > 0 + ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)` + : 0, + backgroundColor: + PROVIDER_PRESENTATION[model.provider].color, + }} + /> +
+
+ {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} + + {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} + {formatTokens(model.totalTokens)}
@@ -772,6 +836,35 @@ export function UsagePage() {
+ {selectedModel !== undefined && !showingLimits ? ( + { + setSelectedModelKey(null); + setPriceDialog({ model: selectedModel.model }); + }} + onClose={() => setSelectedModelKey(null)} + /> + ) : null} + {priceDialog ? ( + { + if (!open) setPriceDialog(null); + }} + /> + ) : null} ); } @@ -989,6 +1082,7 @@ function UsageEnvironmentFilter({ isPartial, duplicateSources, contractMismatches, + onOpenModelPrices, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -998,8 +1092,8 @@ function UsageEnvironmentFilter({ readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly contractMismatches: MergedUsage["contractMismatches"]; + readonly onOpenModelPrices: () => void; }) { - const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; const label = allSelected ? "All environments" @@ -1015,121 +1109,108 @@ function UsageEnvironmentFilter({ contractMismatches.length > 0; return ( - <> - - } - className="group/usage-environment min-w-0 max-w-full" - > - {label} - - {showUsageStatus && pendingCount > 0 ? ( - <> - - - {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still - scanning - {isPartial ? "; totals are partial" : ""} - - - ) : showUsageStatus && hasIssue ? ( - - ) : ( - - )} - - - - onSelectionChange(checked ? null : new Set())} - > - All environments - - - {environments.map((environment) => { - const checked = - selectedEnvironmentIds === null || - selectedEnvironmentIds.has(environment.environmentId); - const status = - environment.error !== null - ? "Unavailable" - : environment.summary !== null && - !isCompatibleUsageContractVersion( - environment.summary.contractVersion, - USAGE_CONTRACT_VERSION, - ) - ? "Update required" - : environment.summary === null - ? "Scanning…" - : environment.isPending - ? "Refreshing…" - : "Ready"; - return ( - { - const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); - if (nextChecked) next.add(environment.environmentId); - else next.delete(environment.environmentId); - onSelectionChange(next.size === environments.length ? null : next); - }} - > - - {environment.label} - {showUsageStatus ? ( - - {status} - - ) : null} - - - ); - })} - {environments.length === 0 ? ( -

No environments connected.

- ) : null} - {showUsageStatus && isPartial ? ( -

- Totals are partial while selected environments scan. -

- ) : null} - {showUsageStatus ? ( - + } className="group/usage-environment min-w-0 max-w-full"> + {label} + + {showUsageStatus && pendingCount > 0 ? ( + <> + + + {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning + {isPartial ? "; totals are partial" : ""} + + + ) : showUsageStatus && hasIssue ? ( + - ) : null} - - setModelPricesOpen(true)}> - - Model prices - -
-
- {modelPricesOpen ? ( - - ) : null} - + ) : ( + + )} + + + + onSelectionChange(checked ? null : new Set())} + > + All environments + + + {environments.map((environment) => { + const checked = + selectedEnvironmentIds === null || + selectedEnvironmentIds.has(environment.environmentId); + const status = + environment.error !== null + ? "Unavailable" + : environment.summary !== null && + !isCompatibleUsageContractVersion( + environment.summary.contractVersion, + USAGE_CONTRACT_VERSION, + ) + ? "Update required" + : environment.summary === null + ? "Scanning…" + : environment.isPending + ? "Refreshing…" + : "Ready"; + return ( + { + const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); + if (nextChecked) next.add(environment.environmentId); + else next.delete(environment.environmentId); + onSelectionChange(next.size === environments.length ? null : next); + }} + > + + {environment.label} + {showUsageStatus ? ( + + {status} + + ) : null} + + + ); + })} + {environments.length === 0 ? ( +

No environments connected.

+ ) : null} + {showUsageStatus && isPartial ? ( +

+ Totals are partial while selected environments scan. +

+ ) : null} + {showUsageStatus ? ( + + ) : null} + + + + Model prices + +
+ ); } @@ -1173,15 +1254,16 @@ function UsageSkeleton() {

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} - -
- ), - )} + +
+ +
+
+ + +
@@ -1195,3 +1277,16 @@ function UsageSkeleton() { ); } + +function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) { + return ( +
+ {labels.map((label) => ( +
+ {label} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index 79ad4d473409..c9af1c0e1770 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -91,13 +91,16 @@ type SaveAttempt = { >; }; +/** Edits custom model prices. `initialModel` opens with a new row for that model. */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, + initialModel, onOpenChange, }: { readonly usage: readonly EnvironmentUsageStatus[]; readonly initialSelectedEnvironmentIds: ReadonlySet | null; + readonly initialModel?: string | undefined; readonly onOpenChange: (open: boolean) => void; }) { const environments = useAtomValue(priceTargetsAtom); @@ -105,7 +108,16 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - const [drafts, setDrafts] = useState([]); + // A model that already has a custom price somewhere is edited in its existing row. + const [drafts, setDrafts] = useState(() => + initialModel === undefined || + selected.some( + (environment) => + environment.prices !== null && Object.hasOwn(environment.prices, initialModel), + ) + ? [] + : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], + ); const [pending, setPending] = useState(false); const [attempt, setAttempt] = useState(null); const focusRowRef = useRef(null); diff --git a/apps/web/src/components/usage/UsageShareBar.tsx b/apps/web/src/components/usage/UsageShareBar.tsx new file mode 100644 index 000000000000..6d94a912df53 --- /dev/null +++ b/apps/web/src/components/usage/UsageShareBar.tsx @@ -0,0 +1,73 @@ +import { formatPercent } from "@t3tools/shared/usageFormat"; +import type { ReactNode } from "react"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export interface ShareSegment { + readonly label: string; + readonly value: number; + readonly color: string; +} + +/** + * One part-to-whole bar with its legend, for cost or tokens split by type or + * speed. Empty segments are left out, and nothing renders without a total. + */ +export function UsageShareBar({ + label, + segments, + format, + aside, +}: { + readonly label: string; + readonly segments: readonly ShareSegment[]; + readonly format: (value: number) => string; + readonly aside?: ReactNode; +}) { + const visible = segments.filter((segment) => segment.value > 0); + const total = visible.reduce((sum, segment) => sum + segment.value, 0); + if (total <= 0) return null; + + return ( +
+
+

{label}

+ {aside} +
+
`${segment.label} ${format(segment.value)}`).join(", ")}`} + className="flex h-2 gap-0.5" + > + {visible.map((segment) => ( + + + } + /> + + {segment.label} · {format(segment.value)} · {formatPercent(segment.value / total)} + + + ))} +
+
+ {visible.map((segment) => ( + + + {segment.label} + {format(segment.value)} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index db84528ce361..ff0a611c7d45 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,16 +1,30 @@ import type { ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { sortModelsByTokens } from "./usageBreakdown"; +import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown"; -const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({ +const model = ( + name: string, + totalTokens: number, + costUsd: number, + overrides: Partial = {}, +): ModelTotals => ({ model: name, provider: "codex", costUsd, totalTokens, + tokens: { + uncachedInputTokens: totalTokens, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, records: 1, unpricedRecords: 0, + unpricedTokens: 0, costShare: 0, + ...overrides, }); describe("sortModelsByTokens", () => { @@ -29,3 +43,24 @@ describe("sortModelsByTokens", () => { expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]); }); }); + +describe("model rates", () => { + it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { + const mixed = model("mixed", 4_000_000, 6, { + tokens: { + uncachedInputTokens: 1_000_000, + cachedInputTokens: 1_000_000, + cacheCreationTokens: 2_000_000, + outputTokens: 0, + reasoningTokens: 0, + }, + records: 4, + unpricedRecords: 1, + unpricedTokens: 1_000_000, + }); + + expect(cacheHitRate(mixed)).toBe(0.25); + expect(costPerMillionTokens(mixed)).toBe(2); + expect(costPerMillionTokens({ ...mixed, unpricedRecords: 4 })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index 8245b593e9a9..0249eb26aff2 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -1,7 +1,77 @@ -import type { ModelTotals } from "@t3tools/shared/usageMerge"; +import type { UsageTokenTotals } from "@t3tools/contracts"; +import { + isModelCostUnknown, + type CategoryCost, + type ModelTotals, + type SpeedCost, +} from "@t3tools/shared/usageMerge"; + +import type { ShareSegment } from "./UsageShareBar"; export function sortModelsByTokens(models: readonly ModelTotals[]) { return models.toSorted( (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, ); } + +/** + * Share of a model's input read from cache, or `null` without input. Cache + * writes count as misses: that input was processed in full. + */ +export function cacheHitRate({ tokens }: ModelTotals): number | null { + const input = tokens.uncachedInputTokens + tokens.cachedInputTokens + tokens.cacheCreationTokens; + return input === 0 ? null : tokens.cachedInputTokens / input; +} + +/** Effective USD per million priced tokens, or `null` when none were priced. */ +export function costPerMillionTokens(model: ModelTotals): number | null { + const pricedTokens = model.totalTokens - model.unpricedTokens; + return pricedTokens <= 0 || isModelCostUnknown(model) + ? null + : (model.costUsd / pricedTokens) * 1_000_000; +} + +/** A neutral step between background and foreground, so mixes never borrow a provider's color. */ +const ink = (percent: number) => + `color-mix(in oklab, var(--foreground) ${percent}%, var(--background))`; + +/** Adjacent segments stay above the 15 ΔE separation floor in both themes. */ +const TYPE_COLORS = { + input: ink(60), + cacheRead: ink(30), + cacheWrite: ink(72), + output: ink(100), + other: ink(44), +}; + +export function costTypeSegments(cost: CategoryCost): readonly ShareSegment[] { + return [ + { label: "Input", value: cost.input, color: TYPE_COLORS.input }, + { label: "Cache read", value: cost.cacheRead, color: TYPE_COLORS.cacheRead }, + { label: "Cache write", value: cost.cacheWrite, color: TYPE_COLORS.cacheWrite }, + { label: "Output", value: cost.output, color: TYPE_COLORS.output }, + // Reported cost with no rates to split it, or from older servers. Below a + // cent it is rounding, not usage. + { label: "Other", value: cost.unsplit >= 0.005 ? cost.unsplit : 0, color: TYPE_COLORS.other }, + ]; +} + +export function tokenTypeSegments( + tokens: Omit, +): readonly ShareSegment[] { + return [ + { label: "Input", value: tokens.uncachedInputTokens, color: TYPE_COLORS.input }, + { label: "Cache read", value: tokens.cachedInputTokens, color: TYPE_COLORS.cacheRead }, + { label: "Cache write", value: tokens.cacheCreationTokens, color: TYPE_COLORS.cacheWrite }, + { label: "Output", value: tokens.outputTokens, color: TYPE_COLORS.output }, + ]; +} + +/** Speeds are ordered by price, so they brighten from standard to ultrafast. */ +export function speedCostSegments(cost: SpeedCost): readonly ShareSegment[] { + return [ + { label: "Standard", value: cost.standard, color: ink(34) }, + { label: "Fast", value: cost.fast, color: ink(66) }, + { label: "Ultrafast", value: cost.ultrafast, color: ink(100) }, + ]; +} diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f61bb9060384..7bd4657e7d56 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -10,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, type EnvironmentId, + type UsageBucket, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -79,6 +80,33 @@ export interface UsageView { readonly refresh: (input?: UsageSummaryInput) => Promise; } +/** + * Merges every environment that has answered. `keepBucket` narrows the merge, + * for example to one model; source ownership still applies, so the result + * matches that slice of the full merge. Session counts are per directory and + * are not narrowed. + */ +export function mergeAnsweredUsage( + environments: readonly EnvironmentUsageStatus[], + keepBucket?: (bucket: UsageBucket) => boolean, +): MergedUsage { + const answered: EnvironmentUsage[] = environments.flatMap(({ environmentId, label, summary }) => + summary === null + ? [] + : [ + { + environmentId, + label, + summary: + keepBucket === undefined + ? summary + : { ...summary, buckets: summary.buckets.filter(keepBucket) }, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); +} + export function useUsage( input: UsageSummaryInput, selectedEnvironmentIds: ReadonlySet | null = null, @@ -126,20 +154,7 @@ export function useUsage( [selectedEnvironments, windowKey], ); - const merged = useMemo(() => { - const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => - environment.summary === null - ? [] - : [ - { - environmentId: environment.environmentId, - label: environment.label, - summary: environment.summary, - }, - ], - ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [selectedEnvironments]); + const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]); const answeredCount = selectedEnvironments.filter( (environment) => environment.summary !== null, diff --git a/docs/user/usage.md b/docs/user/usage.md index 5c52122bf8a2..24db5f3f3b92 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -8,7 +8,10 @@ desktop when the terminal is not focused. Customize `usage.open` in **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent -cost. These estimates are not your subscription bill. +cost, split by token type and by speed. These estimates are not your subscription bill. +**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be +split, such as a provider-reported cost for a model without public rates, shows as **Other**. +Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. @@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter; choose all environments or select individual destinations. Enter the exact model ID and USD rates per million input and output tokens. You can enter any model ID, including models -without public pricing. +without public pricing. When a model on **Usage** has no known price, select it under +**Breakdown** and choose **Set price** to open this table with that model added. Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for tokens that are free. Saved prices replace automatic pricing for all of that environment's diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 97db8594379a..f6282b7b2935 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -18,7 +18,8 @@ import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from ". * client renders partial coverage when an environment reports an older version * rather than failing the whole page. * Adding providers or other array-element variants is additive: unknown - * entries are skipped on decode and do not require a version bump. + * entries are skipped on decode and do not require a version bump. So are + * optional bucket fields, which older clients ignore. */ export const USAGE_CONTRACT_VERSION = 6 as const; @@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({ }); export type UsageTokenTotals = typeof UsageTokenTotals.Type; +/** + * A bucket's cost split by token category, in USD. A provider-reported cost is + * split in proportion to the model's list rates. + */ +export const UsageCategoryCost = Schema.Struct({ + input: Schema.Number, + cacheRead: Schema.Number, + cacheWrite: Schema.Number, + output: Schema.Number, +}); +export type UsageCategoryCost = typeof UsageCategoryCost.Type; + /** * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start * instant of a rolling bucket and is present only for hourly requests. @@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({ * rather than derived on the client. */ cacheSavingsUsd: Schema.Number, + /** + * `costUsd` by token category. Cost with no known rates stays out of it, and + * it is absent when nothing could be split or the server predates it. + */ + categoryCostUsd: Schema.optional(UsageCategoryCost), + /** Cost of fast and ultrafast requests. Absent when zero; the rest is standard. */ + fastCostUsd: Schema.optional(Schema.Number), + ultrafastCostUsd: Schema.optional(Schema.Number), + /** What fast and ultrafast requests cost above the standard rate. Absent when zero. */ + speedPremiumUsd: Schema.optional(Schema.Number), costSource: UsageCostSource, /** Distinct assistant responses, after de-duplication. */ records: NonNegativeInt, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 81d1e8565a6c..eb50f2fb19b3 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -546,6 +546,67 @@ describe("mergeUsage", () => { ]); }); + it("splits cost by category and speed, counting older servers as unsplit standard cost", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + costUsd: 10, + categoryCostUsd: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4 }, + fastCostUsd: 6, + speedPremiumUsd: 3, + }), + bucket({ + provider: "codex", + model: "unknown-model", + costUsd: 0, + costSource: "unpriced", + unpricedRecords: 5, + }), + // Reported cost on one record, no rates for the other four. + bucket({ + provider: "codex", + model: "partly-reported", + costUsd: 0, + unpricedRecords: 4, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 5 })], + [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }], + USAGE_MERGE_COMPATIBLE_SINCE, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.categoryCost).toEqual({ + input: 1, + cacheRead: 2, + cacheWrite: 3, + output: 4, + unsplit: 5, + }); + expect(merged.speedCost).toEqual({ standard: 9, fast: 6, ultrafast: 0, premium: 3 }); + expect(merged.models.map(({ model, unpricedTokens }) => [model, unpricedTokens])).toEqual([ + ["claude-fable-5", 0], + ["unknown-model", 1160], + ["partly-reported", 928], + ]); + }); + it("orders models by cost descending", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 90c9a1ff4261..18c2c9f11c49 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -14,6 +14,7 @@ import { type UsageSource, type UsageSourceFingerprint, type UsageSummary, + type UsageTokenTotals, } from "@t3tools/contracts"; export interface EnvironmentUsage { @@ -37,12 +38,18 @@ export interface ModelTotals { readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; + readonly tokens: UsageTokenTotals; readonly records: number; /** * Records whose tokens are counted here but which contributed nothing to * `costUsd`. When it equals `records` the cost is unknown, not zero. */ readonly unpricedRecords: number; + /** + * Tokens with no known rates, which a custom price would cover. A cell that + * mixes these with reported costs counts its tokens by record share. + */ + readonly unpricedTokens: number; readonly costShare: number; } @@ -76,6 +83,27 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +/** + * `costUsd` by token category. `unsplit` is cost no rates could split, + * including all cost from servers that predate the split. + */ +export interface CategoryCost { + readonly input: number; + readonly cacheRead: number; + readonly cacheWrite: number; + readonly output: number; + readonly unsplit: number; +} + +/** `costUsd` by request speed. Servers that predate speeds count as standard. */ +export interface SpeedCost { + readonly standard: number; + readonly fast: number; + readonly ultrafast: number; + /** What fast and ultrafast requests cost above the standard rate. */ + readonly premium: number; +} + export interface UsageContractMismatch { readonly environmentId: EnvironmentId; readonly direction: "serverBehind" | "clientBehind"; @@ -97,6 +125,8 @@ export interface MergedUsage { readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; + readonly categoryCost: CategoryCost; + readonly speedCost: SpeedCost; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; @@ -307,6 +337,8 @@ const EMPTY_MERGED: MergedUsage = { unpricedShare: 0, cacheSavingsUsd: 0, }, + categoryCost: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, unsplit: 0 }, + speedCost: { standard: 0, fast: 0, ultrafast: 0, premium: 0 }, duplicateSources: [], contributingEnvironments: [], contractMismatches: [], @@ -364,6 +396,8 @@ export function mergeUsage( let cacheSavingsUsd = 0; let providerReportedRecords = 0; let unpricedRecords = 0; + const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }; + const speedCost = { fast: 0, ultrafast: 0, premium: 0 }; const providerAccumulator = new Map< UsageProviderKind, @@ -375,8 +409,10 @@ export function mergeUsage( provider: UsageProviderKind; costUsd: number; totalTokens: number; + tokens: UsageTokenTotals; records: number; unpricedRecords: number; + unpricedTokens: number; } >(); const dailyAccumulator = new Map< @@ -434,6 +470,15 @@ export function mergeUsage( records += bucket.records; unpricedRecords += bucket.unpricedRecords; if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + if (bucket.categoryCostUsd !== undefined) { + categoryCost.input += bucket.categoryCostUsd.input; + categoryCost.cacheRead += bucket.categoryCostUsd.cacheRead; + categoryCost.cacheWrite += bucket.categoryCostUsd.cacheWrite; + categoryCost.output += bucket.categoryCostUsd.output; + } + speedCost.fast += bucket.fastCostUsd ?? 0; + speedCost.ultrafast += bucket.ultrafastCostUsd ?? 0; + speedCost.premium += bucket.speedPremiumUsd ?? 0; const provider = providerAccumulator.get(bucket.provider) ?? { costUsd: 0, @@ -451,13 +496,31 @@ export function mergeUsage( provider: bucket.provider, costUsd: 0, totalTokens: 0, + tokens: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, records: 0, unpricedRecords: 0, + unpricedTokens: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; + model.tokens = { + uncachedInputTokens: model.tokens.uncachedInputTokens + bucket.totals.uncachedInputTokens, + cachedInputTokens: model.tokens.cachedInputTokens + bucket.totals.cachedInputTokens, + cacheCreationTokens: model.tokens.cacheCreationTokens + bucket.totals.cacheCreationTokens, + outputTokens: model.tokens.outputTokens + bucket.totals.outputTokens, + reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens, + }; model.records += bucket.records; model.unpricedRecords += bucket.unpricedRecords; + if (bucket.records > 0) { + model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records; + } modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { @@ -515,8 +578,10 @@ export function mergeUsage( provider: totals.provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, + tokens: totals.tokens, records: totals.records, unpricedRecords: totals.unpricedRecords, + unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); @@ -555,6 +620,22 @@ export function mergeUsage( records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, }, + // Clamped so float error never shows as a negative remainder. + categoryCost: { + ...categoryCost, + unsplit: Math.max( + 0, + costUsd - + categoryCost.input - + categoryCost.cacheRead - + categoryCost.cacheWrite - + categoryCost.output, + ), + }, + speedCost: { + ...speedCost, + standard: Math.max(0, costUsd - speedCost.fast - speedCost.ultrafast), + }, duplicateSources: duplicates, contributingEnvironments, contractMismatches, From 18b21325c3ab1ff4f0ee7b017d9c4eefc2b2d307 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 03:08:44 -0700 Subject: [PATCH 33/82] feat(server): agents can watch a PR and get woken when checks, reviews, or conflicts need them (#15057) Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/threads/git/GitOverviewSheet.tsx | 2 +- .../src/features/threads/thread-work-log.tsx | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../toolkits/pullRequests/handlers.test.ts | 53 ++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 68 +++- .../src/mcp/toolkits/pullRequests/tools.ts | 62 ++++ .../src/orchestration-v2/Orchestrator.ts | 194 ++++++++++-- .../PullRequestWatchReactor.ts | 219 +++++++++++++ .../orchestration-v2/pullRequestWatch.test.ts | 217 +++++++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 209 ++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 297 ++++++++++++++++++ .../testkit/OrchestratorScenario.ts | 1 + .../src/provider/RuntimeInstructions.ts | 2 +- .../src/pullRequest/GitHubPullRequestCli.ts | 4 +- .../src/pullRequest/PullRequestProvider.ts | 2 + .../src/pullRequest/PullRequestService.ts | 1 + .../pullRequest/gitHubPullRequestJson.test.ts | 20 ++ .../src/pullRequest/gitHubPullRequestJson.ts | 19 +- apps/server/src/server.ts | 11 + .../src/components/chat/MessagesTimeline.tsx | 2 + .../pullRequest/ThreadPullRequestsPanel.tsx | 57 +++- docs/user/source-control.md | 7 + .../client-runtime/src/operations/commands.ts | 14 + .../src/state/threadCommands.ts | 9 + packages/client-runtime/src/t3ToolSummary.ts | 10 + .../src/work-log/presentation.ts | 10 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestrationV2.ts | 34 ++ packages/contracts/src/pullRequest.ts | 4 + packages/contracts/src/threadPullRequest.ts | 25 ++ packages/shared/src/t3McpToolPresentation.ts | 12 + 31 files changed, 1531 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/orchestration-v2/PullRequestWatchReactor.ts create mode 100644 apps/server/src/orchestration-v2/pullRequestWatch.test.ts create mode 100644 apps/server/src/orchestration-v2/pullRequestWatch.ts diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 17b1b3ea377c..828c8838e31a 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { { void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { if (!opened) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 826b9205ac75..2f06b9462b4d 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1368,6 +1368,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { case "link-pr": case "unlink-pr": case "list-prs": + case "watch-pr": + case "unwatch-pr": return "arrow.triangle.pull"; case "read": return { ios: "eye", android: "visibility" }; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 91ab19b55e01..5381645e9fcf 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -240,6 +240,7 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadVisitedTracking: true, threadPullRequests: true, + threadPullRequestWatch: true, pullRequestStackActions: true, threadPullRequestLinking: true, serverResolvedCommandContext: true, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index dab6e7176498..e329ade90d9e 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("watching an unlinked pull request links it first", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("watch_pull_request", { + url: "https://github.com/t3tools/t3code/pull/9", + }); + // The harness thread never changes, so the result reports what it still holds. + expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.watch", + number: 9, + watching: true, + link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" }, + }, + ]); + }), + ); + + it.effect("refuses to watch a merged pull request and stops an existing watch", () => + Effect.gen(function* () { + const watch = { + startedAt: "2026-08-20T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + remarksThrough: "2026-08-20T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }; + const merged = makeLink(1, { headBranch: "done" }); + const harness = yield* makeHarness({ + thread: makeThread([ + { ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } }, + makeLink(2, { headBranch: "idle" }), + makeLink(3, { headBranch: "watched", watch }), + ]), + }); + const error = yield* harness + .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" }); + expect( + yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }), + ).toMatchObject({ wasWatching: true }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.watch", number: 3, watching: false }, + ]); + }), + ); + it.effect("links by repository and number, defaulting the host to the project's", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => { number: 3, url: "https://github.com/t3tools/t3code/pull/3", source: "agent", + watching: false, state: "open", title: "PR 3", headBranch: "feat-c", diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 19677e1bd212..97af65e1a7bc 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -34,7 +34,9 @@ import { PullRequestHostRequiredError, PullRequestUnlinkFailedError, PullRequestListFailedError, + PullRequestNotOpenError, type PullRequestTargetInput, + PullRequestWatchFailedError, PullRequestThreadNotFoundError, PullRequestsToolkit, type ThreadPullRequestEntry, @@ -122,6 +124,7 @@ function entryOf( number: link.number, url: link.url, source: link.source, + watching: link.watch !== undefined, state: link.snapshot?.state ?? null, title: link.snapshot?.title ?? null, headBranch: link.snapshot?.headBranch ?? null, @@ -163,7 +166,8 @@ const make = Effect.gen(function* () { Failure: | typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError - | typeof PullRequestListFailedError, + | typeof PullRequestListFailedError + | typeof PullRequestWatchFailedError, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); const thread = yield* engine @@ -178,7 +182,10 @@ const make = Effect.gen(function* () { const projectOf = ( thread: OrchestrationV2ThreadShell, - Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestWatchFailedError, ) => projects.getShell(thread.projectId).pipe( Effect.map(Option.getOrUndefined), @@ -186,14 +193,65 @@ const make = Effect.gen(function* () { ); const dispatchFailure = - (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => + ( + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestWatchFailedError, + ) => ( cause: Cause.Cause, - ): Effect.Effect => + ): Effect.Effect< + never, + PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError + > => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) : Effect.fail(new Failure({ cause })); + /** + * Starts or stops a watch. One command links an unlinked pull request and watches it, and the + * result reports the state the thread holds afterwards. + */ + const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( + input: PullRequestTargetInput, + watching: boolean, + ) { + const thread = yield* requireThread(PullRequestWatchFailedError); + const project = yield* projectOf(thread, PullRequestWatchFailedError); + const target = yield* resolveTarget(input, project); + const watchedLink = (shell: OrchestrationV2ThreadShell) => + threadPullRequestsOf(shell).find( + (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target), + ); + const before = watchedLink(thread); + const state = before?.snapshot?.state; + if (watching && state !== undefined && state !== "open") { + return yield* new PullRequestNotOpenError({ state }); + } + yield* engine + .dispatch({ + type: "thread.pull-request.watch", + commandId: yield* commandId("mcp-pr-watch", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + watching, + ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), + }) + .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); + const after = yield* requireThread(PullRequestWatchFailedError); + return { + host: target.host, + repository: target.repository, + number: target.number, + url: target.url, + watching: watchedLink(after)?.watch !== undefined, + wasWatching: before?.watch !== undefined, + }; + }); + return PullRequestsToolkit.of({ link_pull_request: (input) => Effect.gen(function* () { @@ -260,6 +318,8 @@ const make = Effect.gen(function* () { }), list_thread_pull_requests: () => requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), + watch_pull_request: (input) => setWatching(input, true), + unwatch_pull_request: (input) => setWatching(input, false), }); }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 90f22cea2615..52ac473a3d50 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError()( + "PullRequestWatchFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not change whether the pull request is watched."; + } +} + +export class PullRequestNotOpenError extends Schema.TaggedError()( + "PullRequestNotOpenError", + { state: Schema.String }, +) { + override get message(): string { + return `The pull request is ${this.state}, so there is nothing to watch.`; + } +} + export class PullRequestListFailedError extends Schema.TaggedError()( "PullRequestListFailedError", { cause: Schema.Defect() }, @@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([ PullRequestLinkFailedError, PullRequestUnlinkFailedError, PullRequestListFailedError, + PullRequestWatchFailedError, + PullRequestNotOpenError, ]); export type PullRequestToolError = typeof PullRequestToolError.Type; @@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({ }); export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; +export const WatchPullRequestResult = Schema.Struct({ + ...PullRequestIdentity, + watching: Schema.Boolean.annotate({ + description: "Whether T3 Code now watches the pull request for this thread.", + }), + wasWatching: Schema.Boolean.annotate({ + description: "Whether it was already watched before the call.", + }), +}); +export type WatchPullRequestResult = typeof WatchPullRequestResult.Type; + export const ThreadPullRequestEntry = Schema.Struct({ ...PullRequestIdentity, source: ThreadPullRequestLinkSource, + watching: Schema.Boolean, state: Schema.NullOr(PullRequestState), title: Schema.NullOr(Schema.String), headBranch: Schema.NullOr(Schema.String), @@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); +const WatchPullRequestTool = Tool.make("watch_pull_request", { + description: + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. Watching ends when the pull request merges or closes, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request.", + parameters: PullRequestTargetInput, + success: WatchPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Watch pull request") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { + description: + "Stop T3 Code from watching a pull request for this thread. The pull request stays linked. Pass the URL, or repository plus number.", + parameters: PullRequestTargetInput, + success: WatchPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Stop watching pull request") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + export const PullRequestsToolkit = Toolkit.make( LinkPullRequestTool, UnlinkPullRequestTool, ListThreadPullRequestsTool, + WatchPullRequestTool, + UnwatchPullRequestTool, ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 28296f9d7b78..2707fe5b8344 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -19,6 +19,8 @@ import { OrchestrationV2Command, type OrchestrationV2InternalCommand, type OrchestrationV2ServerCommand, + type ThreadPullRequestLink, + type ThreadPullRequestWatch, type OrchestrationV2AppThread, type OrchestrationV2ContextHandoff, type OrchestrationV2ContextSourcePoint, @@ -371,6 +373,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.pull-request.watch": + case "thread.pull-request-watch.sync": case "thread.pull-request.sync": case "thread.title.regeneration.complete": case "thread.runtime-mode.set": @@ -444,6 +448,35 @@ function hasLiveRun(projection: Pick): ); } +/** The link with its watch replaced, or removed when `watch` is undefined. */ +function withPullRequestWatch( + link: ThreadPullRequestLink, + watch: ThreadPullRequestWatch | undefined, +): ThreadPullRequestLink { + const { watch: _previous, ...rest } = link; + return watch === undefined ? rest : { ...rest, watch }; +} + +/** A legacy single-PR link as a link entry. Re-linking a pull request keeps its watch. */ +function legacyPullRequestLink( + thread: OrchestrationV2AppThread, + linked: ThreadLinkedPullRequest, + now: DateTime.Utc, +): ThreadPullRequestLink { + const key = legacyThreadPullRequestKey(linked); + return withPullRequestWatch( + { + ...key, + url: linked.url, + source: "manual", + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }, + threadPullRequestsOf(thread).find((link) => threadPullRequestKeysEqual(link, key))?.watch, + ); +} + function delegatedCompletionWakeDetail(taskIds: ReadonlyArray): string { const taskList = taskIds.join(", "); return taskIds.length === 1 @@ -2188,9 +2221,66 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + // Checked under the thread lock: the watch or the thread can change while the host is read. + // The watch is recorded first so the wake's own thread events carry it. + const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( + function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.pull-request-watch.sync" } + >, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const key = normalizeThreadPullRequestKey(command); + const link = threadPullRequestsOf(thread).find( + (candidate) => + candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, key), + ); + // Same rule as a direct message.dispatch: a provider-native subagent takes no messages. + const inactive = + thread.archivedAt !== null || + thread.settledOverride === "settled" || + thread.settledAt !== null || + isProviderNativeSubagentThread(thread); + if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The pull request watch ended or its thread settled while it was read.", + }); + } + yield* dispatchThreadMutation(command, events, effects); + if (command.wake === undefined) return; + yield* dispatchMessage( + { + type: "message.dispatch", + commandId: command.commandId, + threadId: command.threadId, + messageId: command.wake.messageId, + text: command.wake.text, + notification: command.wake.notification, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }, + events, + effects, + ); + }, + ); + const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( command: Extract< - OrchestrationV2Command, + OrchestrationV2ServerCommand, { readonly type: | "thread.archive" @@ -2209,6 +2299,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.pull-request.link" | "thread.pull-request.unlink" | "thread.pull-request-link.sync" + | "thread.pull-request.watch" + | "thread.pull-request-watch.sync" | "thread.pull-request.sync" | "thread.title.regeneration.complete" | "thread.runtime-mode.set" @@ -2236,6 +2328,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is deleted.`, }); } + if ( + command.type === "thread.pull-request.watch" && + command.watching && + isProviderNativeSubagentThread(thread) + ) { + return yield* new OrchestratorSubagentThreadReadOnlyError({ + commandId: command.commandId, + threadId: command.threadId, + }); + } if ( command.type === "thread.metadata.update" && command.expectedWorktreePath !== undefined && @@ -2719,16 +2821,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ...(command.linkedPullRequest - ? [ - { - ...legacyThreadPullRequestKey(command.linkedPullRequest), - url: command.linkedPullRequest.url, - source: "manual" as const, - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }, - ] + ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)] : []), ], }), @@ -2801,7 +2894,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); pullRequests = belongsToStack ? links.map((link) => - link === existing ? { ...link, source: "stack-dismissed" as const } : link, + link === existing + ? { + ...withPullRequestWatch(link, undefined), + source: "stack-dismissed" as const, + } + : link, ) : links.filter((link) => link !== existing); } else { @@ -2830,6 +2928,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, }; } + case "thread.pull-request.watch": + case "thread.pull-request-watch.sync": { + const key = normalizeThreadPullRequestKey(command); + const startedAt = DateTime.formatIso(now); + const linked = threadPullRequestsOf(thread); + const visible = (link: ThreadPullRequestLink) => + link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, key); + // A watch started on an unlinked (or dismissed) pull request links it in the same step. + const links = + command.type === "thread.pull-request.watch" && + command.watching && + command.link !== undefined && + !linked.some(visible) + ? [ + ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)), + { + ...key, + url: command.link.url, + source: command.link.source, + linkedAt: startedAt, + snapshot: null, + stack: null, + }, + ] + : linked; + const existing = links.find(visible); + if (existing === undefined) return thread; + const watch = + command.type === "thread.pull-request-watch.sync" + ? // Progress read before a stop or restart must not bring the old watch back. + existing.watch?.startedAt === command.startedAt + ? (command.watch ?? undefined) + : existing.watch + : !command.watching + ? undefined + : (existing.watch ?? { + startedAt, + headSha: null, + failedChecks: [], + passed: false, + remarksThrough: startedAt, + remarkIds: [], + conflicting: false, + wakes: 0, + }); + if (watch === existing.watch && links === linked) return thread; + return { + ...thread, + pullRequests: links.map((link) => + link === existing ? withPullRequestWatch(link, watch) : link, + ), + // A user or agent starting or stopping a watch is activity; recorded progress is not. + updatedAt: command.type === "thread.pull-request.watch" ? now : thread.updatedAt, + }; + } case "thread.pull-request.sync": return { ...thread, @@ -2857,16 +3010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ...(command.linkedPullRequest - ? [ - { - ...legacyThreadPullRequestKey(command.linkedPullRequest), - url: command.linkedPullRequest.url, - source: "manual" as const, - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }, - ] + ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)] : []), ], }), @@ -2927,6 +3071,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.pull-request.watch": + case "thread.pull-request-watch.sync": case "thread.pull-request.sync": return "thread.pull-request-synced" as const; case "thread.runtime-mode.set": @@ -9130,6 +9276,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.pull-request.watch": case "thread.pull-request.sync": case "thread.title.regeneration.complete": case "thread.runtime-mode.set": @@ -9138,6 +9285,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider.switch": yield* dispatchThreadMutation(command, events, effects); break; + case "thread.pull-request-watch.sync": + yield* dispatchPullRequestWatchSync(command, events, effects); + break; case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts new file mode 100644 index 000000000000..4eb6ec80ce17 --- /dev/null +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -0,0 +1,219 @@ +import { + CommandId, + MessageId, + type OrchestrationV2Notification, + type ThreadPullRequestLink, + type ThreadPullRequestWatch, +} from "@t3tools/contracts"; +import { + normalizeThreadPullRequestKey, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; + +/** Passes in a row that could not read a pull request before its watch ends (one a minute). */ +const READ_FAILURE_LIMIT = 15; + +const logFailure = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning(message, { ...fields, cause }); + +interface WatchTarget { + readonly thread: ProjectionStore.ProjectionThreadPullRequests; + readonly link: ThreadPullRequestLink; + readonly watch: ThreadPullRequestWatch; +} + +const failureKey = ({ thread, link, watch }: WatchTarget) => + `${thread.id} ${threadPullRequestKeyOf(link)} ${watch.startedAt}`; + +function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { + return ( + left.startedAt === right.startedAt && + left.headSha === right.headSha && + left.failedChecks.join("\n") === right.failedChecks.join("\n") && + left.passed === right.passed && + left.remarksThrough === right.remarksThrough && + left.remarkIds.join("\n") === right.remarkIds.join("\n") && + left.conflicting === right.conflicting && + left.wakes === right.wakes + ); +} + +/** + * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look: + * checks finished on the head commit, someone else commented, or the branch started to + * conflict. One pass a minute reads each watched pull request; settled threads wait until + * they are active again, and a merged or closed pull request ends its watch. + */ +export class PullRequestWatchReactor extends Context.Service< + PullRequestWatchReactor, + { + readonly start: () => Effect.Effect; + /** One pass over every watched pull request. */ + readonly sweep: Effect.Effect; + } +>()("t3/orchestration-v2/PullRequestWatchReactor") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop. + const readFailures = new Map(); + + // Host-level identity, with the repository as linked, the way pull request sync reads it. + const identityOf = (link: ThreadPullRequestLink) => ({ + host: normalizeThreadPullRequestKey(link).host, + repository: link.repository, + number: link.number, + }); + + /** + * Records what a pass saw, and wakes the agent with it. The orchestrator applies this only + * while the same watch is on, so a stop or restart that lands during the host read wins. + */ + const record = ( + target: WatchTarget, + next: ThreadPullRequestWatch | null, + wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, + ) => + Effect.gen(function* () { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`), + threadId: target.thread.id, + ...identityOf(target.link), + startedAt: target.watch.startedAt, + watch: next, + ...(wake === undefined + ? {} + : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), + }); + }); + + // A watch that cannot read its pull request ends with a wake saying so, rather than showing + // "Watching" while it learns nothing. + const giveUp = (target: WatchTarget) => + record(target, null, { + text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it could not read it from the host for ${READ_FAILURE_LIMIT} minutes. Check it yourself, and call watch_pull_request to watch it again.`, + notification: { + source: { kind: "monitor" }, + outcome: "failed", + summary: `#${target.link.number}: stopped watching, could not read it`, + }, + }).pipe(Effect.catch(() => record(target, null))); + + const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { + const { thread, link, watch } = target; + const pullRequest = identityOf(link); + // A merged pull request cannot reopen, so its watch ends without a host read, even on a + // settled thread. A closed one can, so the host decides below. + if (link.snapshot?.state === "merged") return yield* record(target, null); + if (thread.settledOverride === "settled" || thread.settledAt !== null) return; + + const reference = { projectId: thread.projectId, ...pullRequest }; + const read = yield* Effect.exit( + Effect.all( + [ + pullRequests.detail({ ...reference, allowStale: false }), + pullRequests.activity(reference), + ], + { concurrency: 2 }, + ), + ); + // Only host reads count towards giving up; a refused wake is not the host's fault. + const key = failureKey(target); + if (Exit.isFailure(read)) { + if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); + const failures = (readFailures.get(key) ?? 0) + 1; + readFailures.set(key, failures); + // The count stays until the stop lands, so a failed stop is tried again next pass. + if (failures >= READ_FAILURE_LIMIT) { + yield* giveUp(target); + readFailures.delete(key); + } + return yield* Effect.failCause(read.cause); + } + readFailures.delete(key); + const [detail, activity] = read.value; + if (detail.state !== "open") return yield* record(target, null); + + // A degraded read (GitHub's review thread query failed) is truncated with no long thread to + // explain it, and would skip review comments, so remarks wait for a later pass. Replies past + // the first ten of a long review thread are not read. + const degraded = + activity.commentsTruncated && + !activity.reviewThreads.some((reviewThread) => reviewThread.nextCommentsCursor !== undefined); + const report = evaluatePullRequestWatch(watch, detail, degraded ? null : activity.comments); + if (report.changes.length > 0) { + return yield* record( + target, + report.exhausted ? null : report.next, + pullRequestWatchMessage({ + number: link.number, + url: link.url, + baseBranch: detail.baseBranch, + headSha: report.next.headSha, + report, + }), + ); + } + if (!watchesEqual(report.next, watch)) yield* record(target, report.next); + }); + + const sweep = Effect.gen(function* () { + const threads = yield* projections.getThreadsWithPullRequests(); + const targets = threads.flatMap((thread) => + visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => + link.watch === undefined ? [] : [{ thread, link, watch: link.watch }], + ), + ); + const keys = new Set(targets.map(failureKey)); + for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key); + yield* Effect.forEach( + targets, + (target) => + check(target).pipe( + Effect.catchCause( + logFailure("pull request watch check failed", { + threadId: target.thread.id, + pullRequest: threadPullRequestKeyOf(target.link), + }), + ), + ), + { concurrency: 4, discard: true }, + ); + }).pipe( + Effect.catchCause(logFailure("pull request watch sweep failed", {})), + Effect.withSpan("PullRequestWatchReactor.sweep"), + ); + + const start: PullRequestWatchReactor["Service"]["start"] = () => + forkParked(sweep.pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid)); + + return { start, sweep } satisfies PullRequestWatchReactor["Service"]; +}); + +export const layer = Layer.effect(PullRequestWatchReactor, make); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts new file mode 100644 index 000000000000..c247d78fd332 --- /dev/null +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -0,0 +1,217 @@ +import type { + PullRequestCheck, + PullRequestComment, + PullRequestDetail, + ThreadPullRequestWatch, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { + PULL_REQUEST_WATCH_WAKE_LIMIT, + evaluatePullRequestWatch, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; + +const STARTED = "2026-10-02T12:00:00.000Z"; + +const watch = (overrides: Partial = {}): ThreadPullRequestWatch => ({ + startedAt: STARTED, + headSha: null, + failedChecks: [], + passed: false, + remarksThrough: STARTED, + remarkIds: [], + conflicting: false, + wakes: 0, + ...overrides, +}); + +const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({ + name, + status, + description: null, + url: `https://ci.example/${name}`, +}); + +type Detail = Parameters[1]; + +const detail = (overrides: Partial = {}): Detail => ({ + headSha: "aaaaaaaaaa", + checks: [check("lint", "success"), check("test", "pending")], + mergeability: "mergeable", + viewer: "agent-user", + author: { login: "agent-user", name: null, avatarUrl: null }, + ...overrides, +}); + +const remark = ( + login: string, + createdAt: string, + body = "Please rename this.", +): PullRequestComment => ({ + id: `${login}-${createdAt}`, + kind: "review-comment", + author: { login, name: null, avatarUrl: null }, + body, + createdAt, + url: `https://github.com/o/r/pull/1#${login}`, + path: "src/index.ts", + reviewState: null, +}); + +const noRemarks: ReadonlyArray = []; + +describe("evaluatePullRequestWatch", () => { + it("reports each failure at once, even while another check never finishes", () => { + const bot = check("CodeRabbit", "pending"); + const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); + const lint = evaluatePullRequestWatch(watch(), first, noRemarks); + assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]); + assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []); + + // A different job failing later is news of its own. + const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); + const test = evaluatePullRequestWatch(lint.next, second, noRemarks); + assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]); + + // A rerun leaves the list while it runs, so failing again is reported again. + const rerun = evaluatePullRequestWatch(test.next, first, noRemarks); + assert.equal(evaluatePullRequestWatch(rerun.next, second, noRemarks).changes.length, 1); + + // A push reports its failures, even ones that failed between two passes. + const pushed = detail({ ...second, headSha: "bbbbbbbbbb" }); + assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1); + }); + + it("reports passed once the required checks pass, whatever the others do", () => { + const required = (name: string, status: PullRequestCheck["status"]) => ({ + ...check(name, status), + required: true, + }); + const green = detail({ + checks: [required("test", "success"), required("lint", "success"), check("bot", "pending")], + }); + const passed = evaluatePullRequestWatch(watch(), green, noRemarks); + assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 2, required: true }]); + assert.deepEqual(evaluatePullRequestWatch(passed.next, green, noRemarks).changes, []); + + // Where nothing is marked required, every check has to pass. + const plain = detail({ checks: [check("test", "success"), check("bot", "pending")] }); + assert.deepEqual(evaluatePullRequestWatch(watch(), plain, noRemarks).changes, []); + }); + + it("keeps remarks for a later pass when the conversation was not read whole", () => { + const comments = [remark("reviewer", "2026-10-02T12:06:00Z")]; + const partial = evaluatePullRequestWatch(watch(), detail(), null); + assert.deepEqual(partial.changes, []); + assert.equal( + evaluatePullRequestWatch(partial.next, detail(), comments).changes[0]?.kind, + "remarks", + ); + }); + + it("reports a remark that shows up late with the same time as a reported one", () => { + const first = remark("reviewer", "2026-10-02T12:06:00Z"); + const late = { ...remark("bot", "2026-10-02T12:06:00Z"), id: "late" }; + const reported = evaluatePullRequestWatch(watch(), detail(), [first]); + const again = evaluatePullRequestWatch(reported.next, detail(), [first, late]); + assert.deepEqual(again.changes, [{ kind: "remarks", remarks: [late] }]); + assert.deepEqual(again.next.remarkIds, [first.id, "late"]); + }); + + it("does not treat a failed check read as a rerun", () => { + const failed = detail({ checks: [check("lint", "failure")] }); + const reported = evaluatePullRequestWatch(watch(), failed, noRemarks); + assert.equal(reported.changes.length, 1); + const unreadable = evaluatePullRequestWatch(reported.next, detail({ checks: [] }), noRemarks); + assert.deepEqual(evaluatePullRequestWatch(unreadable.next, failed, noRemarks).changes, []); + }); + + it("wakes for the pull request's author when the agent is someone else", () => { + const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } }); + const reply = remark("contributor", "2026-10-02T12:06:00Z"); + assert.deepEqual(evaluatePullRequestWatch(watch(), contributor, [reply]).changes, [ + { kind: "remarks", remarks: [reply] }, + ]); + // Without a viewer, the author is taken to be the agent. + const noViewer = detail({ viewer: undefined, author: contributor.author }); + assert.deepEqual(evaluatePullRequestWatch(watch(), noViewer, [reply]).changes, []); + }); + + it("reports remarks from others once and never the agent's own", () => { + const comments = [ + remark("agent-user", "2026-10-02T12:05:00Z", "Fixed in the latest push."), + remark("macroscope-app[bot]", "2026-10-02T12:06:00Z"), + remark("reviewer", "2026-10-02T11:00:00Z", "Older than the watch."), + ]; + const report = evaluatePullRequestWatch(watch(), detail(), comments); + assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [comments[1]!] }]); + assert.equal(report.next.remarksThrough, "2026-10-02T12:06:00Z"); + assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []); + }); + + it("reports a conflict once, until the branch is clean again", () => { + const conflicting = detail({ mergeability: "conflicting" }); + const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks); + assert.deepEqual(first.changes, [{ kind: "conflicting" }]); + // GitHub answers "unknown" while it recomputes after a push; that is not a resolution. + const recomputing = evaluatePullRequestWatch( + first.next, + detail({ mergeability: "unknown" }), + noRemarks, + ); + assert.deepEqual( + evaluatePullRequestWatch(recomputing.next, conflicting, noRemarks).changes, + [], + ); + const clean = evaluatePullRequestWatch(first.next, detail(), noRemarks); + assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [ + { kind: "conflicting" }, + ]); + }); + + it("does not spend the comment wake limit on check results", () => { + const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); + const result = evaluatePullRequestWatch( + tired, + detail({ checks: [check("lint", "failure")] }), + noRemarks, + ); + assert.isFalse(result.exhausted); + assert.equal(result.next.wakes, 0); + }); + + it("stops after the wake limit unless the head moves", () => { + const comments = [remark("reviewer", "2026-10-02T12:10:00Z")]; + const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); + assert.isTrue(evaluatePullRequestWatch(tired, detail(), comments).exhausted); + const pushed = evaluatePullRequestWatch(tired, detail({ headSha: "cccccccccc" }), comments); + assert.isFalse(pushed.exhausted); + assert.equal(pushed.next.wakes, 1); + }); +}); + +describe("pullRequestWatchMessage", () => { + it("tells the agent what changed and marks failures for the timeline", () => { + const report = evaluatePullRequestWatch( + watch(), + detail({ checks: [check("lint", "failure")] }), + [remark("reviewer", "2026-10-02T12:10:00Z", "Needs a test.")], + ); + const message = pullRequestWatchMessage({ + number: 12, + url: "https://github.com/o/r/pull/12", + baseBranch: "main", + headSha: report.next.headSha, + report, + }); + assert.include(message.text, "- Checks failed on aaaaaaa:\n - lint https://ci.example/lint"); + assert.include(message.text, ' - reviewer on src/index.ts: "Needs a test."'); + assert.include(message.text, "unwatch_pull_request"); + assert.deepEqual(message.notification, { + source: { kind: "monitor" }, + outcome: "failed", + summary: "#12: checks failed, new comments", + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts new file mode 100644 index 000000000000..af9ce27636ee --- /dev/null +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -0,0 +1,209 @@ +import type { + OrchestrationV2Notification, + PullRequestCheck, + PullRequestComment, + PullRequestDetail, + ThreadPullRequestWatch, +} from "@t3tools/contracts"; + +/** + * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so + * this only stops a chatty bot looping an agent that is replying to it. + */ +export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; +const LISTED_ITEMS = 10; +const SNIPPET_LENGTH = 200; + +export type PullRequestWatchChange = + | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray } + | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } + | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } + | { readonly kind: "conflicting" }; + +export interface PullRequestWatchReport { + /** What the agent has not been told yet. Empty means no wake. */ + readonly changes: ReadonlyArray; + /** The watch to record, whether or not anything is reported. */ + readonly next: ThreadPullRequestWatch; + /** This report spends the last wake before the limit, so watching stops after it. */ + readonly exhausted: boolean; +} + +// "action-required" is a finished check that needs someone, so the agent hears about it. +const isFailedCheck = (check: PullRequestCheck) => + check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; + +/** + * Compares a watched pull request with what its agent was last told. Each check is reported as + * soon as it fails, so a check that never finishes (an advisory review bot) cannot hold the + * news back. "Passed" is reported once the checks the base branch requires all passed, or all + * checks where the host marks none required. Remarks count when someone other than the agent's + * own account wrote them, so its own replies never wake it. `remarks` is null when the + * conversation could not be read; remarks then wait for a later pass. + */ +export function evaluatePullRequestWatch( + watch: ThreadPullRequestWatch, + detail: Pick, + remarks: ReadonlyArray | null, +): PullRequestWatchReport { + const changes: Array = []; + const headSha = detail.headSha ?? null; + const headMoved = headSha !== watch.headSha; + + // An empty list keeps the last state: a host can answer with one when its check read fails. + let failedChecks = headMoved ? [] : watch.failedChecks; + let passed = headMoved ? false : watch.passed; + if (detail.checks.length > 0) { + const failed = detail.checks.filter(isFailedCheck); + const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); + if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); + // A check that runs again leaves the list, so a rerun that fails again is reported. + failedChecks = failed.map((check) => check.name); + + const required = detail.checks.filter((check) => check.required === true); + const gate = required.length > 0 ? required : detail.checks; + const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); + if (passedNow && !passed) { + changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 }); + } + passed = passedNow; + } + + const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); + const through = Date.parse(watch.remarksThrough); + // GitHub times are per second, so remarks at the boundary time are told apart by ID. + const fresh = (remarks ?? []).filter((remark) => { + const at = Date.parse(remark.createdAt); + return ( + (at > through || (at === through && !watch.remarkIds.includes(remark.id))) && + remark.author?.login.toLowerCase() !== own + ); + }); + if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh }); + const latest = Math.max(through, ...fresh.map((remark) => Date.parse(remark.createdAt))); + const atLatest = fresh.filter((remark) => Date.parse(remark.createdAt) === latest); + const remarksThrough = latest === through ? watch.remarksThrough : atLatest[0]!.createdAt; + const remarkIds = [ + ...(latest === through ? watch.remarkIds : []), + ...atLatest.map((remark) => remark.id), + ]; + + if (detail.mergeability === "conflicting" && !watch.conflicting) { + changes.push({ kind: "conflicting" }); + } + // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict. + const conflicting = + detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; + + const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); + const progress = headMoved || (changes.length > 0 && !commentsOnly); + const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); + return { + changes, + next: { + startedAt: watch.startedAt, + headSha, + failedChecks, + passed, + remarksThrough, + remarkIds, + conflicting, + wakes, + }, + exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, + }; +} + +function snippet(body: string): string { + const text = body + .replaceAll(//g, " ") + .replaceAll(/\s+/g, " ") + .trim(); + return text.length <= SNIPPET_LENGTH ? text : `${text.slice(0, SNIPPET_LENGTH - 3)}...`; +} + +function listed(items: ReadonlyArray, line: (item: T) => string): Array { + const lines = items.slice(0, LISTED_ITEMS).map(line); + if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`); + return lines; +} + +function changeLines( + change: PullRequestWatchChange, + context: { readonly baseBranch: string; readonly commit: string }, +): Array { + switch (change.kind) { + case "checks-failed": + return [ + `- Checks failed${context.commit}:`, + ...listed( + change.failed, + (check) => + ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`, + ), + ]; + case "checks-passed": + return [ + `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`, + ]; + case "remarks": + return [ + `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`, + ...listed(change.remarks, (remark) => { + const where = remark.path === null ? "" : ` on ${remark.path}`; + const body = snippet(remark.body); + const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`; + return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`; + }), + ]; + case "conflicting": + return [`- The branch now conflicts with ${context.baseBranch}.`]; + } +} + +const SUMMARY: Record = { + "checks-failed": "checks failed", + "checks-passed": "checks passed", + remarks: "new comments", + conflicting: "merge conflict", +}; + +/** The wake the agent reads and the timeline notification the user sees. */ +export function pullRequestWatchMessage(input: { + readonly number: number; + readonly url: string; + readonly baseBranch: string; + readonly headSha: string | null; + readonly report: PullRequestWatchReport; +}): { readonly text: string; readonly notification: OrchestrationV2Notification } { + const { changes, exhausted } = input.report; + const context = { + baseBranch: input.baseBranch, + commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`, + }; + const text = [ + `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`, + ...changes.flatMap((change) => changeLines(change, context)), + "", + exhausted + ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` + : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. Call unwatch_pull_request when you no longer need updates.", + ].join("\n"); + const failed = changes.some( + (change) => change.kind === "checks-failed" || change.kind === "conflicting", + ); + const summary = changes.map((change) => SUMMARY[change.kind]); + if (exhausted) summary.push("stopped watching"); + return { + text, + notification: { + source: { kind: "monitor" }, + outcome: failed + ? "failed" + : changes.every((change) => change.kind === "checks-passed") + ? "completed" + : "updated", + summary: `#${input.number}: ${summary.join(", ")}`, + }, + }; +} diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index d077f7e20910..2c5235c767d7 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -17,6 +17,7 @@ import { type ModelSelection, type OrchestrationV2Run, ProjectId, + type PullRequestDetail, ProviderDriverKind, ProviderInstanceId, ProviderThreadId, @@ -58,6 +59,9 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -212,6 +216,7 @@ const TestLayer = Layer.mergeAll( OrchestrationV2LayerLive, OrchestrationV2EventSinkLayerLive, ProjectStore.layer, + ProjectionStore.layer, EffectOutbox.layer, ThreadCommandExecutor.layer, ).pipe( @@ -2148,6 +2153,298 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("starts, records, and stops a pull request watch", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make("runtime-pull-request-watch"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-create"), + threadId, + projectId: ProjectId.make("pr-watch-project"), + title: "Watch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + const url = "https://github.com/pingdotgg/t3code/pull/7"; + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + + // Watching an unlinked pull request links it in the same command. + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-start"), + threadId, + ...key, + watching: true, + link: { url, source: "agent" }, + }); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.source, + "agent", + ); + const started = yield* watchOf; + assert.isDefined(started); + if (started === undefined) return; + + // A legacy client re-linking the same pull request keeps its watch. + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("pr-watch-legacy-relink"), + threadId, + linkedPullRequest: { projectId: ProjectId.make("pr-watch-project"), ...key, url }, + }); + assert.deepEqual(yield* watchOf, started); + + const recorded = { ...started, headSha: "abc123", failedChecks: ["lint"], wakes: 1 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-record"), + threadId, + ...key, + startedAt: started.startedAt, + watch: recorded, + }); + assert.deepEqual(yield* watchOf, recorded); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* watchOf, recorded); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-stop"), + threadId, + ...key, + watching: false, + }); + // A wake read before the stop must neither wake the agent nor bring the watch back. + const late = yield* orchestrator + .dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-late-record"), + threadId, + ...key, + startedAt: started.startedAt, + watch: { ...recorded, wakes: 2 }, + wake: { + messageId: MessageId.make("pr-watch-late-wake"), + text: "Update", + notification: { source: { kind: "monitor" }, outcome: "updated", summary: "#7" }, + }, + }) + .pipe(Effect.flip); + assert.equal(late._tag, "OrchestratorDispatchError"); + assert.isUndefined(yield* watchOf); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual(messages, []); + }), + ); + + it.effect("ends a watch it cannot read, and tells the agent", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-unreadable"); + const projectId = ProjectId.make("pr-watch-unreadable-project"); + yield* seedProject({ + projectId, + title: "Watch unreadable", + workspaceRoot: "/workspace/watch-unreadable", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-unreadable-create"), + threadId, + projectId, + title: "Watch unreadable", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-unreadable-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.die("host unreachable"), + activity: () => Effect.die("host unreachable"), + }), + ), + ), + ); + for (let pass = 0; pass < 15; pass += 1) yield* reactor.sweep; + + const thread = yield* orchestrator.getThreadShell(threadId); + assert.isUndefined(thread?.pullRequests?.[0]?.watch); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => message.notification?.summary ?? []), + ["#8: stopped watching, could not read it"], + ); + }), + ); + + it.effect("wakes a watched thread once for failed checks and a review comment", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-wake"); + const projectId = ProjectId.make("pr-watch-wake-project"); + yield* seedProject({ + projectId, + title: "Watch wake", + workspaceRoot: "/workspace/watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-wake-create"), + threadId, + projectId, + title: "Watch wake", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + const url = "https://github.com/pingdotgg/t3code/pull/7"; + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("pr-watch-wake-link"), + threadId, + ...key, + url, + source: "agent", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-wake-start"), + threadId, + ...key, + watching: true, + }); + + const at = "2026-10-02T12:00:00.000Z"; + const detail: PullRequestDetail = { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: false, + }, + projectId, + projectTitle: "Watch wake", + workspaceRoot: "/workspace/watch", + repository: key.repository, + number: key.number, + title: "Watched pull request", + body: "", + url, + author: { login: "agent-user", name: null, avatarUrl: null }, + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feature", + headSha: "abc1234def", + baseBranch: "main", + createdAt: at, + updatedAt: at, + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [{ name: "lint", status: "failure", description: null, url: null }], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewer: "agent-user", + }; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.succeed(detail), + activity: () => + Effect.succeed({ + comments: [ + { + id: "review-1", + kind: "review-comment", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "One more thing.", + createdAt: "2999-01-01T00:00:00.000Z", + url: null, + path: "src/index.ts", + reviewState: null, + }, + ], + commentCount: 1, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), + ), + ); + yield* reactor.sweep; + yield* reactor.sweep; + + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => + message.notification === undefined ? [] : [message.notification.summary], + ), + ["#7: checks failed, new comments"], + ); + const watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; + assert.deepEqual( + { headSha: watch?.headSha, failedChecks: watch?.failedChecks, wakes: watch?.wakes }, + { headSha: "abc1234def", failedChecks: ["lint"], wakes: 0 }, + ); + }), + ); + it.effect("persists rejected command receipts across retries", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 4cc834dddd62..b9cad107409a 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -153,6 +153,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray -When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. +When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. `; /** diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 020b571fe248..f40cff8f50bf 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -52,7 +52,7 @@ import { decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestCoreJson, - PULL_REQUEST_CORE_GRAPHQL_QUERY, + pullRequestCoreGraphQlQuery, type GitHubPullRequestCore, type GitHubPullRequestSummary, decodePullRequestPreviewJson, @@ -1595,7 +1595,7 @@ export const make = Effect.gen(function* () { ["-F", `number=${input.number}`], ["-f", `headRef=refs/pull/${input.number}/head`], ], - query: PULL_REQUEST_CORE_GRAPHQL_QUERY, + query: pullRequestCoreGraphQlQuery(input.host), decode: decodePullRequestCoreJson, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index ed4312c125c7..cc370637c601 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -213,6 +213,8 @@ export interface ProviderChangeRequestStat { } export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + /** The head commit, where the host's detail read reports it. */ + readonly headSha?: string | null; readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 44c175ab652f..293318f91c4b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1697,6 +1697,7 @@ export const make = Effect.gen(function* () { ...(changeRequest.headRepositoryNameWithOwner === undefined ? {} : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), + ...(changeRequest.headSha ? { headSha: changeRequest.headSha } : {}), baseBranch: changeRequest.baseBranch, createdAt: changeRequest.createdAt, updatedAt: changeRequest.updatedAt, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 12239a2af0ee..1393ab6e6b6a 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -26,6 +26,7 @@ import { decodeWorkflowRunApprovalsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, + pullRequestCoreGraphQlQuery, pullRequestSearchGraphQlQuery, } from "./gitHubPullRequestJson.ts"; @@ -285,6 +286,25 @@ describe("pull request detail decoding", () => { ]); }); + it("keeps what branch protection requires, and asks for it on github.com only", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, + { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, + { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, + ], + }), + ), + ); + expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); + expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); + expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); + }); + it("keeps a workflow waiting for approval out of the passing state", () => { const raw = JSON.parse(detailJson) as Record; const detail = expectSuccess( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 8e3a9ee330b8..d1600754b50b 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -93,6 +93,8 @@ const RawCheckSchema = Schema.Struct({ workflowName: Schema.optional(Schema.NullOr(Schema.String)), startedAt: Schema.optional(Schema.NullOr(Schema.String)), completedAt: Schema.optional(Schema.NullOr(Schema.String)), + /** Branch protection requires this check; read by the detail query on github.com only. */ + isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), }); const RawListItemSchema = Schema.Struct({ @@ -706,8 +708,15 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; -/** Pull refs let the comparison share the detail read without first resolving a fork branch. */ -export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { +/** + * Pull refs let the comparison share the detail read without first resolving a fork branch. + * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and + * an unknown field fails the whole read. + */ +export const pullRequestCoreGraphQlQuery = (host: string) => { + const required = + host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; + return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { repository(owner: $owner, name: $name) { mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission pullRequest(number: $number) { @@ -727,9 +736,9 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename - ... on StatusContext { context state targetUrl createdAt description } + ... on StatusContext { context state targetUrl createdAt description${required} } ... on CheckRun { - name status conclusion startedAt completedAt detailsUrl + name status conclusion startedAt completedAt detailsUrl${required} checkSuite { workflowRun { workflow { name } } } } } @@ -739,6 +748,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St } } }`; +}; export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { @@ -1468,6 +1478,7 @@ function toCheckEntries( status: toCheckStatus(check), description: trimmed(check.description), url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), }, workflowName: trimmed(check.workflowName), at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98039e56642a..9e9d5c5e3064 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import * as Random from "effect/Random"; import * as Semaphore from "effect/Semaphore"; import * as StorageCleanup from "./storageCleanup.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; @@ -521,6 +522,16 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( Layer.provide(PullRequestServiceLive), Layer.provide(ProjectionStoreV2.layer), ), + Layer.effectDiscard( + Effect.gen(function* () { + const service = yield* PullRequestWatchReactor.PullRequestWatchReactor; + yield* service.start(); + }), + ).pipe( + Layer.provide(PullRequestWatchReactor.layer), + Layer.provide(PullRequestServiceLive), + Layer.provide(ProjectionStoreV2.layer), + ), // Subscribes to `account.rate-limits.updated` so usage bars track live // telemetry instead of waiting for the next status probe. ProviderUsageLimitsIngestionLive, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5dec1b6d411d..b5113b5f41a3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3630,6 +3630,8 @@ function toolGroupSummaryIconName( case "link-pr": case "unlink-pr": case "list-prs": + case "watch-pr": + case "unwatch-pr": return "pull-request"; case "read": return "eye"; diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 3dd1bcbefaff..643c17d4b775 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -3,7 +3,14 @@ import { resolveThreadPullRequestChains, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; -import { ArrowUpRightIcon, LinkIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import { + ArrowUpRightIcon, + EyeIcon, + EyeOffIcon, + LinkIcon, + MoreHorizontalIcon, + PlusIcon, +} from "lucide-react"; import { useCallback, useMemo } from "react"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; @@ -68,14 +75,19 @@ function LinkRow({ line, threadRef, onUnlink, + onSetWatching, }: { line: PullRequestListLine; threadRef: ScopedThreadRef; onUnlink: (link: ThreadPullRequestLink) => void; + /** Null when the environment cannot watch pull requests. */ + onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null; }) { const openPrLink = useOpenPrLink(threadRef); const { link, depth, stack } = line; const snapshot = link.snapshot; + const open = snapshot === null || snapshot.state === "open"; + const watching = link.watch !== undefined; return (
- {snapshot.checksState ? : null} - {snapshot.reviewDecision ? ( + {watching ? ( + + }> + + + + Watching: the agent wakes when checks finish, someone comments, or the branch + conflicts + + + ) : null} + {snapshot?.checksState ? : null} + {snapshot?.reviewDecision ? ( ) : null} @@ -220,6 +243,12 @@ function LinkRow({ Open + {onSetWatching !== null && open ? ( + onSetWatching(link, !watching)}> + {watching ? : } + {watching ? "Stop watching" : "Watch for changes"} + + ) : null} onUnlink(link)}> {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} @@ -248,6 +277,10 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread const thread = useThreadShell(threadRef); const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); + const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true }); + const supportsWatch = + useServerConfigs().get(threadRef.environmentId)?.environment.capabilities + .threadPullRequestWatch === true; const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); const handleUnlink = useCallback( @@ -264,6 +297,21 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread }, [threadRef, unlink], ); + const handleSetWatching = useCallback( + (link: ThreadPullRequestLink, watching: boolean) => { + void watch({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + host: link.host, + repository: link.repository, + number: link.number, + watching, + }, + }); + }, + [threadRef, watch], + ); const openCount = useMemo( () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, [links], @@ -304,6 +352,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread line={line} threadRef={threadRef} onUnlink={handleUnlink} + onSetWatching={supportsWatch ? handleSetWatching : null} /> ))}
diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3d068a232be7..64beb946c408 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -181,6 +181,13 @@ closed reviews refresh periodically so reopening one on the host is detected. Me when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked review is terminal. An open or unsynced link keeps it active. +Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While +the thread is active, the server checks the pull request every minute and wakes the agent when a check +fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. +Comments from your own account do not wake it. Watching ends when the pull request merges or closes, +after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for +15 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. + Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ded1ff733e22..c0cb8e67504c 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -1027,6 +1027,20 @@ export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPu }); }, ); +export type WatchThreadPullRequestInput = Omit< + Extract, + "type" | "commandId" +> & + CommandMetadata; +export const watchThreadPullRequest = Effect.fn("EnvironmentCommands.watchThreadPullRequest")( + function* (input: WatchThreadPullRequestInput) { + return yield* dispatch({ + ...input, + type: "thread.pull-request.watch", + commandId: yield* allocateCommandId(input), + }); + }, +); export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")( function* (input: UnlinkThreadPullRequestInput) { return yield* dispatch({ diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 685ccc454a08..e3ad5c07f583 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -48,6 +48,7 @@ import { type UnarchiveThreadInput, type UnlinkThreadPullRequestInput, type UnpinThreadInput, + type WatchThreadPullRequestInput, type UnsettleThreadInput, type UnsnoozeThreadInput, type UpdateThreadMetadataInput, @@ -86,6 +87,7 @@ import { unsnoozeThread, updateThreadMetadata, visitThread, + watchThreadPullRequest, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; @@ -130,6 +132,7 @@ export type { UnsnoozeThreadInput, UpdateThreadMetadataInput, VisitThreadInput, + WatchThreadPullRequestInput, } from "../operations/commands.ts"; export function createThreadEnvironmentAtoms( @@ -251,6 +254,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + watchPullRequest: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:watch-pull-request", + execute: (input: WatchThreadPullRequestInput) => watchThreadPullRequest(input), + scheduler, + concurrency, + }), setRuntimeMode: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:set-runtime-mode", execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 79577892a697..c6b40e5a62ff 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -372,6 +372,16 @@ export function summarizeT3ToolCalls( case "unlink-pr": label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request")); break; + case "watch-pr": + label = phrase("Watching", "watch", quantity(selected.length, "pull request")); + break; + case "unwatch-pr": + label = phrase( + "Stopped watching", + "stop watching", + quantity(selected.length, "pull request"), + ); + break; case "list-prs": label = phrase( "Checked", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index fdaf28dcf3b7..84229d3bac7f 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -85,6 +85,8 @@ export type ToolGroupAction = | "link-pr" | "unlink-pr" | "list-prs" + | "watch-pr" + | "unwatch-pr" | "read" | "edit" | "command" @@ -156,7 +158,9 @@ function resolveT3McpToolPresentation( const actionKind = definition.summaryAction === "link-pr" || definition.summaryAction === "unlink-pr" || - definition.summaryAction === "list-prs" + definition.summaryAction === "list-prs" || + definition.summaryAction === "watch-pr" || + definition.summaryAction === "unwatch-pr" ? definition.summaryAction : undefined; const payload = asRecord(data); @@ -539,6 +543,10 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; case "unlink-pr": return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "watch-pr": + return `Watching ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "unwatch-pr": + return `Stopped watching ${count} ${count === 1 ? "pull request" : "pull requests"}`; case "list-prs": return count === 1 ? "Checked linked pull requests" diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a649cfe38de0..6752fa2a62ee 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -162,6 +162,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ shaping and validation when this is absent. */ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ + threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1820008b77dd..6004ab119917 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -46,6 +46,7 @@ import { ThreadPullRequestLinkSource, ThreadPullRequestSnapshot, ThreadPullRequestStack, + ThreadPullRequestWatch, } from "./threadPullRequest.ts"; import { ProviderApprovalDecision, @@ -2600,6 +2601,18 @@ export const OrchestrationV2Command = Schema.Union([ snapshot: ThreadPullRequestSnapshot, stack: Schema.NullOr(ThreadPullRequestStack), }), + /** Start or stop the server watching a linked pull request for this thread's agent. */ + Schema.Struct({ + type: Schema.Literal("thread.pull-request.watch"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + watching: Schema.Boolean, + /** Links the pull request first when starting a watch on one the thread has not linked. */ + link: Schema.optional( + Schema.Struct({ url: TrimmedNonEmptyString, source: ThreadPullRequestLinkSource }), + ), + }), Schema.Struct({ type: Schema.Literal("thread.pull-request.sync"), commandId: CommandId, @@ -2859,6 +2872,27 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + /** + * Records what a pull request watch saw, and wakes the agent in the same transaction when + * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is + * rejected on a settled or archived thread, so a read that raced either changes nothing. + */ + Schema.Struct({ + type: Schema.Literal("thread.pull-request-watch.sync"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + startedAt: IsoDateTime, + /** The watch to record, or null to end it. */ + watch: Schema.NullOr(ThreadPullRequestWatch), + wake: Schema.optional( + Schema.Struct({ + messageId: MessageId, + text: Schema.String, + notification: OrchestrationV2Notification, + }), + ), + }), /** Records that the provider rollback `requestId` failed for good. */ Schema.Struct({ type: Schema.Literal("checkpoint.rollback.fail"), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index cf8c00db0a88..c8feaad9a41f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -152,6 +152,8 @@ export const PullRequestCheck = Schema.Struct({ status: PullRequestCheckStatus, description: Schema.NullOr(Schema.String), url: Schema.NullOr(Schema.String), + /** The base branch requires this check to merge. Absent where the host does not say. */ + required: Schema.optional(Schema.Boolean), }); export type PullRequestCheck = typeof PullRequestCheck.Type; @@ -844,6 +846,8 @@ export const PullRequestDetail = Schema.Struct({ changedFiles: NonNegativeInt, headBranch: TrimmedNonEmptyString, headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** The head commit, where the host reports it with the detail. */ + headSha: Schema.optional(TrimmedNonEmptyString), baseBranch: TrimmedNonEmptyString, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 119503c2a85d..9df5cbe9670a 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -92,6 +92,30 @@ export const ThreadPullRequestKey = Schema.Struct({ }); export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; +/** + * Present while the server watches the pull request for its thread. The server wakes the + * thread's agent when checks finish on the head commit, someone else comments, or the branch + * starts to conflict. The other fields record what the agent was last told, so each change is + * reported once. + */ +export const ThreadPullRequestWatch = Schema.Struct({ + startedAt: IsoDateTime, + /** Head commit at the last pass; null where the host does not report one. */ + headSha: Schema.NullOr(TrimmedNonEmptyString), + /** Failed checks on that commit the agent was told about; a rerun that fails again is news. */ + failedChecks: Schema.Array(TrimmedNonEmptyString), + /** The agent was told the required checks on that commit passed. */ + passed: Schema.Boolean, + /** Remarks from others created up to this host time were reported. */ + remarksThrough: IsoDateTime, + /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */ + remarkIds: Schema.Array(TrimmedNonEmptyString), + conflicting: Schema.Boolean, + /** Comment-only wakes in a row. Watching stops at a limit, so bots cannot loop it. */ + wakes: NonNegativeInt, +}); +export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; + export const ThreadPullRequestLink = Schema.Struct({ ...ThreadPullRequestKey.fields, url: TrimmedNonEmptyString, @@ -99,5 +123,6 @@ export const ThreadPullRequestLink = Schema.Struct({ linkedAt: IsoDateTime, snapshot: Schema.NullOr(ThreadPullRequestSnapshot), stack: Schema.NullOr(ThreadPullRequestStack), + watch: Schema.optional(ThreadPullRequestWatch), }); export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index dd38709bd0e6..d85104cf4bc1 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -55,6 +55,8 @@ export type T3McpToolSummaryAction = | "link-pr" | "unlink-pr" | "list-prs" + | "watch-pr" + | "unwatch-pr" | "browser" | "device"; @@ -93,6 +95,16 @@ const T3_MCP_TOOLS: Readonly> = { "list-prs", "pull-request", ), + watch_pull_request: tool( + ["Watch", "Watching", "Watching", "a pull request"], + "watch-pr", + "pull-request", + ), + unwatch_pull_request: tool( + ["Stop watching", "Stopping watching", "Stopped watching", "a pull request"], + "unwatch-pr", + "pull-request", + ), orchestrator_capabilities: tool( ["Get", "Getting", "Got", "orchestration capabilities"], "capabilities", From 31a9da179ed0763335f05681c577474aec5d2309 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 03:41:49 -0700 Subject: [PATCH 34/82] fix(server): keep delegated review rounds on the task API (#15115) --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 ++ .../OrchestratorMcpToolkit.integration.test.ts | 8 ++++++++ .../src/mcp/toolkits/orchestrator/tools.test.ts | 11 +++++++++++ .../src/mcp/toolkits/orchestrator/tools.ts | 8 ++++---- .../provider/T3OrchestrationInstructions.test.ts | 5 +++++ .../src/provider/T3OrchestrationInstructions.ts | 3 ++- docs/orchestration-v2/orchestrator-mcp-server.md | 16 ++++++++++++++-- 7 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5fdc7eb81c80..9c7323ce546a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1510,6 +1510,8 @@ const make = Effect.gen(function* () { ), ), ); + // Published task results stay terminal. Later child-thread messages do not + // reopen the task, so cancelling it must not interrupt those separate runs. if (isTerminalTaskStatus(current.status)) { yield* disposeCompletionDelivery; return { diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 7d20bf512b10..142e0cc55f12 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1651,6 +1651,14 @@ describe("orchestrator MCP toolkit", () => { taskId: delegated.taskId, status: "completed", }); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === delegated.taskId, + ), + ).toMatchObject({ + result: delegatedResult, + completionDelivery: { state: "disposed" }, + }); expect( (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( (run) => run.id === activeChildFollowup.runId, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index de85b6f72e0c..572cdcbf43e9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -4,6 +4,7 @@ import { Tool } from "effect/unstable/ai"; import { CreateThreadsTool, DelegateTaskTool, + OrchestratorToolkit, ScheduleTaskTool, ThreadUpdateTool, } from "./tools.ts"; @@ -17,6 +18,16 @@ describe("orchestrator MCP tool guidance", () => { assert.include(DelegateTaskTool.description ?? "", "waitTimedOut"); assert.include(DelegateTaskTool.description ?? "", "does not cancel the child"); assert.include(DelegateTaskTool.description ?? "", "keep that taskId"); + assert.include(DelegateTaskTool.description ?? "", "call delegate_task again"); + assert.include(DelegateTaskTool.description ?? "", "childThreadId is backing storage"); + assert.include( + OrchestratorToolkit.tools.t3_thread_send.description ?? "", + "Do not use a delegated task's childThreadId to start another review round", + ); + assert.include( + OrchestratorToolkit.tools.task_cancel.description ?? "", + "without interrupting later child-thread runs", + ); }); it("documents wait timeout as a parent budget, not a child failure", () => { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 833e88bd2ae8..a86f1ce214c5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -57,7 +57,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -70,7 +70,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns; latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -84,7 +84,7 @@ const TaskStatusTool = Tool.make("task_status", { const TaskCancelTool = Tool.make("task_cancel", { description: - "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. Completed task results remain available.", + "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", parameters: OrchestratorMcpTaskCancelInput, success: OrchestratorMcpTaskCancelResult, failure: OrchestratorMcpFailure, @@ -200,7 +200,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/T3OrchestrationInstructions.test.ts b/apps/server/src/provider/T3OrchestrationInstructions.test.ts index dbcd209a136f..e625e40c1125 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.test.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.test.ts @@ -13,6 +13,11 @@ describe("T3 orchestration provider instructions", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "cross-provider"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "call `delegate_task` again"); + assert.include( + T3_CODE_ORCHESTRATION_INSTRUCTIONS, + "Do not use `t3_thread_send` on `childThreadId`", + ); }); it("documents structured schedules instead of JSON strings", () => { diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index ed510b879c0f..a70ef35bffcd 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -6,8 +6,9 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. +- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time. ### Choose the workspace before starting a new thread diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index ae68b1fc8539..eab33d76f5b0 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -228,6 +228,14 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails when unavailable. Selecting a different provider without a model uses that provider's first advertised model. +Each delegated review round uses a new `delegate_task` call with the original brief, +prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use +a distinct `clientRequestId` per round, stable across retries of that round. +`childThreadId` is backing storage, not a target for another review round through +`t3_thread_send`. Ordinary thread messaging remains available for user-requested +conversations; it does not reopen a completed task. There is no task-level follow-up +API for preserving the same reviewer session. + Delegation requires an active parent run owned by the MCP credential's provider session. The request becomes the V2 command `delegated_task.request`. @@ -272,8 +280,12 @@ the published task result. ### `task_cancel` Interrupts the currently active task run through the normal V2 `run.interrupt` -command. Native background work between turns currently has no interruptible run. It is idempotent for terminal tasks and accepts an optional cancellation -reason. Use `t3_thread_interrupt` to interrupt a later follow-up run. +command and disposes automatic parent delivery. Native background work between +turns currently has no interruptible run. For a terminal task, it returns the +existing status and disposes delivery without interrupting later child-thread runs, +even when `task_status` reports `hasPendingChildRuns: true`. Published task results +remain available. It accepts an optional cancellation reason. Use +`t3_thread_interrupt` to stop a later active run. ### `create_threads` From f391794a35c604d57e166a3ab48d56fc6e4e469a Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:46:57 +0530 Subject: [PATCH 35/82] fix(shared): classify workspace previews by literal filenames (#10311) Co-authored-by: yashranaway Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/server/src/assets/AssetAccess.test.ts | 43 ++++++++++++++++++++++ docs/user/composer.md | 3 ++ packages/shared/src/filePreview.test.ts | 25 +++++++++---- packages/shared/src/filePreview.ts | 4 +- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 71a2c769ef4e..a596ca55c32a 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -713,6 +713,49 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("previews workspace files with literal fragment characters in their paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-literal-" }); + const directory = path.join(root, "assets#archive"); + yield* fileSystem.makeDirectory(directory); + + for (const name of ["icon#v2.png", "report#draft.pdf"]) { + const filePath = path.join(directory, name); + yield* fileSystem.writeFileString(filePath, "preview fixture"); + const canonicalFile = yield* fileSystem.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + + expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile }); + if (name.endsWith(".png")) { + expect(yield* resolveAsset(token, "other.png")).toBeNull(); + } + } + + const disguisedPath = path.join(root, "image.png#notes.txt"); + yield* fileSystem.writeFileString(disguisedPath, "not an image"); + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: disguisedPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/docs/user/composer.md b/docs/user/composer.md index 59efc3c30f01..8cb8d7805ac9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML file to source view to read its markup; a link to a specific line opens source automatically. HTML previews cannot access your T3 Code session. +The file viewer recognizes images, HTML, and PDF files by their filename extension, +including filenames or folders containing `#` or `?`. + On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 34d619e7a782..602fb8299cdd 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -14,7 +14,7 @@ import { } from "./filePreview.ts"; describe("workspace file previews", () => { - it.each(["report.html", "report.HTM", "document.pdf?download=1"])( + it.each(["report.html", "report.HTM", "document#draft.pdf", "reports?old/document.pdf"])( "recognizes browser preview path %s", (path) => { expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); @@ -26,7 +26,9 @@ describe("workspace file previews", () => { "icon.png", "photo.JPEG", "animation.gif", - "vector.svg#mark", + "vector#mark.svg", + "photo?edited.JPEG", + "images#archive/icon.png", "texture.webp", "image.avif", ])("recognizes image preview path %s", (path) => { @@ -34,12 +36,19 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(path)).toBe(true); }); - it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( - "rejects non-preview path %s", - (path) => { - expect(isWorkspacePreviewEntryPath(path)).toBe(false); - }, - ); + it.each([ + "README.md", + "src/index.ts", + "image.png.ts", + "png", + "image.png#notes.txt", + "image.svg?notes.txt", + "document.pdf?download=1", + "report.html#notes.txt", + "image%2Epng", + ])("rejects non-preview path %s", (path) => { + expect(isWorkspacePreviewEntryPath(path)).toBe(false); + }); it("serves audio in place from the host like video and browser documents", () => { expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index da62d2e689ec..04e285eff224 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null { } function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { - const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; - return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); + const literalPath = path.toLowerCase(); + return extensions.some((extension) => literalPath.endsWith(extension)); } export function isWorkspaceBrowserPreviewPath(path: string): boolean { From 1945ce82c0d66d2b8ffbe867e688c0ecbf530e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9?= Date: Sat, 3 Oct 2026 16:34:13 +0200 Subject: [PATCH 36/82] fix(mobile): iOS threads no longer jump to the top (#14808) --- apps/mobile/package.json | 2 +- ...t-native-keyboard-controller@1.22.6.patch} | 155 ++++++++++-------- pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 4 +- 4 files changed, 96 insertions(+), 77 deletions(-) rename patches/{react-native-keyboard-controller@1.22.4.patch => react-native-keyboard-controller@1.22.6.patch} (88%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d1bd5c810894..b5f0d49d2bc1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -114,7 +114,7 @@ "react-native": "0.88.0-rc.3", "react-native-gesture-handler": "~3.2.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.22.4", + "react-native-keyboard-controller": "1.22.6", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", diff --git a/patches/react-native-keyboard-controller@1.22.4.patch b/patches/react-native-keyboard-controller@1.22.6.patch similarity index 88% rename from patches/react-native-keyboard-controller@1.22.4.patch rename to patches/react-native-keyboard-controller@1.22.6.patch index 01c1760bf33a..8b9170240d4e 100644 --- a/patches/react-native-keyboard-controller@1.22.4.patch +++ b/patches/react-native-keyboard-controller@1.22.6.patch @@ -1,8 +1,8 @@ diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index 5617394..4f2222a 100644 +index 0b78ff3..98b3b6c 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -27,9 +27,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -28,9 +28,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -15,17 +15,16 @@ index 5617394..4f2222a 100644 onEndVisible, ...rest }, ref) => { -@@ -51,13 +54,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ - freeze: freezeSV, +@@ -54,6 +57,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset, blankSpace, -- extraContentPadding -+ extraContentPadding, + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, + initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); (0, _useExtraContentPadding.useExtraContentPadding)({ - scrollViewRef, +@@ -61,6 +66,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -34,19 +33,19 @@ index 5617394..4f2222a 100644 scroll, layout, size, -@@ -88,10 +95,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -91,10 +98,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ // a bug for you, please open an issue. - const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + const totalPadding = (0, _reanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + // iOS applies the destination contentInset and contentOffset together at + // keyboard-animation start. Keep that native target behavior, but report + // the presentation height so virtualized-list layout follows the keyboard. -+ const reportedPadding = (0, _reactNativeReanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); ++ const reportedPadding = (0, _reanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); + + // Mirror the effective bottom padding (keyboard + composer + blank floor) + // to the consumer - a virtualized list needs it in its own scroll math or + // its end/maintain targets point at the under-the-keyboard resting offset. -+ (0, _reactNativeReanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { ++ (0, _reanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { + if (onContentInsetChange && current !== previous) { + (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ + bottom: current @@ -57,8 +56,8 @@ index 5617394..4f2222a 100644 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). // Apps that render into the unsafe area can supply a negative // scrollIndicatorInsets adjustment at the application layer. -- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); -+ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); +- const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value); const onLayout = (0, _react.useCallback)(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); @@ -89,7 +88,7 @@ index 4d33b3f..6a787d3 100644 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index cecee07..533a9e9 100644 +index 6481228..0dc5dd1 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { @@ -188,10 +187,10 @@ index cecee07..533a9e9 100644 padding, currentHeight, diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 54a1c57..00c2941 100644 +index 0215bef..5307446 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -31,6 +31,8 @@ function useExtraContentPadding(options) { +@@ -32,6 +32,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, @@ -200,7 +199,7 @@ index 54a1c57..00c2941 100644 scroll, layout, size, -@@ -50,8 +52,13 @@ function useExtraContentPadding(options) { +@@ -51,8 +53,13 @@ function useExtraContentPadding(options) { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -216,7 +215,7 @@ index 54a1c57..00c2941 100644 return; } (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false); -@@ -70,8 +77,8 @@ function useExtraContentPadding(options) { +@@ -71,8 +78,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -227,12 +226,15 @@ index 54a1c57..00c2941 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -90,10 +97,11 @@ function useExtraContentPadding(options) { +@@ -91,13 +98,11 @@ function useExtraContentPadding(options) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); scrollToTarget(target); } else { - const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. +- const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), 0); + const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); + const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); @@ -244,20 +246,23 @@ index 54a1c57..00c2941 100644 //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index d085394..a57c2f9 100644 +index e0c8391..8df802a 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js -@@ -1,7 +1,7 @@ +@@ -1,9 +1,9 @@ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } import React, { forwardRef, useCallback, useMemo } from "react"; -import { StyleSheet } from "react-native"; --import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; +-import { makeMutable, useAnimatedRef } from "react-native-reanimated"; +import { Platform, StyleSheet } from "react-native"; -+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; ++import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated"; import Reanimated from "react-native-reanimated"; +-import { useAnimatedStyle, useDerivedValue } from "../../reanimated"; ++import { useAnimatedReaction, useAnimatedStyle, useDerivedValue } from "../../reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -20,9 +20,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + import { useChatKeyboard } from "./useChatKeyboard"; +@@ -21,9 +21,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -270,17 +275,16 @@ index d085394..a57c2f9 100644 onEndVisible, ...rest }, ref) => { -@@ -44,13 +47,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ - freeze: freezeSV, +@@ -47,6 +50,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset, blankSpace, -- extraContentPadding -+ extraContentPadding, + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, + initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); useExtraContentPadding({ - scrollViewRef, +@@ -54,6 +59,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -289,7 +293,7 @@ index d085394..a57c2f9 100644 scroll, layout, size, -@@ -81,10 +88,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -84,10 +91,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ // a bug for you, please open an issue. const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); @@ -343,7 +347,7 @@ index 295e221..2f7b87e 100644 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 407275d..19427d7 100644 +index 043b973..df5a408 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { @@ -442,10 +446,10 @@ index 407275d..19427d7 100644 padding, currentHeight, diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 5885415..8b3c4f0 100644 +index 5a9b91d..a11bfdb 100644 --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -25,6 +25,8 @@ function useExtraContentPadding(options) { +@@ -26,6 +26,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, @@ -454,7 +458,7 @@ index 5885415..8b3c4f0 100644 scroll, layout, size, -@@ -44,8 +46,13 @@ function useExtraContentPadding(options) { +@@ -45,8 +47,13 @@ function useExtraContentPadding(options) { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -470,7 +474,7 @@ index 5885415..8b3c4f0 100644 return; } scrollTo(scrollViewRef, 0, target, false); -@@ -64,8 +71,8 @@ function useExtraContentPadding(options) { +@@ -65,8 +72,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -481,12 +485,15 @@ index 5885415..8b3c4f0 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -84,11 +91,12 @@ function useExtraContentPadding(options) { +@@ -85,14 +92,12 @@ function useExtraContentPadding(options) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); scrollToTarget(target); } else { - const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. +- const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), 0); + const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); + const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); @@ -524,10 +531,10 @@ index 2b51cc4..3acec1f 100644 -export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; +export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index aff9b5a..fdd8395 100644 +index 8d358bc..911f3d9 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -@@ -9,6 +9,10 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -554,21 +561,27 @@ index ec73f70..45b3874 100644 scroll: SharedValue; /** Visible viewport dimensions. */ diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 554b38c..725d546 100644 +index 935bc71..d411dc4 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx -@@ -1,7 +1,9 @@ +@@ -1,9 +1,13 @@ import React, { forwardRef, useCallback, useMemo } from "react"; -import { StyleSheet } from "react-native"; +-import { makeMutable, useAnimatedRef } from "react-native-reanimated"; +import { Platform, StyleSheet } from "react-native"; - import { - makeMutable, -+ runOnJS, ++import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated"; + import Reanimated from "react-native-reanimated"; + +-import { useAnimatedStyle, useDerivedValue } from "../../reanimated"; ++import { + useAnimatedReaction, - useAnimatedRef, - useAnimatedStyle, - useDerivedValue, -@@ -39,9 +41,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ++ useAnimatedStyle, ++ useDerivedValue, ++} from "../../reanimated"; + import useCombinedRef from "../hooks/useCombinedRef"; + import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; + +@@ -35,9 +39,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -581,16 +594,16 @@ index 554b38c..725d546 100644 onEndVisible, ...rest }, -@@ -68,6 +73,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -64,6 +71,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, + initialContentOffsetY: rest.contentOffset?.y, }); - useExtraContentPadding({ -@@ -75,6 +82,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -72,6 +81,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< extraContentPadding, keyboardPadding: padding, blankSpace, @@ -599,7 +612,7 @@ index 554b38c..725d546 100644 scroll, layout, size, -@@ -112,13 +121,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -109,13 +120,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ), ); @@ -697,7 +710,7 @@ index 37eacd1..3d77ab2 100644 + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 129a74b..168ce1a 100644 +index 1ae9b81..208505b 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -43,6 +43,8 @@ function useChatKeyboard( @@ -827,10 +840,10 @@ index 129a74b..168ce1a 100644 return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index 02abf5c..4ee0ddd 100644 +index f89b7c8..02e0e19 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { +@@ -13,6 +13,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -842,10 +855,10 @@ index 02abf5c..4ee0ddd 100644 type UseChatKeyboardReturn = { diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -index c9c93f3..02f95c0 100644 +index de7d6f7..631cdfb 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -@@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = { +@@ -17,6 +17,10 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; @@ -856,7 +869,7 @@ index c9c93f3..02f95c0 100644 /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ -@@ -51,6 +55,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -52,6 +56,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { extraContentPadding, keyboardPadding, blankSpace, @@ -865,7 +878,7 @@ index c9c93f3..02f95c0 100644 scroll, layout, size, -@@ -72,8 +78,13 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -73,8 +79,13 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -881,7 +894,7 @@ index c9c93f3..02f95c0 100644 return; } scrollTo(scrollViewRef, 0, target, false); -@@ -99,14 +110,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -100,14 +111,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { } // Compute effective delta considering blankSpace floor @@ -902,7 +915,7 @@ index c9c93f3..02f95c0 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { -@@ -139,16 +148,25 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -140,22 +149,28 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { scrollToTarget(target); } else { @@ -911,12 +924,18 @@ index c9c93f3..02f95c0 100644 size.value.height - layout.value.height + currentTotal, - 0, + minScroll, -+ ); -+ const target = Math.max( + ); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. ++ // Clamp at the top as well: when the content is shorter than the ++ // viewport a shrinking padding makes `effectiveDelta` negative while ++ // `scroll.value` is already at the top. + const target = Math.max( + minScroll, -+ Math.min(scroll.value + effectiveDelta, maxScroll), + Math.min(scroll.value + effectiveDelta, maxScroll), +- 0, ); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); scrollToTarget(target); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 092af1eaeeb7..f21be6151710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,7 +111,7 @@ patchedDependencies: expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9 - react-native-keyboard-controller@1.22.4: c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c + react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 @@ -448,8 +448,8 @@ importers: specifier: ^0.2.2 version: 0.2.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-keyboard-controller: - specifier: 1.22.4 - version: 1.22.4(patch_hash=c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + specifier: 1.22.6 + version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -9561,8 +9561,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.22.4: - resolution: {integrity: sha512-2by3oJBQqKH9UAydlBeiEpivxIyTbt345bmverLym7vbEp4NXckWQLF0STnVuXYccRak5/sd3wMYDJPhv/9+3g==} + react-native-keyboard-controller@1.22.6: + resolution: {integrity: sha512-gwJAI3Bhbqs/Ja3uaeBz7EFQSFcem8q4wsXkrC9sy1CL14gjWhBICLu2Ecn7HmezpIu33uhLbirLGyqSawxRtg==} peerDependencies: react: '*' react-native: '*' @@ -20049,7 +20049,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.22.4(patch_hash=c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-keyboard-controller@1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c0ef065701e..5bd65fcacbbb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -143,7 +143,7 @@ minimumReleaseAgeExclude: - "react@19.3.0" - "react-dom@19.3.0" - "react-native@0.88.0-rc.3" - - "react-native-keyboard-controller@1.22.4" + - "react-native-keyboard-controller@1.22.6" - "react-native-worklets@0.13.0" - "react-native-reanimated@4.7.0" - "react-native-screens@4.28.0" @@ -260,7 +260,7 @@ patchedDependencies: # Link http(s) URLs with a port or a single-label host. md4c otherwise requires a dotted host and stops at ':'. react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch - react-native-keyboard-controller@1.22.4: patches/react-native-keyboard-controller@1.22.4.patch + react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch From 44b49650170a76e6171642d6f8de946cf1d1c821 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:51:55 +0300 Subject: [PATCH 37/82] fix(web): reduce the gap above the draft composer (#15196) --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fe72ec446d34..92400fedd9b3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10812,7 +10812,7 @@ export default function ChatView(props: ChatViewProps) { {isDraftHeroState ? (
Date: Sat, 3 Oct 2026 10:54:10 -0400 Subject: [PATCH 38/82] fix(mobile): a dev server left running no longer shows the waiting bolt (#15194) Co-authored-by: Claude Opus 5.5 (1M context) --- .../mobile/src/features/threads/ThreadDetailScreen.tsx | 3 ++- .../src/features/threads/floating-working-control.tsx | 8 +++++--- .../src/features/threads/floating-working-status.ts | 10 ++++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 623ae734053d..8b9e061f39a2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -474,11 +474,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { - kind: "waiting", + kind: "background", label: pendingBackgroundWork.title, accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items .map((item) => item.label) .join(", ")}`, + waiting: pendingBackgroundWork.waiting, }; } return null; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 3538949c9d2d..95ea8ca5534a 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -385,16 +385,18 @@ function FloatingStatusLabel(props: { ); } - if (props.status.kind === "waiting") { + if (props.status.kind === "background") { return ( + {/* A dev server can run for hours after the agent is done, so only work + that will wake the agent gets the bolt. */} Date: Sat, 3 Oct 2026 10:54:44 -0400 Subject: [PATCH 39/82] fix(server): a Claude command you stop shows as interrupted (#14896) Co-authored-by: Claude Opus 5.5 (1M context) --- .../server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 6 ++++-- .../fixtures/claude_background_task_interrupt/output.ts | 4 ++-- .../fixtures/turn_interrupt_mid_tool/claude_output.ts | 2 +- .../fixtures/turn_interrupt_restart/claude_output.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1aa14ae6fdd6..d437ac2a2ef5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3673,7 +3673,7 @@ export function makeClaudeAdapterV2( readonly output: ClaudeNativeToolOutput; readonly status: Extract< OrchestrationV2TurnItem["status"], - "running" | "completed" | "failed" + "running" | "completed" | "failed" | "interrupted" | "cancelled" >; readonly startedAt: DateTime.Utc; readonly updatedAt: DateTime.Utc; @@ -4740,7 +4740,9 @@ export function makeClaudeAdapterV2( parentNodeId: toolCall.parentNodeId, ordinal: toolCall.ordinal, output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, - status: "failed", + // A stopped turn cuts its open tools short; only a turn that + // ended on its own leaves them failed. + status: input.status === "completed" ? "failed" : input.status, startedAt: toolCall.startedAt, updatedAt: input.completedAt, presentation: toolCall.presentation, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts index 120f1cfc435a..c58112b37202 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts @@ -44,7 +44,7 @@ export function assertClaudeBackgroundTaskInterruptOutput( projection.turnItems.flatMap((item) => item.type === "command_execution" ? [item.status] : [], ), - ["completed", "failed"], - "the background launch completed; the interrupted foreground command did not", + ["completed", "interrupted"], + "the background launch completed; the foreground command was interrupted", ); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts index 72884607068b..d90531499a80 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts @@ -81,7 +81,7 @@ export function assertTurnInterruptMidToolClaudeOutput( assert.isDefined(commandItem); assert.isDefined(interruptRequest); assert.isDefined(interruptResult); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); assert.equal(interruptRequest.status, "completed"); assert.equal(interruptResult.status, "interrupted"); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts index 1da224a8485b..f05b470d32d8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts @@ -111,7 +111,7 @@ export function assertTurnInterruptRestartClaudeOutput( assert.equal(projection.providerThreads[0]?.status, "idle"); const commandItem = projection.turnItems.find((item) => item.type === "command_execution"); assert.isDefined(commandItem); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); const outboundFrames = transcript.entries.flatMap((entry) => From 0080e80c00a3e333e3dcafd85b2615d56f84e3b8 Mon Sep 17 00:00:00 2001 From: Bob Fowler Date: Sat, 3 Oct 2026 11:45:10 -0400 Subject: [PATCH 40/82] fix(server): editors appear once a slow discovery scan finishes (#13917) --- .../src/process/externalLauncher.test.ts | 41 ++-- apps/server/src/process/externalLauncher.ts | 79 +++++--- apps/server/src/ws.test.ts | 188 +++++++++++++++++- apps/server/src/ws.ts | 110 ++++++++-- 4 files changed, 359 insertions(+), 59 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index aab4d78f4d71..9a5c8b99ce7d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = ); }); -// A client that disconnects mid-scan interrupts the shared discovery effect on -// the connection fiber. The cache must not retain that interrupt: doing so -// replayed it to every later connect for the whole TTL, so `server.getConfig` -// failed and no client could reconnect until the server restarted. -it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { +// Connects run discovery under a timeout and may disconnect mid-scan. Neither +// may cancel the scan: on a busy host every connect would time out partway +// through, cache nothing, and leave every client without editors. +it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; - let blockFirstScan = true; - let scans = 0; + const release = Deferred.makeUnsafe(); + let parkedStats = 0; const launcherLayer = ExternalLauncher.layer.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ - // The first scan parks inside `stat` so the interrupt lands while - // discovery is in flight, which is what a client disconnecting - // mid-connect does to the shared effect. + // Scans park inside `stat` until released, so the interrupt lands + // while discovery is in flight. stat: () => Effect.gen(function* () { - scans += 1; - if (blockFirstScan) { - return yield* Effect.never; + if (!Deferred.isDoneUnsafe(release)) { + parkedStats += 1; + yield* Deferred.await(release); } return fileInfo; }), @@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru return Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors()); yield* Effect.yieldNow; - yield* Fiber.interrupt(fiber); + yield* Fiber.interrupt(interrupted); - // The next connect must still get a real answer well inside the TTL. - blockFirstScan = false; - scans = 0; - const editors = yield* launcher.resolveAvailableEditors(); + // The next connect joins the running scan instead of starting its own. + const next = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + yield* Effect.yieldNow; + assert.equal(parkedStats, 1); + + yield* Deferred.succeed(release, undefined); + const editors = yield* Fiber.join(next); assert.equal(editors.includes("vscode"), true); - assert.isAbove(scans, 0); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index ab287e4d78ee..058318ea9d55 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -28,13 +28,16 @@ import { import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. // -// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the -// first caller's Exit whatever it is, including an interrupt. Callers run this -// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), -// so one client disconnecting mid-scan would cache the interrupt and replay it -// to every later connect for the whole TTL, breaking `server.getConfig` -// permanently. Storing only on success means an interrupted scan leaves the -// cache untouched and the next connect simply rescans. +// The scan runs on its own fiber in the service scope, and every caller awaits +// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`) +// and disconnect mid-connect; neither may cancel a scan other connects are +// waiting on, or throw away work a slow host (a busy server at startup, a +// long PATH) needs more than one connect to finish. A failed scan clears the +// entry so the next caller starts over rather than replaying the failure. // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock // adjustment cannot keep an expired entry alive. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { - readonly editors: ReadonlyArray; - readonly expiresAtNanos: bigint; + readonly scan: Deferred.Deferred>; + /** Undefined while the scan is still running. */ + readonly expiresAtNanos: bigint | undefined; } /** @@ -760,27 +763,57 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const scope = yield* Scope.Scope; const editorDiscoveryCache = yield* Ref.make>( Option.none(), ); - const cachedAvailableEditors = Effect.gen(function* () { - const nowNanos = yield* Clock.currentTimeNanos; - const entry = yield* Ref.get(editorDiscoveryCache); - if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { - return entry.value.editors; - } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + const runEditorDiscovery = (scan: Deferred.Deferred>) => + provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.onExit((exit) => + Effect.gen(function* () { + const expiresAtNanos = (yield* Clock.currentTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; + yield* Ref.update(editorDiscoveryCache, (current) => + Option.isNone(current) || current.value.scan !== scan + ? current + : Exit.isSuccess(exit) + ? Option.some({ scan, expiresAtNanos }) + : Option.none(), + ); + yield* Deferred.done(scan, exit); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), ); - yield* Ref.set( + // Claiming the cache entry and starting its scan must not be split by an + // interrupt, or the entry would wait on a scan that never runs. + const acquireEditorDiscovery = Effect.gen(function* () { + const nowNanos = yield* Clock.currentTimeNanos; + const [scan, isNewScan] = yield* Ref.modify( editorDiscoveryCache, - Option.some({ - editors, - expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, - }), + ( + current, + ): [ + [EditorDiscoveryCacheEntry["scan"], boolean], + Option.Option, + ] => { + if ( + Option.isSome(current) && + (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) + ) { + return [[current.value.scan, false], current]; + } + const scan = Deferred.makeUnsafe>(); + return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; + }, ); - return editors; - }); + if (isNewScan) { + yield* runEditorDiscovery(scan); + } + return scan; + }).pipe(Effect.uninterruptible); + const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index e280f0750cef..630d64c49b6c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,15 +1,29 @@ import { assert, it } from "@effect/vitest"; -import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { + ORCHESTRATION_PROTOCOL_VERSION, + type ServerConfig, + type ServerConfigStreamEvent, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as ConfigProvider from "effect/ConfigProvider"; 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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; import { hasCompatibleOrchestrationProtocol, resolveAvailableEditorsForConfig, shouldUseBoundedThreadSnapshot, + withLateEditorConfig, } from "./ws.ts"; it("accepts only the current orchestration protocol before websocket RPC setup", () => { @@ -48,3 +62,175 @@ it.effect("does not block server config when editor discovery never resolves", ( assert.deepEqual(availableEditors, []); }), ); + +// Only the fields the late-editor fold reads or rewrites. +const snapshotConfig = (fields: Partial) => + ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; + +const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: settings as ServerConfig["settings"] }, +}); + +it.effect("resends late editors without rolling back updates already sent", () => + Effect.gen(function* () { + const settingsSent = yield* Deferred.make(); + const events = yield* withLateEditorConfig( + snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }), + Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })), + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + // Holds the late snapshot until the settings change has gone out. + resolveFileManagerRevealKind: () => + Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)), + }, + ).pipe( + Stream.tap((event) => + event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void, + ), + Stream.runCollect, + ); + + const [first, second] = Array.from(events); + assert.equal(events.length, 2); + assert.equal(first?.type, "settingsUpdated"); + assert.equal(second?.type, "snapshot"); + if (second?.type === "snapshot") { + assert.deepEqual(second.config.availableEditors, ["file-manager"]); + assert.equal(second.config.shellRevealInFileManagerKind, "file-explorer"); + assert.deepEqual(second.config.settings, { enableProviderUpdateChecks: false } as never); + } + }), +); + +it.effect("sends no late snapshot when the scan matches the snapshot", () => + Effect.gen(function* () { + const events = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["vscode"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["vscode"]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }, + ).pipe(Stream.runCollect); + + assert.equal(events.length, 0); + }), +); + +it.effect("resends a file manager reveal kind that missed the snapshot", () => + Effect.gen(function* () { + const events = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["file-manager"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + ).pipe(Stream.runCollect); + + const [late] = Array.from(events); + assert.equal(events.length, 1); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); + } + }), +); + +// The real launcher on Windows over a filesystem whose probes park until +// released, like a host too busy to finish discovery inside the snapshot timeout. +const makeParkedWindowsLauncher = Effect.gen(function* () { + const parkedProbes = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + const launcher = yield* ExternalLauncher.make.pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Queue.offer(parkedProbes, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ type: "File" } as FileSystem.File.Info), + ), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected spawn")), + ), + ), + ), + ); + const onWindows = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { PATH: "C:\\t3-late-editors-test", PATHEXT: ".EXE" }, + }), + ), + ), + ); + return { + editors: { + resolveAvailableEditors: () => onWindows(launcher.resolveAvailableEditors()), + resolveFileManagerRevealKind: () => onWindows(launcher.resolveFileManagerRevealKind()), + }, + probeParked: Queue.take(parkedProbes), + releaseProbes: Deferred.succeed(release, undefined), + }; +}); + +it.effect("recovers editors after a real scan outlasts the config timeout", () => + Effect.gen(function* () { + const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; + + const snapshotFiber = yield* resolveAvailableEditorsForConfig( + editors.resolveAvailableEditors(), + ).pipe(Effect.forkChild); + yield* probeParked; + yield* TestClock.adjust(Duration.seconds(5)); + const snapshotEditors = yield* Fiber.join(snapshotFiber); + assert.deepEqual(snapshotEditors, []); + + const lateFiber = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: snapshotEditors }), + Stream.empty, + editors, + ).pipe(Stream.runCollect, Effect.forkChild); + yield* releaseProbes; + + const [late] = Array.from(yield* Fiber.join(lateFiber)); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.availableEditors.includes("vscode"), true); + } + }).pipe(Effect.scoped), +); + +it.effect("recovers a reveal kind whose real probe outlasts the config timeout", () => + Effect.gen(function* () { + const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; + + // The snapshot's bounded probe timed out: file manager, but no reveal kind. + const lateFiber = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["file-manager"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: editors.resolveFileManagerRevealKind, + }, + ).pipe(Stream.runCollect, Effect.forkChild); + yield* probeParked; + yield* TestClock.adjust(Duration.seconds(6)); + yield* releaseProbes; + + const [late] = Array.from(yield* Fiber.join(lateFiber)); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); + } + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a0006ddb24b..783377666f14 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -76,6 +76,8 @@ import { type RelayClientInstallProgressEvent, type ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerConfig as ClientServerConfig, + type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, @@ -261,6 +263,94 @@ const resolveFileManagerRevealKindForConfig = ( discovery: Effect.Effect, ) => resolveDiscoveryForConfig(discovery, () => undefined); +type EditorDiscovery = Pick< + ExternalLauncher.ExternalLauncher["Service"], + "resolveAvailableEditors" | "resolveFileManagerRevealKind" +>; + +// The config fields that follow from which editors are installed. +const resolveEditorConfig = ( + availableEditors: ReadonlyArray, + revealKind: Effect.Effect, +) => + Effect.gen(function* () { + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* revealKind + : undefined; + return { + availableEditors, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + }; + }); + +/** + * Live config updates that follow a snapshot of `config`. A busy host can + * outlast the snapshot's discovery timeouts, which send no editors, or no + * reveal kind for the file manager. The scan keeps running, so once it lands + * this resends the config: clients replace theirs on any snapshot. The resent + * config is folded from the live updates already sent, so it cannot roll back + * a change that landed while the scan ran. + */ +export const withLateEditorConfig = ( + config: ClientServerConfig, + liveUpdates: Stream.Stream, + launcher: EditorDiscovery, +) => { + const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( + Stream.filter( + (editors) => + editors.join() !== config.availableEditors.join() || + (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), + ), + // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so + // a timeout here would cancel a probe that outlasts it every time. + Stream.mapEffect((editors) => + resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), + ), + Stream.filter( + (editorConfig) => + editorConfig.availableEditors.join() !== config.availableEditors.join() || + editorConfig.shellRevealInFileManagerKind !== config.shellRevealInFileManagerKind, + ), + Stream.map((editorConfig) => ({ type: "editorsResolved" as const, editorConfig })), + ); + + return Stream.merge(liveUpdates, lateEditorConfig).pipe( + Stream.mapAccum( + (): ClientServerConfig => config, + (current, event): readonly [ClientServerConfig, ReadonlyArray] => { + switch (event.type) { + case "editorsResolved": { + const { + availableEditors: _editors, + shellRevealInFileManager: _reveal, + shellRevealInFileManagerKind: _revealKind, + ...rest + } = current; + const next = { ...rest, ...event.editorConfig }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } + case "keybindingsUpdated": + return [{ ...current, ...event.payload }, [event]]; + case "providerStatuses": + return [{ ...current, providers: event.payload.providers }, [event]]; + case "settingsUpdated": + return [{ ...current, settings: event.payload.settings }, [event]]; + // Themes and usage-limit sources never ride in a snapshot; clients + // carry their projected values across one. + default: + return [current, [event]]; + } + }, + ), + ); +}; + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1620,14 +1710,10 @@ const makeWsRpcLayer = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), + const editorConfig = yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), + resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; return { environment, @@ -1637,7 +1723,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors, + ...editorConfig, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -1659,12 +1745,6 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, ...Option.match(scratchWorkspaceRoot, { @@ -3632,7 +3712,7 @@ const makeWsRpcLayer = ( return Stream.concat( rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), - liveUpdates, + withLateEditorConfig(config, liveUpdates, externalLauncher), ); }), { "rpc.aggregate": "server" }, From aad732901e4b7d485574eaef5a9c1fb388c4291a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 08:55:10 -0700 Subject: [PATCH 41/82] fix(server): Claude threads no longer stay stuck in plan mode Claude entered itself (#15224) --- .../Adapters/ClaudeAdapterV2.test.ts | 36 ++++++ .../Adapters/ClaudeAdapterV2.testkit.ts | 13 +++ .../Adapters/ClaudeAdapterV2.ts | 107 ++++++++++++++---- 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e5839c824219..b837575c1faf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -947,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1097,6 +1098,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1187,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1406,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1544,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1632,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1803,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2058,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Deferred.await(processed); }); const offeredMessages: Array = []; + const permissionModeChanges: Array = []; const continuationRequests: Array = []; const terminalReceipts = yield* Queue.unbounded>(); @@ -2106,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: (mode) => + Effect.sync(() => { + permissionModeChanges.push(mode); + }), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; @@ -2158,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { sdkMessages, offerAndWait, offeredMessages, + permissionModeChanges, continuationRequests, events, terminalReceipts, @@ -2985,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + // Claude entered plan mode on its own (EnterPlanMode). + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -3095,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); + // The second prompt reuses the live process, which is still in the + // plan mode Claude entered, so it is put back in the thread's mode. + assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -3615,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -3864,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(queue), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -6655,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -6837,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7071,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7257,6 +7290,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7433,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7564,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ca133173a9eb..39b0d44ba9ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame { readonly model: string; } +interface ClaudeQuerySetPermissionModeFrame { + readonly type: "query.set_permission_mode"; + readonly mode: string; +} + interface ClaudeQueryInterruptFrame { readonly type: "query.interrupt"; } @@ -239,6 +244,7 @@ type ClaudeOutboundFrame = | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame + | ClaudeQuerySetPermissionModeFrame | ClaudeQueryInterruptFrame | ClaudePermissionResponseFrame | ClaudeSessionForkFrame @@ -811,6 +817,13 @@ function makeReplayQueryRunner( model, }); }), + setPermissionMode: (mode) => + replayEffect(() => { + assertNextOutboundFrame({ + type: "query.set_permission_mode", + mode, + }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d437ac2a2ef5..0aca36abe77c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -327,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession { readonly messages: Stream.Stream; readonly offer: (message: SDKUserMessage) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly setPermissionMode: ( + mode: PermissionMode, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -466,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly model: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.set_permission_mode"; + readonly mode: PermissionMode; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -670,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< }), ), ), + setPermissionMode: (mode) => + Effect.tryPromise({ + try: () => queryRuntime.setPermissionMode(mode), + catch: (cause) => queryRunnerError(cause, "setPermissionMode"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_permission_mode", + mode, + }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -2688,6 +2715,11 @@ interface ClaudeLiveQueryContext { // uuid before any echo, so it echoes, but a resume's own turns can still // run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; + // The mode this process was opened in, and the mode the CLI last reported + // (init and status frames). Claude changes the latter itself through + // EnterPlanMode. + readonly openedPermissionMode: PermissionMode; + permissionMode: PermissionMode; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; // Registry entries still running when this process opened. Their process @@ -6843,10 +6875,23 @@ export function makeClaudeAdapterV2( if ( existing !== null && existing.nativeThreadId === nativeThreadId && - (isClaudeProviderContinuationTurn(turnInput) || - (existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity)) + isClaudeProviderContinuationTurn(turnInput) + ) { + return existing; + } + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + existing.selectionKey === compiledSelection.queryIdentity ) { + // Claude can switch its own mode mid-session (EnterPlanMode), and + // a denied ExitPlanMode leaves it there. Put the live process back + // in the thread's mode before the next prompt. + if (existing.permissionMode !== existing.openedPermissionMode) { + yield* existing.query.setPermissionMode(existing.openedPermissionMode); + existing.permissionMode = existing.openedPermissionMode; + } return existing; } @@ -6889,31 +6934,32 @@ export function makeClaudeAdapterV2( const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...mcpOverrides, + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId, - options: makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }), + options: queryOptions, }) .pipe( Effect.tapError(() => @@ -6960,6 +7006,8 @@ export function makeClaudeAdapterV2( selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", + openedPermissionMode: queryOptions.permissionMode, + permissionMode: queryOptions.permissionMode, stopping: false, subagentsFromEarlierProcesses: new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( @@ -6969,7 +7017,16 @@ export function makeClaudeAdapterV2( }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })), + Stream.runForEach((message) => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { From 65731f986ba2175063b0182deb6a143f8f323b74 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 3 Oct 2026 12:45:58 -0400 Subject: [PATCH 42/82] fix(mobile): show complete subagent details (#15189) --- .../src/features/threads/SubagentRow.tsx | 154 ++++++++++++++++++ .../features/threads/SubagentStatusDot.tsx | 10 +- .../features/threads/ThreadAgentsSheet.tsx | 47 +++--- .../threads/thread-subagent-group.tsx | 76 ++------- .../threads/threadAgentsPresentation.test.ts | 10 ++ .../threads/threadAgentsPresentation.ts | 14 +- .../chat/SubagentTooltipContent.tsx | 50 +----- .../src/state/subagentDisplay.test.ts | 130 ++++++++++++++- .../src/state/subagentDisplay.ts | 88 +++++++++- 9 files changed, 433 insertions(+), 146 deletions(-) create mode 100644 apps/mobile/src/features/threads/SubagentRow.tsx diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx new file mode 100644 index 000000000000..bef52c4b7c75 --- /dev/null +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -0,0 +1,154 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; +import { subagentCardDetail } from "./subagent-card-presentation"; +import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; +import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; + +type SubagentRowSubagent = Pick< + OrchestrationV2Subagent, + | "threadId" + | "childThreadId" + | "model" + | "driver" + | "providerInstanceId" + | "title" + | "prompt" + | "status" + | "progress" + | "result" +>; + +/** + * One agent as shown in the Agents sheet and transcript cards: status, model, + * workspace changes, and recent progress or result. Callers own the press + * target and pass their own elapsed timer so ticking stays isolated. + */ +export function SubagentRow(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; + readonly elapsed: ReactNode; +}) { + const presentation = resolveSubagentRowPresentation(props.subagent); + const detail = subagentCardDetail(presentation.detail); + return ( + + + + + + + + + {presentation.title} + + + · + + + {presentation.statusLabel} + + + {props.elapsed} + {presentation.canOpenThread ? ( + + ) : null} + + + {detail ? ( + + {detail} + + ) : null} + + + ); +} + +/** Uses shell data already held by the client, without loading child transcripts. */ +function SubagentMetadata(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; +}) { + const { environmentId, subagent } = props; + const config = useEnvironmentServerConfig(environmentId); + const provider = config?.providers.find( + (candidate) => candidate.instanceId === subagent.providerInstanceId, + ); + const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; + const child = useThreadShell( + subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), + )?.source; + const parentProject = useProject( + parent ? scopeProjectRef(environmentId, parent.projectId) : null, + ); + const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null); + const { modelLabel, workspace } = resolveSubagentMetadata({ + model: subagent.model, + provider, + parentThread: parent, + childThread: child, + parentProject, + childProject, + }); + return ( + + + + {provider?.displayName ? `${provider.displayName} · ` : ""} + {modelLabel} + + {workspace.map(({ label, value }) => ( + // The row reads this label in place of the icon. collapsable keeps the + // view (and label) from being flattened away without making it a + // separate accessibility stop. + + · + + + {value} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/SubagentStatusDot.tsx b/apps/mobile/src/features/threads/SubagentStatusDot.tsx index a08355effb50..348f6e534b6c 100644 --- a/apps/mobile/src/features/threads/SubagentStatusDot.tsx +++ b/apps/mobile/src/features/threads/SubagentStatusDot.tsx @@ -10,19 +10,25 @@ const TONE_CLASS = { stopped: "bg-foreground-muted", } as const satisfies Record; +export const SUBAGENT_TONE_TEXT_CLASS = { + working: "text-adaptive-sky-600-400", + completed: "text-adaptive-emerald-600-400", + failed: "text-adaptive-rose-600-400", + stopped: "text-foreground-muted", +} as const satisfies Record; + export function SubagentStatusDot({ tone, placement = "inline", }: { readonly tone: SubagentRowTone; - readonly placement?: "inline" | "provider" | "sheet"; + readonly placement?: "inline" | "sheet"; }) { return ( diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 7d7c825aaa08..1d082515172d 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { environmentThreadDetails } from "../../state/threads"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; - -import { SubagentStatusDot } from "./SubagentStatusDot"; +import { SubagentRow } from "./SubagentRow"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { @@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { } function AgentRow(props: { + readonly environmentId: EnvironmentId; readonly subagent: OrchestrationV2Subagent; readonly tickSeconds: boolean; readonly onOpen: (childThreadId: ThreadId) => void; }) { const { subagent } = props; - const presentation = resolveSubagentRowPresentation(subagent); const childThreadId = subagent.childThreadId; - const elapsed = useSubagentElapsed(subagent, props.tickSeconds); const row = ( - - - - - {presentation.title} - - - {presentation.detail ?? presentation.statusLabel} - - - {elapsed === null ? null : ( - {elapsed} - )} - {presentation.canOpenThread ? ( - - ) : null} + + } + /> ); @@ -154,7 +141,6 @@ function AgentRow(props: { return ( {row} @@ -165,7 +151,6 @@ function AgentRow(props: { return ( props.onOpen(childThreadId)} className="active:opacity-70" @@ -175,9 +160,19 @@ function AgentRow(props: { ); } +function AgentElapsed(props: { + readonly subagent: OrchestrationV2Subagent; + readonly tickSeconds: boolean; +}) { + const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds); + return elapsed === null ? null : ( + {elapsed} + ); +} + /** - * Elapsed time for one agent. Only a roster with live work subscribes to the - * shared second tick, so a settled sheet never repaints. + * Elapsed time for one agent. Only live work ticks, inside AgentElapsed, + * so the timer never repaints the metadata or a settled sheet. */ function useSubagentElapsed( subagent: Pick, diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 0c51c6195b75..c0f1adf4736e 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -2,10 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useIsFocused, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; -import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, OrchestrationV2Subagent, @@ -21,9 +18,8 @@ import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; import { environmentThreadDetails } from "../../state/threads"; -import { SubagentStatusDot } from "./SubagentStatusDot"; -import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; +import { subagentCardElapsed } from "./subagent-card-presentation"; +import { SubagentRow } from "./SubagentRow"; import { WorkLogBlock } from "./work-log-layout"; type SubagentItem = Extract; @@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray{elapsed} + {elapsed} ) : null; } function SubagentAvatar(props: { readonly item: SubagentItem; readonly iconUrl?: string | null | undefined; - readonly status?: OrchestrationV2Subagent["status"]; }) { return ( - {props.status ? ( - - ) : null} ); } @@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: { completedAt: live?.completedAt ?? item.completedAt, result: live?.result ?? item.result, progress: live?.progress ?? item.progress, + model: live?.model ?? null, }; }); const grouped = agents.length > 1; @@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: { {!grouped || expanded ? ( {agents.map((agent) => { - const presentation = resolveSubagentRowPresentation(agent); - const detail = subagentCardDetail( - isTerminalSubagentStatus(agent.status) - ? agent.result?.trim() || agent.progress || null - : agent.progress?.trim() || agent.result, - ); const threadId = agent.childThreadId; return ( - } /> - - - - {presentation.title} - - {detail && agent.status !== "completed" ? ( - - {presentation.statusLabel} - - ) : null} - - - {detail ?? presentation.statusLabel} - - - - {threadId !== null ? ( - - ) : null} ); })} diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts index bf095f541873..65c12910cea7 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts @@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => { expect(row.detail).toBeNull(); expect(row.statusLabel).toBe("Working"); }); + + it("bounds long result previews without dropping the agent's status", () => { + const row = resolveSubagentRowPresentation({ + ...base, + status: "failed", + result: "x".repeat(400), + }); + expect(row.detail).toBe(`${"x".repeat(280)}…`); + expect(row.statusLabel).toBe("Failed"); + }); }); diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index d1b85b896470..9605891143b4 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -1,8 +1,8 @@ -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; + formatSubagentDisplayTitle, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { OrchestrationV2Subagent } from "@t3tools/contracts"; const PROMPT_TITLE_LIMIT = 80; @@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation( >, ): SubagentRowPresentation { const live = isActiveSubagentStatus(subagent.status); - const progress = subagent.progress?.trim() ?? ""; - const result = subagent.result?.trim() ?? ""; - const settled = isTerminalSubagentStatus(subagent.status); - const detail = settled ? result || progress : progress || result; return { title: rowTitle(subagent), - detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null, + detail: subagentDetailPreview(subagent), statusLabel: rowStatusLabel(subagent.status), tone: rowTone(subagent.status), live, diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 49c382ba639f..d638736759a4 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -1,12 +1,14 @@ import type { OrchestrationV2ThreadShell, + OrchestrationV2TurnItemStatus, OrchestrationProjectShell, ServerProvider, ProviderDriverKind, } from "@t3tools/contracts"; -import { fileBasename } from "@t3tools/client-runtime/markdown-links"; -import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; -import { getTriggerDisplayModelName } from "./providerIconUtils"; +import { + resolveSubagentMetadata, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; import type { ReactNode } from "react"; import { BotIcon, @@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: { childThread?: Pick | undefined; parentProject?: Pick | undefined; childProject?: Pick | undefined; - status: string; + status: OrchestrationV2TurnItemStatus; result?: string | null | undefined; progress?: string | null | undefined; }) { - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); - const modelLabel = providerModel - ? getTriggerDisplayModelName(providerModel) - : model - ? formatModelSlugName(model) - : "Not reported"; - const currentWorkspace = props.parentThread?.worktreePath ?? props.parentProject?.workspaceRoot; - const childWorkspace = props.childThread?.worktreePath ?? props.childProject?.workspaceRoot; - const metadata = [ - ...(props.parentThread && - props.childProject && - props.childProject.id !== props.parentThread.projectId - ? [{ label: "Project", value: props.childProject.title }] - : []), - ...(currentWorkspace && childWorkspace && currentWorkspace !== childWorkspace - ? [ - { - label: props.childThread?.branch - ? "Branch" - : props.childThread?.worktreePath - ? "Worktree" - : "Workspace", - value: props.childThread?.branch ?? fileBasename(childWorkspace), - }, - ] - : []), - ]; - const settled = ["completed", "failed", "cancelled", "interrupted"].includes(props.status); - const result = props.result?.trim(); - const progress = props.progress?.trim(); - const detail = (settled ? result || progress : progress || result) || ""; - const compactDetail = detail.trim().replace(/\s+/g, " "); - const preview = - compactDetail.length > 280 ? `${compactDetail.slice(0, 280).trimEnd()}…` : compactDetail; + const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); + const preview = subagentDetailPreview(props); const driver = props.provider?.driver ?? props.driver; const working = ["running", "in_progress", "pending", "waiting"].includes(props.status); const failed = ["failed", "error"].includes(props.status); diff --git a/packages/client-runtime/src/state/subagentDisplay.test.ts b/packages/client-runtime/src/state/subagentDisplay.test.ts index 11184d369469..3485cd4f2da2 100644 --- a/packages/client-runtime/src/state/subagentDisplay.test.ts +++ b/packages/client-runtime/src/state/subagentDisplay.test.ts @@ -1,6 +1,11 @@ +import { ProjectId, ProviderDriverKind } from "@t3tools/contracts"; import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { subagentGroupSummary } from "./subagentDisplay.js"; +import { + subagentGroupSummary, + resolveSubagentMetadata, + subagentDetailPreview, +} from "./subagentDisplay.js"; describe("subagentGroupSummary", () => { it.each(["pending", "running", "waiting"] as const)( @@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => { }); }); }); + +describe("resolveSubagentMetadata", () => { + it("resolves provider aliases to catalog names, including custom models", () => { + expect( + resolveSubagentMetadata({ + model: "claude-haiku-4-5-20251001", + provider: { + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + shortName: "Haiku 4.5", + aliases: ["claude-haiku-4-5-20251001"], + isCustom: false, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("Haiku 4.5"); + expect( + resolveSubagentMetadata({ + model: "my-model", + provider: { + driver: ProviderDriverKind.make("acpRegistry"), + models: [ + { + slug: "my-model", + name: "Cloud+ / My custom model", + subProvider: "Cloud+", + isCustom: true, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("My custom model"); + }); + + it("keeps unknown model identities and does not invent an unreported model", () => { + expect(resolveSubagentMetadata({ model: " custom/model " }).modelLabel).toBe("custom/model"); + expect( + resolveSubagentMetadata({ + model: null, + provider: { driver: ProviderDriverKind.make("codex"), models: [] }, + }).modelLabel, + ).toBe("Not reported"); + expect(resolveSubagentMetadata({ model: " " }).modelLabel).toBe("Not reported"); + }); + + const parentThread = { projectId: ProjectId.make("parent"), worktreePath: null }; + const parentProject = { workspaceRoot: "/repo" }; + + it("shows another project and its branch when the child has a different workspace", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "fix/agents", worktreePath: "/worktrees/agents" }, + childProject: { + id: ProjectId.make("child"), + title: "Other project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([ + { label: "Project", value: "Other project" }, + { label: "Branch", value: "fix/agents" }, + ]); + }); + + it("labels a detached worktree or another project workspace without a branch", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: null, worktreePath: "/worktrees/agents" }, + }).workspace, + ).toEqual([{ label: "Worktree", value: "agents" }]); + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childProject: { + id: parentThread.projectId, + title: "Same project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([{ label: "Workspace", value: "other" }]); + }); + + it("hides redundant workspace metadata and tolerates unavailable child shells", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "main", worktreePath: "/repo" }, + childProject: { id: parentThread.projectId, title: "Same project", workspaceRoot: "/repo" }, + }).workspace, + ).toEqual([]); + expect(resolveSubagentMetadata({ model: null, parentThread, parentProject }).workspace).toEqual( + [], + ); + }); +}); + +describe("subagentDetailPreview", () => { + it("prefers progress for live work and results for settled work", () => { + const details = { progress: "Reading files", result: "Found two\n problems" }; + expect(subagentDetailPreview({ ...details, status: "running" })).toBe("Reading files"); + expect(subagentDetailPreview({ ...details, status: "completed" })).toBe("Found two problems"); + expect( + subagentDetailPreview({ status: "failed", progress: "Last progress", result: " " }), + ).toBe("Last progress"); + expect(subagentDetailPreview({ status: "pending" })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 4d1d2c5cd15d..0a456e7a2ddc 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,4 +1,12 @@ -import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; +import type { + OrchestrationV2TurnItemStatus, + OrchestrationV2ThreadShell, + OrchestrationProjectShell, + ServerProvider, +} from "@t3tools/contracts"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { fileBasename } from "../markdownLinks.ts"; +import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; /** Summarizes one adjacent group, without changing its member identities or order. */ export function subagentGroupSummary( @@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string { const name = path[1]!.replace(/[_\s]+/gu, " ").trim(); return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle; } + +/** Match desktop's model resolution and show only changes from the parent's workspace. */ +export function resolveSubagentMetadata(input: { + readonly model: string | null; + readonly provider?: Pick | null | undefined; + readonly parentThread?: + | Pick + | null + | undefined; + readonly childThread?: + | Pick + | null + | undefined; + readonly parentProject?: Pick | null | undefined; + readonly childProject?: + | Pick + | null + | undefined; +}) { + const model = input.model?.trim(); + const slug = input.provider + ? resolveSelectableModel(input.provider.driver, model, input.provider.models) + : model; + const catalogModel = input.provider?.models.find((candidate) => candidate.slug === slug); + const reportedLabel = catalogModel + ? catalogModel.shortName || catalogModel.name + : model + ? formatModelSlugName(model) + : "Not reported"; + const qualifier = catalogModel?.subProvider?.trim(); + const modelLabel = qualifier + ? reportedLabel + .replace( + new RegExp( + `^${qualifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s*[.:/-]\\s*|\\s+)`, + "iu", + ), + "", + ) + .trim() || reportedLabel + : reportedLabel; + const parentWorkspace = input.parentThread?.worktreePath ?? input.parentProject?.workspaceRoot; + const childWorkspace = input.childThread?.worktreePath ?? input.childProject?.workspaceRoot; + const workspace = [ + ...(input.parentThread && + input.childProject && + input.childProject.id !== input.parentThread.projectId + ? [{ label: "Project", value: input.childProject.title }] + : []), + ...(parentWorkspace && childWorkspace && parentWorkspace !== childWorkspace + ? [ + { + label: input.childThread?.branch + ? "Branch" + : input.childThread?.worktreePath + ? "Worktree" + : "Workspace", + value: input.childThread?.branch ?? fileBasename(childWorkspace), + }, + ] + : []), + ]; + return { modelLabel, workspace }; +} + +/** Live work leads with progress; settled work leads with its result. */ +export function subagentDetailPreview(input: { + readonly status: OrchestrationV2TurnItemStatus; + readonly result?: string | null | undefined; + readonly progress?: string | null | undefined; +}): string | null { + const result = input.result?.trim(); + const progress = input.progress?.trim(); + const detail = + (isTerminalSubagentStatus(input.status) ? result || progress : progress || result) || ""; + const compact = detail.replace(/\s+/gu, " "); + return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null; +} From 77823bd102ae50430d4acda9a553e5743d2aa5ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 10:24:23 -0700 Subject: [PATCH 43/82] fix(mobile): an expired Live Activity no longer leaves a second card (#15254) Co-authored-by: Claude Opus 5.5 (1M context) --- patches/expo-widgets@58.0.11.patch | 29 ++++++++++++++++++++++++----- pnpm-lock.yaml | 8 ++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/patches/expo-widgets@58.0.11.patch b/patches/expo-widgets@58.0.11.patch index f2632f180c84..cb8aef9f06dd 100644 --- a/patches/expo-widgets@58.0.11.patch +++ b/patches/expo-widgets@58.0.11.patch @@ -1,5 +1,5 @@ diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts -index c9f4dfe..dc0792e 100644 +index c9f4dfe1f10a3a65ab72bd7e639f6f6d148e5f95..dc0792e1cd7b6762394de5e3fefa0863a253f70c 100644 --- a/build/Widgets.types.d.ts +++ b/build/Widgets.types.d.ts @@ -93,6 +93,10 @@ export type LiveActivityEnvironment = { @@ -13,8 +13,27 @@ index c9f4dfe..dc0792e 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift +index 29e4f1566d188665e301d32bc1ad85640f83e68c..7143f9a1c06245d16649b981d7f5cde1d8868217 100644 +--- a/ios/LiveActivityFactory.swift ++++ b/ios/LiveActivityFactory.swift +@@ -19,6 +19,14 @@ final class LiveActivityFactory: SharedObject { + throw LiveActivitiesNotSupportedException() + } + ++ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to ++ // 4 more. getInstances() can't see it, so a caller starting a replacement would leave ++ // both cards visible. Dismiss the ended ones now. ++ for activity in Activity.activities ++ where activity.content.state.name == name && activity.activityState == .ended { ++ Task { await activity.end(nil, dismissalPolicy: .immediate) } ++ } ++ + do { + let initialState = LiveActivityAttributes.ContentState(name: name, props: props) + let activity = try Activity.request( diff --git a/ios/Widgets/Utils.swift b/ios/Widgets/Utils.swift -index 76b2199..6cb52c3 100644 +index 76b2199c2d3f879463fed5d2bd49038b0428a2e7..6cb52c3de4ad9696bc0c51ad62401cce96f405e7 100644 --- a/ios/Widgets/Utils.swift +++ b/ios/Widgets/Utils.swift @@ -125,6 +125,10 @@ func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: @@ -29,7 +48,7 @@ index 76b2199..6cb52c3 100644 env["isActivityUpdateReduced"] = environment.isActivityUpdateReduced env["activityFamily"] = "\(environment.activityFamily)" diff --git a/ios/Widgets/WidgetLiveActivity.swift b/ios/Widgets/WidgetLiveActivity.swift -index 4d135c4..dae4744 100644 +index 4d135c4e4eb1f846315654ed9d835a935d120d42..dae47445b27516fa2464a25b68bddc8f6dec3e1e 100644 --- a/ios/Widgets/WidgetLiveActivity.swift +++ b/ios/Widgets/WidgetLiveActivity.swift @@ -19,53 +19,41 @@ struct LiveActivityAttributes: ActivityAttributes { @@ -132,7 +151,7 @@ index 4d135c4..dae4744 100644 LiveActivityBanner(context: context, nodes: nodes) } else if let node = nodes["banner"] as? [String: Any] { diff --git a/scripts/build-layout-registry.mjs b/scripts/build-layout-registry.mjs -index d52a60e..fa9bd77 100644 +index d52a60e342c8e0a8a1d78f7174da5e60e6bd1c53..fa9bd77c5c04d459038a5b4a00e1391780b406fa 100644 --- a/scripts/build-layout-registry.mjs +++ b/scripts/build-layout-registry.mjs @@ -154,6 +154,8 @@ function createLayoutRegistry(expectedWidgets, capturedLayouts) { @@ -146,7 +165,7 @@ index d52a60e..fa9bd77 100644 await main(); } diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts -index 0770613..924fa00 100644 +index 07706139379384dae27befef4e3e5ae09d04a0db..924fa0029243664e369ce08c81e504b6300bfc7f 100644 --- a/src/Widgets.types.ts +++ b/src/Widgets.types.ts @@ -107,6 +107,10 @@ export type LiveActivityEnvironment = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f21be6151710..8315c8a24db9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,7 +108,7 @@ patchedDependencies: expo-blur@58.0.3: d0311b0c0b05bd94393bd9a84653cb42503d5f3c8a19eb2627c34ace08bedf18 expo-glass-effect@58.0.3: f750041c45ab838a3530451dc7b50d6640a79141037a1b0499c5e3412a860c57 expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 - expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b + expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9 react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d @@ -431,7 +431,7 @@ importers: version: 58.0.4(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.11 - version: 58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -16305,7 +16305,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) - expo-widgets: 58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -17535,7 +17535,7 @@ snapshots: expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - expo-widgets@58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + expo-widgets@58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.11(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) From cfdff56f7b3c9c931ae368224041aa14825649fd Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:29:18 -0300 Subject: [PATCH 44/82] test(web): remove redundant thread sort fallback tests (#15095) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- apps/web/src/lib/threadSort.test.ts | 50 ----------------------------- 1 file changed, 50 deletions(-) diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 9ae3c3386cd3..0d48fbe3e136 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -117,56 +117,6 @@ describe("sortThreads", () => { ]); }); - it("falls back to createdAt when updatedAt is invalid", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "invalid-date" as never, - messages: [], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T09:00:00.000Z", - updatedAt: "2026-03-09T09:30:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "invalid-created-at" as never, - updatedAt: "invalid-updated-at" as never, - messages: [], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "invalid-created-at" as never, - updatedAt: "invalid-updated-at" as never, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - it("can sort threads by createdAt when configured", () => { const sorted = sortThreads( [ From 70e5a40c20ddbaab7831ab36e17e64c9908269a5 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 16:41:56 -0300 Subject: [PATCH 45/82] fix(web): thinking row after a failed tool expands the run's tool calls (#15056) --- .../chat/MessagesTimeline.logic.test.ts | 15 +++++++ .../components/chat/MessagesTimeline.logic.ts | 42 ++++++++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 25 +++++++---- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f87cb75b34b8..173a964d5efc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2684,8 +2684,10 @@ describe("deriveMessagesTimelineRows", () => { it("reuses one activity row for initial thinking and the latest tool", () => { const deriveRows = ( toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null, + expandedWorkGroupIds?: ReadonlySet, ) => deriveMessagesTimelineRows({ + ...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}), timelineEntries: toolLifecycleStatus === null ? [] @@ -2733,6 +2735,19 @@ describe("deriveMessagesTimelineRows", () => { expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); expect(failedRows.some((row) => row.kind === "work-live")).toBe(false); expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + const failedThinkingRow = failedRows.at(-1); + const failedGroupId = + failedThinkingRow?.kind === "thinking" ? failedThinkingRow.groupId : undefined; + expect(failedGroupId).toBeDefined(); + const expandedFailedRows = deriveRows("failed", new Set([failedGroupId!])); + expect(expandedFailedRows.slice(-2)).toMatchObject([ + { kind: "thinking", id: "live-activity-row", expanded: true }, + { + kind: "work", + isExpandedToolGroup: true, + groupedEntries: [{ id: "latest-command" }], + }, + ]); expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 09784aaf9dbd..280cc3672103 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -509,6 +509,9 @@ type MessagesTimelineRowContent = kind: "thinking"; id: string; createdAt: string | null; + /** Tool calls this row stands in for after the latest one failed. */ + groupId?: string; + expanded?: boolean; } | { kind: "work-toggle"; @@ -1737,11 +1740,35 @@ export function deriveMessagesTimelineRows(input: { !hasActiveCompaction && (!hasActivityRow || latestToolFailed) ) { - nextRows.push({ - kind: "thinking", - id: LIVE_ACTIVITY_ROW_ID, - createdAt: input.activeTurnStartedAt ?? null, - }); + // A failed latest tool hands the row back to thinking, but its group + // stays reachable through the same disclosure the live row offers. + const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined; + if (failedGroupAnchor) { + const groupId = workGroupId(failedGroupAnchor.id); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + nextRows.push({ + kind: "thinking", + id: LIVE_ACTIVITY_ROW_ID, + createdAt: input.activeTurnStartedAt ?? null, + groupId, + expanded, + }); + if (expanded) { + nextRows.push( + expandedWorkGroupRow( + groupId, + failedGroupAnchor.createdAt, + visibleActiveToolEntries.map((entry) => entry.entry), + ), + ); + } + } else { + nextRows.push({ + kind: "thinking", + id: LIVE_ACTIVITY_ROW_ID, + createdAt: input.activeTurnStartedAt ?? null, + }); + } } const result = attachTrailingToolGroupsToAssistant( @@ -1952,8 +1979,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - case "thinking": return a.createdAt === (b as typeof a).createdAt; + case "thinking": { + const bt = b as typeof a; + return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded; + } case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b5113b5f41a3..8b2808b3a18d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1741,7 +1741,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work-toggle" || row.kind === "thinking"; const isExpandedToolGroupHeader = - (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded); + (row.kind === "work-toggle" || row.kind === "work-live" || row.kind === "thinking") && + row.expanded === true; return (
: null} {row.kind === "work-toggle" ? : null} - {row.kind === "thinking" ? : null} + {row.kind === "thinking" ? : null} ) : null} {row.kind === "turn-fold" ? : null} @@ -3461,13 +3462,23 @@ function CompactingLabel() { ); } -function ThinkingTimelineRow() { +function ThinkingTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. - return isPreparingWorktree || isCompacting ? ( - - ) : ( - + if (isPreparingWorktree || isCompacting) return ; + const activity = ; + const { groupId } = row; + if (groupId === undefined) return activity; + return ( + ); } From 39efcd855833a1486ff7662e2efcedeeee6be0cc Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:58:09 -0700 Subject: [PATCH 46/82] perf(web): DOM changes no longer restyle the whole page (#15265) --- apps/web/src/components/chat/ComposerBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 78753c36097b..cbcb58897157 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -111,7 +111,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", // Adjacent attachments share their outline, including notices outside the form. "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0", - "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", + "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", className, )} {...props} From 71dbaf1f94b580491d033f2e347a9b1c507fd793 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 13:03:09 -0700 Subject: [PATCH 47/82] perf(usage): cut warm usage scans from seconds to milliseconds on large histories (#15149) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/usage/UsageService.test.ts | 187 ++++++++++++++++++ apps/server/src/usage/UsageService.ts | 169 +++++++++++----- .../server/src/usage/usageAggregation.test.ts | 35 ++++ apps/server/src/usage/usageAggregation.ts | 116 ++++++++--- apps/server/src/usage/usagePricing.ts | 20 ++ apps/server/src/usage/usageScanCache.test.ts | 21 ++ apps/server/src/usage/usageScanCache.ts | 111 ++++++++--- .../server/src/usage/usageTranscriptReader.ts | 41 ++-- apps/server/src/usage/usageTranscripts.ts | 10 - 9 files changed, 579 insertions(+), 131 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 127679d0a449..62345d8472e1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real // transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -117,6 +118,41 @@ const serviceLayers = (input: { ), ); +/** Outside `NARROW_WINDOW`, inside `WINDOW`. Seconds, as `utimes` takes them. */ +const BEFORE_NARROW_WINDOW = Date.parse("2026-08-01T10:00:00Z") / 1000; +const NARROW_WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make("2026-09-02"), +}; + +/** + * A FIFO named like a transcript. A scan's read of it waits in `open` until + * `openGate`, then fails at once, so a gate holds that scan's directory reads + * in flight. Each waiting gate holds one libuv pool thread; keep at most three. + */ +const makeGate = (path: string, lastWriteSeconds?: number) => + Effect.promise(async () => { + NodeChildProcess.execFileSync("mkfifo", [path]); + if (lastWriteSeconds !== undefined) { + await NodeFSP.utimes(path, lastWriteSeconds, lastWriteSeconds); + } + }); + +/** + * Returns once a scan has opened the gate. A scan opens every file of a + * directory at once, so it then holds the directory's transcripts open too. + */ +const openGate = (path: string) => + Effect.promise(async () => (await NodeFSP.open(path, "w")).close()); + +/** Replaces a file by rename, so a scan holding the old one keeps reading it. */ +const replaceFile = (path: string, content: string) => + Effect.promise(async () => { + await NodeFSP.writeFile(path + ".next", content); + await NodeFSP.rename(path + ".next", path); + }); + function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); } @@ -972,6 +1008,157 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("credits the same copy of a duplicate after its transcripts are deleted", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + // The walk-first file is the original, padded so it finishes parsing + // after the small fork copy that repeats its record under a new session. + const [first = "", second = ""] = yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(dir, "a.jsonl"), ""); + await NodeFSP.writeFile(NodePath.join(dir, "b.jsonl"), ""); + return (await NodeFSP.readdir(dir)).map((name) => NodePath.join(dir, name)); + }); + const forked = (line: string) => line.replace('"session-1"', '"session-2"'); + yield* Effect.promise(async () => { + await NodeFSP.writeFile( + first, + claudeLine(1, 5).replace( + '"message":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', + ), + ); + await NodeFSP.writeFile(second, forked(claudeLine(1, 5)) + forked(claudeLine(2, 7))); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const live = yield* service.readSummary(WINDOW); + assert.strictEqual(live.buckets[0]?.sessions, 2); + + yield* Effect.promise(() => Promise.all([NodeFSP.rm(first), NodeFSP.rm(second)])); + const saved = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(saved.buckets, live.buckets); + assert.deepStrictEqual(saved.sources, live.sources); + const restored = yield* (yield* UsageService.make).readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, live.buckets); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-copy-order-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "keeps a newer cached read when a slower scan of another window finishes later", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + const probe = NodePath.join(dir, "probe.jsonl"); + const hold = NodePath.join(dir, "hold.jsonl"); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + yield* makeGate(probe, BEFORE_NARROW_WINDOW); + yield* makeGate(hold, BEFORE_NARROW_WINDOW); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* openGate(probe); + yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); + // The narrow scan caches the newer read while the wide one waits. + yield* service.readSummary(NARROW_WINDOW); + yield* openGate(hold); + yield* Fiber.join(wide); + + yield* Effect.promise(() => + Promise.all([transcript, probe, hold].map((path) => NodeFSP.rm(path))), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-stale-read-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "keeps the later read when a scan that read earlier finishes first", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + const gate = (name: string) => NodePath.join(dir, `${name}.jsonl`); + const wideProbe = gate("wide-probe"); + const wideHold = gate("wide-hold"); + const narrowProbe = gate("narrow-probe"); + const narrowHold = gate("narrow-hold"); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + yield* makeGate(wideProbe, BEFORE_NARROW_WINDOW); + yield* makeGate(wideHold, BEFORE_NARROW_WINDOW); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* openGate(wideProbe); + yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); + // Made after the wide scan's walk, so only the narrow scan waits on them. + yield* makeGate(narrowProbe); + yield* makeGate(narrowHold); + const narrow = yield* service.readSummary(NARROW_WINDOW).pipe(Effect.forkChild); + yield* openGate(narrowProbe); + // Both scans started from an empty cache entry; the earlier read lands first. + yield* openGate(wideHold); + yield* Fiber.join(wide); + yield* openGate(narrowHold); + yield* Fiber.join(narrow); + + yield* Effect.promise(() => + Promise.all( + [transcript, wideProbe, wideHold, narrowProbe, narrowHold].map((path) => + NodeFSP.rm(path), + ), + ), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-late-read-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live("reports saved usage of a removed directory only for windows it reaches", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; + await NodeFSP.utimes(transcript, lastWrite, lastWrite); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-saved-window-test", home, settings }), + ), + ); + const first = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + + const reached = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(reached.buckets, first.buckets); + assert.strictEqual(reached.sources[0]?.status, "ok"); + + // A missing source cannot claim this directory from another environment + // that still reads it, so it adds nothing to a window after its last write. + const later = yield* service.readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-10"), + untilDay: UsageDay.make("2026-08-12"), + }); + assert.deepStrictEqual(later.buckets, []); + assert.strictEqual(later.sources[0]?.status, "missing"); + assert.strictEqual(later.sources[0]?.scannedFiles, 0); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 026641ae4d07..dc3121fc0024 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -63,10 +63,11 @@ import { import { decodeScanCache, dedupeWithinFile, - encodeScanCache, LEGACY_SCAN_CACHE_FILE_NAME, + makeScanCacheWriter, pruneScanCache, SCAN_CACHE_FILE_NAME, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -90,6 +91,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +/** Transcripts parsed at once. More gains little once the disk stays busy. */ +const TRANSCRIPT_READ_CONCURRENCY = 4; + const decodeCodexSettings = Schema.decodeOption(CodexSettings); const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); @@ -108,9 +112,37 @@ const encodeRatesCache = Schema.encodeEffect( /** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); -const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); + +/** Whether `a` read a later state of its file than `b`. Transcripts only grow. */ +function isLaterRead(a: CachedFile, b: CachedFile): boolean { + return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); +} + +/** + * Codex sessions with records in more than one file, such as a rollout that + * moved after it was read. Only these need cross-file dedupe keys: within one + * file the occurrence count already keeps every key unique, so keying the rest + * would only build and hash a string for each of their records. + */ +function sharedCodexSessions( + files: readonly { readonly records: readonly UsageRecord[] }[], +): ReadonlySet { + const firstFile = new Map(); + const shared = new Set(); + for (const [index, file] of files.entries()) { + let previous = ""; + for (const { provider, sessionId } of file.records) { + if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue; + previous = sessionId; + const first = firstFile.get(sessionId); + if (first === undefined) firstFile.set(sessionId, index); + else if (first !== index) shared.add(sessionId); + } + } + return shared; +} const decodeCachedSources = Schema.decodeUnknownOption( Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), ); @@ -386,20 +418,28 @@ export const make = Effect.gen(function* () { }), ); + const writeScanCache = makeScanCacheWriter(); + // Scans with different windows can finish together; two writes interleaved + // in one file would corrupt it. + const persistLock = yield* Semaphore.make(1); + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { if (!cacheDirty) return; - // Cleared only after the write lands, so a failed persist is retried on - // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile({ - ...encodeScanCache(fileCache), - sources: Object.fromEntries(sourceCache), - }).pipe( + // Cleared before encoding, so a scan that changes the cache while this + // write is in flight marks it dirty again. A failed write restores the + // flag, so the next scan retries instead of leaving disk stale. + cacheDirty = false; + yield* Effect.sync(() => + writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), + ).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), - Effect.map(() => { - cacheDirty = false; - }), // A cache we cannot write is a slower next start, not a failed read. - Effect.ignoreCause, + Effect.catchCause(() => + Effect.sync(() => { + cacheDirty = true; + }), + ), + persistLock.withPermit, ); }); @@ -410,13 +450,22 @@ export const make = Effect.gen(function* () { * written multi-hundred-megabyte rollout costs its appended bytes per scan * rather than a full re-read. The reader verifies the position's guard bytes * and silently restarts from byte 0 when they no longer match. + * + * A fresh parse comes back as `update` for the caller to cache, with the + * entry it was built from. Reads run concurrently, and the caller stores + * updates in walk order rather than completion order: saved records of + * deleted transcripts aggregate in cache order, where the first copy of a + * duplicate wins. */ const readFileRecords = ( filePath: string, size: number, mtimeMs: number, provider: UsageProviderKind, - ): Effect.Effect => + ): Effect.Effect<{ + readonly records: readonly UsageRecord[]; + readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; + }> => Effect.gen(function* () { const cached = fileCache.get(filePath); // Provider is part of the identity: if both providers were ever pointed @@ -427,9 +476,12 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords]; + return { + records: + cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords], + }; } // Only a strictly grown file may resume. Same size with a new mtime, or @@ -445,7 +497,9 @@ export const make = Effect.gen(function* () { // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) - return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; + return { + records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + }; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. One @@ -456,16 +510,13 @@ export const make = Effect.gen(function* () { const records = dedupeWithinFile([...base, ...parsed.records], seen); const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); - fileCache.set(filePath, { - size, - mtimeMs, - provider, - records, - tailRecords, - position: parsed.position, - }); - cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + return { + records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + update: { + entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, + replaces: cached, + }, + }; }); /** One provider directory's walk and parse, before rates are involved. */ @@ -505,11 +556,32 @@ export const make = Effect.gen(function* () { const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), ); - const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; - for (const file of files) { - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - parsedFiles.push({ path: file.path, records }); - } + // A cold parse waits on disk reads, so a few files in flight read + // close to twice as fast. Results keep walk order. + const read = yield* Effect.forEach( + files, + (file) => + readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( + Effect.map((result) => ({ path: file.path, ...result })), + ), + { concurrency: TRANSCRIPT_READ_CONCURRENCY }, + ); + const parsedFiles = read.map(({ path, records, update }) => { + if (update === undefined) return { path, records }; + // A scan of another window may have cached its own read of this file + // meanwhile. Then keep whichever read saw the later file, so a slower + // scan never replaces newer usage with older. + const current = fileCache.get(path); + if ( + current === update.replaces || + current === undefined || + !isLaterRead(current, update.entry) + ) { + fileCache.set(path, update.entry); + cacheDirty = true; + } + return { path, records }; + }); scanned.push({ provider, dir, volumeId, files: parsedFiles }); } @@ -753,37 +825,39 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - for (const { - provider, - dir, - volumeId, - files, - status, - message, - action, - hostId: sourceHostId, - } of scannedDirs) { + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to its source through the normal aggregation and dedupe + // path. Like the walk, skip files last written before the window: they + // cannot hold records inside it. + const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); + const filesByDir = scannedDirs.map(({ provider, dir, files }) => { const retainedFiles = [...(files ?? [])]; const livePaths = new Set(retainedFiles.map((file) => file.path)); - // Cleanup may remove transcripts, but the usage we already saved still - // contributes to this source. Keep the normal aggregation and dedupe path. for (const [filePath, entry] of fileCache) { if ( entry.provider !== provider || - entry.mtimeMs < retentionCutoffMs || + entry.mtimeMs < retainedSinceMs || livePaths.has(filePath) || !isWithinDirectory(filePath, dir) ) continue; retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); } + return retainedFiles; + }); + const sharedSessions = sharedCodexSessions(filesByDir.flat()); + + for (const [ + index, + { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId }, + ] of scannedDirs.entries()) { let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); - for (const file of retainedFiles) { + for (const file of filesByDir[index] ?? []) { if (file.records.length === 0) { skippedFiles += 1; continue; @@ -792,9 +866,10 @@ export const make = Effect.gen(function* () { const codexEventOccurrences = new Map(); for (const record of file.records) { let usageRecord = record; - if (record.provider === "codex" && record.sessionId.length > 0) { + if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { // Match moved rollout copies without collapsing repeated equal events // within one rollout (timestamps can have only second precision). + // Only sessions seen in several files can have a copy to match. const key = encodeUsageRecordKey([ record.provider, record.sessionId, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index ab260d3398b2..9fea9cd96644 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -128,6 +128,41 @@ describe("UsageAggregator", () => { expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); }); + it("finds the day at a quarter-hour zone's midnight across interleaved buckets", () => { + // Kathmandu is UTC+5:45, so its midnight falls at 18:15 UTC. + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-01T18:14:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z"), model: "claude-opus-5" }), + record({ timestampMs: Date.parse("2026-08-01T18:16:00.000Z") }), + ], + "Asia/Kathmandu", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.model, bucket.records])).toEqual([ + ["2026-08-01", "claude-fable-5", 1], + ["2026-08-02", "claude-fable-5", 2], + ["2026-08-02", "claude-opus-5", 1], + ]); + }); + + it("finds the day at a fixed offset's midnight between quarter hours", () => { + // At +00:01, midnight falls at 23:59 UTC. + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-01T23:58:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-01T23:59:00.000Z") }), + ], + "+00:01", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.records])).toEqual([ + ["2026-08-01", 1], + ["2026-08-02", 1], + ]); + }); + it("splits an hourly request into fixed buckets anchored to its exact start", () => { const result = aggregate( [ diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 040c258075d4..0652ca4b7dcf 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -20,7 +20,7 @@ import type { UsageTokenTotals, } from "@t3tools/contracts"; -import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** @@ -47,13 +47,30 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { day: "2-digit", }); } - return (timestampMs) => format.format(new Date(timestampMs)); + // Formatting once per quarter-hour slot keeps `Intl` (about 2µs a call) off + // the per-record path. A slot whose two ends fall on one day lies wholly in + // that day. Named zones put midnight on a quarter hour, so their slots never + // split; a fixed offset such as `+00:01` can, and records in a split slot are + // formatted exactly. `null` marks a split slot. + const days = new Map(); + return (timestampMs) => { + const slot = Math.floor(timestampMs / QUARTER_HOUR_MS); + let day = days.get(slot); + if (day === undefined) { + const first = format.format(new Date(slot * QUARTER_HOUR_MS)); + const last = format.format(new Date((slot + 1) * QUARTER_HOUR_MS - 1)); + day = first === last ? first : null; + days.set(slot, day); + } + return day ?? format.format(new Date(timestampMs)); + }; } +const QUARTER_HOUR_MS = 15 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; interface MutableBucket { - totals: UsageTokenTotals; + totals: { -readonly [K in keyof UsageTokenTotals]: number }; costUsd: number; cacheSavingsUsd: number; categoryCostUsd: UsageCategoryCost | null; @@ -98,6 +115,14 @@ export class UsageAggregator { readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; + #lastBucket: { + readonly day: string; + readonly hourIndex: number; + readonly provider: string; + readonly model: string; + readonly source: string; + readonly bucket: MutableBucket; + } | null = null; #duplicatesDropped = 0; #outOfWindow = 0; @@ -149,35 +174,20 @@ export class UsageAggregator { return false; } - const hourStart = + const hourIndex = this.#hourlyWindow === null - ? "" - : new Date( - this.#hourlyWindow.sinceTimeMs + - Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, - ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; - let bucket = this.#buckets.get(key); - if (bucket === undefined) { - bucket = { - totals: EMPTY_TOTALS, - costUsd: 0, - cacheSavingsUsd: 0, - categoryCostUsd: null, - fastCostUsd: 0, - ultrafastCostUsd: 0, - speedPremiumUsd: 0, - records: 0, - unpricedRecords: 0, - providerReportedRecords: 0, - sessions: new Set(), - }; - this.#buckets.set(key, bucket); - } + ? -1 + : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); + const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); - bucket.totals = addTotals(bucket.totals, record.totals); + const totals = bucket.totals; + totals.uncachedInputTokens += record.totals.uncachedInputTokens; + totals.cachedInputTokens += record.totals.cachedInputTokens; + totals.cacheCreationTokens += record.totals.cacheCreationTokens; + totals.outputTokens += record.totals.outputTokens; + totals.reasoningTokens += record.totals.reasoningTokens; bucket.costUsd += priced.costUsd; if (priced.categoryCostUsd !== null) { const sum = bucket.categoryCostUsd; @@ -207,6 +217,54 @@ export class UsageAggregator { return true; } + /** + * Records arrive file by file in time order, so most land in the bucket the + * previous record used. Checking that first skips building and hashing a + * key string per record. + */ + #bucketFor( + day: string, + hourIndex: number, + provider: string, + model: string, + source: string, + ): MutableBucket { + const last = this.#lastBucket; + if ( + last !== null && + last.day === day && + last.hourIndex === hourIndex && + last.provider === provider && + last.model === model && + last.source === source + ) { + return last.bucket; + } + const window = this.#hourlyWindow; + const hourStart = + window === null ? "" : new Date(window.sinceTimeMs + hourIndex * HOUR_MS).toISOString(); + const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${model}\u0000${source}`; + let bucket = this.#buckets.get(key); + if (bucket === undefined) { + bucket = { + totals: { ...EMPTY_TOTALS }, + costUsd: 0, + cacheSavingsUsd: 0, + categoryCostUsd: null, + fastCostUsd: 0, + ultrafastCostUsd: 0, + speedPremiumUsd: 0, + records: 0, + unpricedRecords: 0, + providerReportedRecords: 0, + sessions: new Set(), + }; + this.#buckets.set(key, bucket); + } + this.#lastBucket = { day, hourIndex, provider, model, source, bucket }; + return bucket; + } + finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { @@ -222,7 +280,7 @@ export class UsageAggregator { provider: provider as UsageBucket["provider"], model, ...(sourcePath === "" ? {} : { sourcePath }), - totals: bucket.totals, + totals: { ...bucket.totals }, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, // Zero and unknown figures are omitted to keep payloads small. diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 51ef35d7a8ad..0ec7ef8d622f 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -235,7 +235,27 @@ const UNPRICEABLE_MODELS = new Set([ "fable", ]); +/** + * Lookups per table, by raw model name. A scan prices every record twice + * against a few dozen models, and tables are never mutated once built. + */ +const resolvedRates = new WeakMap>(); + export function lookupRate(table: RateTable, model: string): ModelRate | null { + let resolved = resolvedRates.get(table); + if (resolved === undefined) { + resolved = new Map(); + resolvedRates.set(table, resolved); + } + let rate = resolved.get(model); + if (rate === undefined) { + rate = resolveRate(table, model); + resolved.set(model, rate); + } + return rate; +} + +function resolveRate(table: RateTable, model: string): ModelRate | null { const key = stripVariantSuffix(normalizeRateKey(model)); const bareName = bareModelName(key); if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 82ff1e36dfc1..b59768cf892b 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -4,6 +4,7 @@ import { decodeScanCache, dedupeWithinFile, encodeScanCache, + makeScanCacheWriter, pruneScanCache, type CachedFile, type ScanCache, @@ -149,6 +150,26 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); + it("rewrites only changed entries and still restores the whole cache", () => { + const write = makeScanCacheWriter(); + const cache = cacheWith([ + ["/a.jsonl", 100, [record()]], + ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], + ]); + const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; + expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache); + + // The replacement adds intern entries; /a's memoised indexes must hold. + cache.set("/b.jsonl", { + ...cache.get("/b.jsonl")!, + size: 30, + records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], + }); + const document = JSON.parse(write(cache, { sources })); + expect(decodeScanCache(document)).toEqual(cache); + expect(document.sources).toEqual(sources); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 3c6826a67986..04e7ab4ce982 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -103,26 +103,32 @@ interface SerializedCache { readonly files: Readonly>; } -/** Serialises the cache, interning the repeated model and session strings. */ -export function encodeScanCache(cache: ScanCache): SerializedCache { - const models: string[] = []; - const sessions: string[] = []; - const modelIndex = new Map(); - const sessionIndex = new Map(); +/** Model and session strings, each stored once and referenced by index. */ +interface InternTables { + readonly models: string[]; + readonly sessions: string[]; + readonly modelIndex: Map; + readonly sessionIndex: Map; +} - const intern = (table: string[], index: Map, value: string): number => { - const existing = index.get(value); - if (existing !== undefined) return existing; - const next = table.length; - table.push(value); - index.set(value, next); - return next; - }; +function makeInternTables(): InternTables { + return { models: [], sessions: [], modelIndex: new Map(), sessionIndex: new Map() }; +} + +function intern(table: string[], index: Map, value: string): number { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; +} +function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile { const serializeRecord = (record: UsageRecord): SerializedRecord => [ record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), + intern(tables.models, tables.modelIndex, record.model), + intern(tables.sessions, tables.sessionIndex, record.sessionId), record.totals.uncachedInputTokens, record.totals.cachedInputTokens, record.totals.cacheCreationTokens, @@ -132,23 +138,68 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.reportedCostUsd, SPEEDS.indexOf(record.speed), ]; + return { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, + }; +} +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const tables = makeInternTables(); const files: Record = {}; - for (const [path, entry] of cache) { - files[path] = { - s: entry.size, - m: entry.mtimeMs, - p: entry.provider, - r: entry.records.map(serializeRecord), - t: entry.tailRecords.map(serializeRecord), - o: entry.position.resumeOffset, - gl: entry.position.guardLength, - gh: entry.position.guardHash, - cs: entry.position.codexState, - }; - } + for (const [path, entry] of cache) files[path] = serializeFile(entry, tables); + return { + version: USAGE_SCAN_CACHE_VERSION, + models: tables.models, + sessions: tables.sessions, + files, + }; +} - return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +/** + * Returns a function that serialises the cache to JSON text, re-encoding only + * the entries that changed since its last call. Call it once per persist. + * + * Writes the same document as `encodeScanCache`. Most entries never change + * between scans, and encoding all of them made each persist cost close to a + * second on a large cache. Entries are replaced, never mutated, when their file + * changes, so an entry's JSON is memoised by identity. The intern tables only + * grow, so a memoised entry's indexes stay valid; a pruned entry can leave an + * unused string behind until the next process start. + */ +export function makeScanCacheWriter(): ( + cache: ScanCache, + extra: Readonly>, +) => string { + const tables = makeInternTables(); + const fragments = new WeakMap(); + return (cache, extra) => { + const files: string[] = []; + for (const [path, entry] of cache) { + let fragment = fragments.get(entry); + if (fragment === undefined) { + fragment = JSON.stringify(serializeFile(entry, tables)); + fragments.set(entry, fragment); + } + files.push(`${JSON.stringify(path)}:${fragment}`); + } + // Encoded after the files, which may have added to the intern tables. + const head = JSON.stringify({ + ...extra, + version: USAGE_SCAN_CACHE_VERSION, + models: tables.models, + sessions: tables.sessions, + }); + return `${head.slice(0, -1)},"files":{${files.join(",")}}}`; + }; } function isRecordArray(value: unknown): value is readonly unknown[] { diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 4d170237568d..bca22468a95e 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -85,6 +85,8 @@ export const GUARD_LENGTH = 64; // readers; it never discards a record because of its size. const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; const NEWLINE = 0x0a; +/** `stat` calls one transcript walk keeps in flight. The libuv pool has 4 threads. */ +const STAT_CONCURRENCY = 32; const CARRIAGE_RETURN = 0x0d; type SelectedFields = { readonly [key: string]: true | SelectedFields }; @@ -156,15 +158,19 @@ function fnv1a(buffer: Buffer): number { * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that * never carry usage, so the basename filter keeps a cold scan off those files. + * + * Directories are listed depth-first, one at a time, then the candidates are + * stat'd by a fixed pool of workers: a warm scan stats thousands of files, and + * one at a time each waits its own trip through the thread pool. Results keep + * `readdir` order, which the aggregator's first-seen dedupe relies on. */ export async function listTranscriptFiles( root: string, sinceMs: number, options?: { readonly fileName?: string }, ): Promise { - const found: TranscriptFile[] = []; const fileName = options?.fileName; - + const candidates: string[] = []; const walk = async (dir: string): Promise => { let entries; try { @@ -174,28 +180,33 @@ export async function listTranscriptFiles( } for (const entry of entries) { const child = NodePath.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(child); - continue; - } - if (fileName !== undefined) { - if (entry.name !== fileName) continue; - } else if (!entry.name.endsWith(".jsonl")) { - continue; + if (entry.isDirectory()) await walk(child); + else if (fileName !== undefined ? entry.name === fileName : entry.name.endsWith(".jsonl")) { + candidates.push(child); } + } + }; + await walk(root); + + const found: Array = Array.from({ length: candidates.length }); + // Each worker pulls the next candidate from one shared iterator. + const queue = candidates.entries(); + const statQueued = async (): Promise => { + for (const [index, path] of queue) { try { - const stats = await NodeFSP.stat(child); + const stats = await NodeFSP.stat(path); if (stats.mtimeMs >= sinceMs) { - found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + found[index] = { path, size: stats.size, mtimeMs: stats.mtimeMs }; } } catch { // Vanished between readdir and stat. } } }; - - await walk(root); - return found; + await Promise.all( + Array.from({ length: Math.min(STAT_CONCURRENCY, candidates.length) }, statQueued), + ); + return found.filter((file) => file !== undefined); } /** diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index eb32e664f436..451f8a726b3e 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -54,16 +54,6 @@ function parseTimestampMs(value: unknown): number | null { return Number.isNaN(parsed) ? null : parsed; } -export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { - return { - uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, - cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, - cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, - outputTokens: a.outputTokens + b.outputTokens, - reasoningTokens: a.reasoningTokens + b.reasoningTokens, - }; -} - export function totalTokens(totals: UsageTokenTotals): number { // reasoningTokens is a subset of outputTokens and must not be added again. return ( From fffe6e6c82c12769e0cbc4d89a23ec845c5abed1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:27:38 -0700 Subject: [PATCH 48/82] perf(web): virtualize command palette results (#15266) --- .../components/CommandPalette.logic.test.ts | 45 +++++++ .../src/components/CommandPalette.logic.ts | 59 ++++++++++ apps/web/src/components/CommandPalette.tsx | 83 +++++++++++-- .../src/components/CommandPaletteResults.tsx | 110 ++++++++++++++++-- apps/web/src/components/ui/autocomplete.tsx | 28 ++++- apps/web/src/components/ui/command.tsx | 27 +++++ .../src/state/queries.threadSearch.test.tsx | 64 ++++++++++ apps/web/src/state/queries.ts | 3 + 8 files changed, 400 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/state/queries.threadSearch.test.tsx diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 916b66a32b34..2bd7c118c69f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; import { buildBrowseGroups, buildCommandPaletteProjectMetadata, + buildCommandPaletteRows, buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + findHighlightedCommandPaletteItem, reduceCommandPaletteUiState, type CommandPaletteActionItem, type CommandPaletteGroup, @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { ]); }); }); + +describe("virtualized command palette rows", () => { + const action = (value: string, disabled = false): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [], + title: value, + icon: null, + ...(disabled ? { disabled } : {}), + run: async () => {}, + }); + const groups: CommandPaletteGroup[] = [ + { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, + { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, + ]; + + it("keeps group order and headings while indexing only enabled items", () => { + const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); + + expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ + "# Actions", + "actions:new-thread", + "actions:offline", + "# Threads", + "threads:thread-a", + "threads:thread-b", + ]); + expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); + expect(rowIndexByItemIndex).toEqual([1, 4, 5]); + expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ + 0, + null, + 1, + 2, + ]); + }); + + it("resolves Enter to the highlighted item without needing its row mounted", () => { + expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); + expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); + expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index d031f36ed35c..49e963f68313 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -172,6 +172,65 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export type CommandPaletteRow = + | { + readonly kind: "label"; + readonly key: string; + readonly label: string; + readonly first: boolean; + } + | { + readonly kind: "item"; + readonly key: string; + readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + /** Position among enabled items, or null for disabled rows the keyboard skips. */ + readonly itemIndex: number | null; + }; + +/** + * Flattens groups into the rows a virtualized list renders. `itemValues` is the + * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a + * highlight back to its row for scrolling. + */ +export function buildCommandPaletteRows(groups: ReadonlyArray) { + const rows: CommandPaletteRow[] = []; + const itemValues: string[] = []; + const rowIndexByItemIndex: number[] = []; + for (const group of groups) { + if (group.label) { + rows.push({ + kind: "label", + key: `group:${group.value}`, + label: group.label, + first: rows.length === 0, + }); + } + for (const item of group.items) { + const itemIndex = item.disabled ? null : itemValues.length; + if (itemIndex !== null) { + itemValues.push(item.value); + rowIndexByItemIndex.push(rows.length); + } + rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex }); + } + } + return { rows, itemValues, rowIndexByItemIndex }; +} + +/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */ +export function findHighlightedCommandPaletteItem( + groups: ReadonlyArray, + highlightedItemValue: string | null, +): CommandPaletteActionItem | CommandPaletteSubmenuItem | null { + if (highlightedItemValue === null) return null; + for (const group of groups) { + for (const item of group.items) { + if (item.value === highlightedItemValue && !item.disabled) return item; + } + } + return null; +} + export function enumerateCommandPaletteItems( items: ReadonlyArray, ): CommandPaletteActionItem[] { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aad0e71a06b0..f7b78b5ae676 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -77,6 +77,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; +import type { LegendListRef } from "@legendapp/list/react"; import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -157,7 +158,9 @@ import { buildRootGroups, buildThreadActionItems, buildLinkedThreadActionItems, + buildCommandPaletteRows, enumerateCommandPaletteItems, + findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, @@ -174,7 +177,10 @@ import { import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; -import { CommandPaletteResults } from "./CommandPaletteResults"; +import { + CommandPaletteVirtualizedResults, + scrollCommandPaletteRowIntoView, +} from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const resultListRef = useRef(null); + // Typing or entering a submenu clears the highlight. Base UI keeps its own on the + // first row, but the palette shows none until the user navigates, and the first + // ArrowDown lands on it. + const highlightClearedRef = useRef(false); + function clearTypedHighlight(): void { + highlightClearedRef.current = true; + setHighlightedItemValue(null); + } const clientSettings = useClientSettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: { ? { source: match.source, snippet: match.snippet, - query: threadSearchQuery, + query: threadSearch.query, } : undefined; }, @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: { projectTitleById, providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, - threadSearchQuery, + threadSearch.query, threads, ], ); @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), }, ]); + highlightClearedRef.current = true; setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: { function handleQueryChange(nextQuery: string): void { browseNavigation.invalidate(); - setHighlightedItemValue(null); + clearTypedHighlight(); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: { } else if (isBrowsing) { displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; } + const resultRows = buildCommandPaletteRows(displayedGroups); + const autoHighlightsFirstRow = + !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null; const inputPlaceholder = newProjectFlow !== null @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); + return; + } + + // Base UI ignores navigation keys with modifiers, so these fallbacks do too. + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + // Base UI only keeps a hidden highlight on the first row when it auto-highlights. + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; + if ( + event.key === "ArrowDown" && + highlightClearedRef.current && + firstItemValue && + !event.nativeEvent.isComposing + ) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + highlightClearedRef.current = false; + setHighlightedItemValue(firstItemValue); + scrollCommandPaletteRowIntoView( + resultListRef.current, + resultRows.rowIndexByItemIndex[0] ?? 0, + ); + return; + } + + // Base UI clicks the highlighted row on Enter, which does nothing once the + // virtualized list has unmounted it, so run the tracked highlight directly. + if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? firstItemValue ?? null, + ); + if (highlightedItem) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + executeItem(highlightedItem); + } } } @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { { + items={resultRows.itemValues} + virtualized + onItemHighlighted={(value, eventDetails) => { + if (eventDetails.reason === "none" && highlightClearedRef.current) return; + highlightClearedRef.current = false; setHighlightedItemValue(typeof value === "string" ? value : null); + const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; + if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { + scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); + } }} onValueChange={handleQueryChange} showBackHint={isSubmenu} @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {
) : null} - void; } +function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); +} + export function CommandPaletteResults(props: CommandPaletteResultsProps) { if (props.groups.length === 0) { - return ( -
- {props.emptyStateMessage ?? - (props.isActionsOnly - ? "No matching actions." - : "No matching commands, projects, or threads.")} -
- ); + return ; } return ( @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +/** + * Scrolls a keyboard highlight into view the way the unvirtualized list did: + * nearest edge, clear of the scroll fade. Rows outside the rendered window + * fall back to the list's own scrolling. + */ +export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { + const element = list?.getState?.().elementAtIndex(rowIndex); + if (element instanceof HTMLElement) { + element.scrollIntoView({ block: "nearest" }); + return; + } + void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false }); +} + +interface CommandPaletteVirtualizedResultsProps extends Omit { + rows: ReadonlyArray; + listRef: RefObject; +} + +/** + * Renders only the visible rows. The parent passes the flat item order to the + * Command root as `items` with `virtualized`, scrolls keyboard highlights into + * view through `listRef`, and runs Enter itself since the row may be unmounted. + */ +export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) { + const { listRef } = props; + const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); + const updateScrollFade = useCallback(() => { + const scrollElement = listRef.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) return; + const top = scrollElement.scrollTop > 1; + const bottom = + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1; + setScrollFade((current) => + current.top === top && current.bottom === bottom ? current : { top, bottom }, + ); + }, [listRef]); + + if (props.rows.length === 0) { + return ; + } + + return ( + + + ref={listRef} + data={props.rows} + keyExtractor={(row) => row.key} + getItemType={(row) => row.kind} + extraData={props.highlightedItemValue} + renderItem={({ item: row }) => + row.kind === "label" ? ( +
+ {row.label} +
+ ) : row.itemIndex === null ? ( + + ) : ( + + ) + } + estimatedItemSize={40} + drawDistance={400} + onLayout={updateScrollFade} + onScroll={updateScrollFade} + contentContainerClassName="px-2" + className={cn( + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + getVirtualizedScrollFadeClassName(scrollFade), + )} + /> +
+ ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { } function CommandPaletteResultRow(props: { + index?: number; item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; keybindings: ResolvedKeybindingsConfig; @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { return ( { diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 8251d8c24b7c..548efd944897 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. ); } +const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; + function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { return ( + ); +} + +// A group label for virtualized lists, where rows cannot be wrapped in a group. +function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) { + return ( + ); @@ -226,6 +250,8 @@ export { AutocompleteGroupLabel, AutocompleteEmpty, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, AutocompleteClear, AutocompleteCollection, }; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index d3a83865151d..b3a473ba1d22 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -12,6 +12,8 @@ import { AutocompleteInput, AutocompleteItem, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, } from "~/components/ui/autocomplete"; import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; import { Button } from "~/components/ui/button"; @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandListHeading({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { return (
({ + matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }], + isLoading: false, +})); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: (atom: { label?: ReadonlyArray }) => + String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults, +})); + +const environmentIds = [EnvironmentId.make("local")]; +type ThreadSearch = ReturnType; + +function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) { + props.onResult(useThreadSearch(environmentIds, props.query)); + return null; +} + +describe("useThreadSearch", () => { + let renderer: ReactTestRenderer | null = null; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + }); + afterEach(() => { + act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("keeps the settled query and matches stable while typing, then reports the settled query", () => { + const results: ThreadSearch[] = []; + const render = (query: string) => { + const element = results.push(result)} />; + act(() => { + if (renderer) renderer.update(element); + else renderer = create(element); + }); + return results.at(-1)!; + }; + + render(""); + const typing = ["li", "lin", "link "].map(render); + expect(typing.map((result) => result.query)).toEqual(["", "", ""]); + expect(new Set(typing.map((result) => result.matches)).size).toBe(1); + expect(typing.every((result) => result.isPending)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + const settled = results.at(-1)!; + expect(settled.query).toBe("link"); + expect(settled.matches).toBe(searchResults.matches); + expect(settled.isPending).toBe(false); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 15211efab4e6..01bfa8864bb7 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -72,6 +72,8 @@ export function useThreadSearch( query: string, ): { readonly matches: ReadonlyArray; + /** The settled query `matches` came from; it only changes when results do. */ + readonly query: string; readonly isPending: boolean; } { const normalizedQuery = query.trim(); @@ -88,6 +90,7 @@ export function useThreadSearch( const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; return { matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + query: settledQuery ?? "", isPending: canSearch && (isDebouncing || result.isLoading), }; } From 0cfa113be597af3a1b2fe905a58a5b583f8089b8 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:28:08 -0700 Subject: [PATCH 49/82] chore(lint): flag :has() variants that restyle the whole page (#15274) --- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-unscoped-has.test.ts | 124 ++++++++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 179 ++++++++++++++++++ vite.config.ts | 1 + 4 files changed, 306 insertions(+) create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.ts diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f4669f588f67..855ba1e7d098 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noUnscopedHas from "./rules/no-unscoped-has.ts"; export default definePlugin({ meta: { @@ -22,5 +23,6 @@ export default definePlugin({ "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, "no-test-in-loop": noTestInLoop, + "no-unscoped-has": noUnscopedHas, }, }); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts new file mode 100644 index 000000000000..76f0e7c7159d --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -0,0 +1,124 @@ +/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-unscoped-has", { + filename: "fixture.tsx", +}); + +describe("t3code/no-unscoped-has", () => { + rule.valid( + "allows :has() on the element itself", + `const className = "[&:has([data-slot=icon])]:ps-2";`, + ); + + rule.valid( + "allows :has() anchored to an attribute", + `const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`, + ); + + rule.valid( + "allows :has() inside :not() on the element itself", + `const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`, + ); + + rule.valid( + "allows built-in has-* variants", + `const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`, + ); + + rule.valid( + "allows a sibling selector without :has()", + `const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`, + ); + + rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); + + rule.valid( + "ignores :has() inside an arbitrary value", + `const className = "before:content-[':has(foo)']";`, + ); + + rule.valid( + "allows a negated :has() on the element itself", + `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, + ); + + rule.valid( + "allows :has() on a group or peer element", + `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`, + ); + + rule.valid( + "allows a selector list whose own branch is anchored", + `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`, + ); + + rule.valid( + "ignores :has() text in quoted attribute values", + `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, + ); + + rule.valid( + "allows a selector without & on the element itself", + `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`, + ); + + rule.invalid( + "reports a sibling :has() with nothing anchoring it", + `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, + (output) => { + assert.match(output, /Anchor the :has\(\)/); + }, + ); + + rule.invalid( + "reports a descendant :has() with nothing anchoring it", + `const className = cn("p-2", "[&_:has(>input)]:gap-1");`, + ); + + rule.invalid( + "reports a universal :has() ancestor", + "const className = `flex [*:has([data-open])_&]:hidden`;", + ); + + rule.invalid( + "reports :has() whose only anchor is negated", + `const className = "[*:not(.safe):has([data-open])_&]:hidden";`, + ); + + rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); + + rule.invalid( + "reports a selector-list branch borrowing another branch's anchor", + `const className = "[.safe,:has(input)_&]:p-2";`, + ); + + rule.invalid( + "reports an unanchored branch inside :is()", + `const className = "[&_:is(.safe,:has(input))]:p-2";`, + ); + + rule.invalid( + "reports an unanchored :has() inside a named group variant", + `const className = "group-[&_:has(x)]/row:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a selector without &", + `const className = "[:has(x)_.foo]:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a group", + `const className = "group-[:has(x)_.y]:p-2";`, + ); + + rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`); + + rule.invalid( + "reports :has() anchored to the document root", + `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts new file mode 100644 index 000000000000..088505b3e0cf --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -0,0 +1,179 @@ +import { defineRule } from "@oxlint/plugins"; + +const COMBINATOR_PATTERN = /[\s>+~]/u; +// A class, id, attribute, or leading tag narrows a compound. Negated ones don't, +// so `:not(...)` is removed before this check. +const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; +const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; + +// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an +// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors. +const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; +const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u; +const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; +const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; + +// A variant's closing bracket is followed by ":" or a group/peer name like "/row:". +const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u; + +/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */ +function variantGroups(token: string): { prefix: string; group: string }[] { + const groups: { prefix: string; group: string }[] = []; + let depth = 0; + let start = -1; + for (let index = 0; index < token.length; index++) { + const char = token[index]; + if (char === "[") { + if (depth === 0) start = index + 1; + depth++; + } else if (char === "]" && depth > 0) { + depth--; + if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) { + groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) }); + } + } + } + return groups; +} + +/** Index of the ")" closing the "(" at `open`, or the selector's length. */ +function closingParen(selector: string, open: number): number { + let depth = 0; + for (let index = open; index < selector.length; index++) { + if (selector[index] === "(") depth++; + else if (selector[index] === ")" && --depth === 0) return index; + } + return selector.length; +} + +/** + * Whether nothing after `from` in the enclosing branch is joined by a combinator, + * i.e. the element ending at `from` is the subject that its wrapper matches. + */ +function isWrapperSubject(selector: string, from: number): boolean { + let depth = 0; + for (let index = from; index < selector.length; index++) { + const char = selector[index] ?? ""; + if (char === "(") depth++; + else if (char === ")") { + if (depth === 0) return true; + depth--; + } else if (depth > 0) continue; + else if (char === ",") return true; + else if (COMBINATOR_PATTERN.test(char)) { + const next = selector.slice(index).trimStart()[0]; + if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue; + return false; + } + } + return true; +} + +/** The compound selector each `:has(` in `selector` is attached to. */ +function hasCompounds(selector: string): string[] { + const compounds: string[] = []; + let index = selector.indexOf(":has("); + while (index !== -1) { + let compound = ""; + let depth = 0; + let subjectEnd = closingParen(selector, index + ":has".length) + 1; + // Other branches of a selector list are skipped until the wrapper that holds + // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/ + // :where(); the compound outside that wrapper applies only when the :has() + // is in the wrapper's subject position. + let skippingBranch = false; + for (let position = index - 1; position >= 0; position--) { + const char = selector[position] ?? ""; + if (char === ")") depth++; + else if (char === "(") { + if (depth > 0) depth--; + else { + if (!isWrapperSubject(selector, subjectEnd)) break; + skippingBranch = false; + subjectEnd = closingParen(selector, position) + 1; + } + } else if (depth === 0 && char === ",") { + skippingBranch = true; + continue; + } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break; + if (!skippingBranch) compound = char + compound; + } + compounds.push(compound); + index = selector.indexOf(":has(", index + 1); + } + return compounds; +} + +/** `compound` without any `:not(...)`, including one left open around the `:has()`. */ +function withoutNegations(compound: string): string { + let result = ""; + let index = 0; + while (index < compound.length) { + if (!compound.startsWith(":not(", index)) { + result += compound[index]; + index++; + continue; + } + let depth = 0; + for (index += ":not".length; index < compound.length; index++) { + if (compound[index] === "(") depth++; + else if (compound[index] === ")" && --depth === 0) break; + } + index++; + } + return result; +} + +/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ +function findUnscopedHasVariants(text: string): string[] { + // Selectors are ASCII case-insensitive. + if (!text.toLowerCase().includes(":has(")) return []; + const offenders: string[] = []; + for (const token of text.split(/\s+/u)) { + for (const { prefix, group } of variantGroups(token)) { + if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue; + // Tailwind writes spaces as "_", and "&" is the element carrying the class. + // A selector without "&" applies to that element, as `&:is(...)`. + const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " "); + const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self"; + const selector = ANCESTOR_PREFIX_PATTERN.test(prefix) + ? `:is(${relative.replaceAll("&", "*")}) .self` + : relative.includes("&") + ? relative.replaceAll("&", owner) + : `${owner}:is(${relative})`; + const unscoped = hasCompounds(selector).some((compound) => { + const anchor = withoutNegations(compound); + return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor); + }); + if (unscoped) offenders.push(`[${group}]`); + } + } + return offenders; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.", + }, + }, + create(context) { + const message = (variant: string) => + `Anchor the :has() in ${variant} to a class, attribute, or tag below the document root, e.g. [&+[data-x]_…] or a has-* variant. Chrome evaluates an unanchored :has() on every ancestor, so any DOM change then restyles the whole page.`; + return { + Literal(node) { + if (typeof node.value !== "string") return; + for (const variant of findUnscopedHasVariants(node.value)) { + context.report({ node, message: message(variant) }); + } + }, + TemplateElement(node) { + for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) { + context.report({ node, message: message(variant) }); + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 97729f5784b3..0eee88e2ad08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -168,6 +168,7 @@ export default defineConfig({ "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", "t3code/no-test-in-loop": "error", + "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", }, overrides: [ From 429c625a8515f9c2e4af28c488fbf32ce7789645 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 13:31:44 -0700 Subject: [PATCH 50/82] fix(web): workspace card docks beside chat when the window is narrow (#14992) Chat stays centered while the workspace card fits beside it with 32px to spare. When it does not fit, chat moves left only as far as needed, narrows only after it reaches the left padding, and the card becomes a popover below a 640px chat. The card is lighter: 280px wide, 32px rows, no section labels, no "Project folder" hint. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../BranchToolbarEnvModeSelector.tsx | 14 ++++-- apps/web/src/components/GitActionsControl.tsx | 2 +- apps/web/src/components/chat/ChatCanvas.tsx | 6 ++- .../src/components/chat/ChatCanvasContext.ts | 1 + .../src/components/chat/ThreadDetailsCard.tsx | 9 +++- .../components/chat/ThreadDetailsControl.tsx | 2 +- .../components/chat/ThreadDetailsPanel.tsx | 4 +- .../chat/ThreadRelationshipsControl.tsx | 4 +- .../components/chat/chatCanvasLayout.test.ts | 46 ++++++++++++++++++- .../src/components/chat/chatCanvasLayout.ts | 26 +++++++++-- .../chat/threadDetailsCardLayout.test.ts | 28 +++++------ .../chat/threadDetailsCardLayout.ts | 15 ++++-- .../chat/threadDetailsPanelStyles.ts | 4 +- apps/web/src/index.css | 2 +- 14 files changed, 123 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 465be828364b..3afa31e2acee 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -59,7 +59,11 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe }: BranchToolbarEnvModeSelectorProps) { const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null; const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath); - const workspaceKind = activeWorktreePath ? "Worktree" : "Project folder"; + // The panel names the workspace kind only when it is not the project folder. + const workspaceKind = activeWorktreePath ? "Worktree" : null; + const lockedWorkspaceKind = forceNewWorktree ? "Worktree" : workspaceKind; + const selectWorkspaceKind = + effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind; const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( @@ -147,9 +151,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe : (workspaceDisplayName ?? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} - {displayMode === "panel" ? ( + {displayMode === "panel" && lockedWorkspaceKind ? ( - {forceNewWorktree ? "Worktree" : workspaceKind} + {lockedWorkspaceKind} ) : null} @@ -210,9 +214,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe - {displayMode === "panel" ? ( + {displayMode === "panel" && selectWorkspaceKind ? ( - {effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind} + {selectWorkspaceKind} ) : null} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 9d9d82c6273f..e6635d892165 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1880,7 +1880,7 @@ export default function GitActionsControl({ // the output row expands below. ) : ( <> diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index bc222d07852e..504db1730489 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -11,7 +11,10 @@ import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; -/** Owns the available conversation space. Floating cards never reserve it themselves. */ +/** + * Owns the available conversation space. Cards only report where they sit; the + * canvas decides when chat moves over to make room for them. + */ export function ChatCanvas({ composerOverlayElement, children, @@ -96,6 +99,7 @@ export function ChatCanvas({ const container = { width: measurements.width, height: measurements.height }; return { container, + lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), previewKey: preview?.key ?? null, reportPreview, diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index b810b0239240..44b91f82632a 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -4,6 +4,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay export const ChatCanvasContext = createContext<{ container: { width: number; height: number }; + lane: { padding: number; minChatWidth: number }; layout: ReturnType; previewKey: string | null; reportPreview: (preview: ChatCanvasPreview) => void; diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 1886aa1ec483..dfee1453eda9 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -29,12 +29,17 @@ export function ThreadDetailsCard({ const preferredPlacement = canvas ? resolveThreadDetailsCardLayout({ container: canvas.container, - chat: canvas.layout.chat, + lane: canvas.lane, frame: null, }) : null; const placement = canvas - ? resolveThreadDetailsCardLayout({ container: canvas.container, ...canvas.layout }) + ? resolveThreadDetailsCardLayout({ + container: canvas.container, + lane: canvas.lane, + frame: canvas.layout.frame, + overlapsDetailsCard: canvas.layout.overlapsDetailsCard, + }) : null; const mode = placement ? "inline" : "popover"; const inlineOpen = useRightPanelStore((state) => diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx index 551cc54282aa..3169f8018488 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx @@ -56,7 +56,7 @@ export function ThreadDetailsControl({ className: cn( "relative inline-flex shrink-0 cursor-pointer items-center whitespace-nowrap border outline-none transition-[box-shadow,scale] [&:active:not([aria-haspopup])]:scale-[0.97] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 disabled:pointer-events-none disabled:opacity-64 aria-disabled:cursor-not-allowed aria-disabled:opacity-64 [&_svg]:shrink-0", parts[part], - multiline && "h-auto min-h-9 py-0.75 disabled:opacity-100 sm:h-auto", + multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto", tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80", tone === "destructive" && "text-destructive hover:text-destructive data-pressed:text-destructive", diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 1d02a0ada889..5b79f0c00f57 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -126,7 +126,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { headingId="thread-details-workspace-heading" title="Workspace" separated={false} - showHeading={density === "full"} + showHeading={false} > {props.versionMismatch ? (
@@ -198,7 +198,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index ac0235f85d1c..418f917a3f61 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -97,7 +97,7 @@ export function ThreadLineageRowList(props: {
- {children} -
+ ) + } + > + {showDiagram ? ( + + + Rendering diagram +
+ } + > + + + + ) : ( + children + )} + ); } @@ -3300,7 +3400,7 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use( + const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use( ChatMarkdownRendererContext, ); const codeBlock = extractCodeBlock(children); @@ -3310,6 +3410,42 @@ const CHAT_MARKDOWN_COMPONENTS = { const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + const highlightedCode = ( + {children}} + > + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > + + + + ); + if (language === "mermaid") { + return ( + expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + > + {highlightedCode} + + ); + } return ( - {children}} - > - {/* Reserve the block's height but stay hidden until Shiki has colored - it, so plain text never flashes before the highlighted version. */} - - {children} - - } - > - - - + {highlightedCode} ); }, diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx new file mode 100644 index 000000000000..30c987918845 --- /dev/null +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -0,0 +1,213 @@ +import DOMPurify from "dompurify"; +import type { Mermaid } from "mermaid"; +import { use, useState } from "react"; + +import { Button } from "../ui/button"; + +type MermaidRenderResult = + | { readonly status: "rendered"; readonly svg: string } + | { readonly status: "error"; readonly message: string; readonly retryable: boolean }; + +let mermaidModulePromise: Promise | null = null; +let renderQueue: Promise = Promise.resolve(); +let nextDiagramId = 0; +const MAX_CACHED_RENDERS = 64; +// Keyed by the full source so distinct diagrams never share an entry. Pending +// renders are never evicted, because use() must get the same promise on retry. +const renderCache = new Map>(); +const settledRenders = new WeakSet>(); + +// Mermaid is ~1MB, so it only loads once a diagram is actually shown. +function loadMermaid(): Promise { + mermaidModulePromise ??= import("mermaid") + .then((module) => module.default) + .catch((error: unknown) => { + mermaidModulePromise = null; + throw error; + }); + return mermaidModulePromise; +} + +const REMOTE_CSS_URL = /url\(\s*(?!['"]?#)[^)]*\)/gi; +let purifier: ReturnType | null = null; + +// Diagrams can come from untrusted PR descriptions, so strip anything that can +// navigate, run script, or fetch remote content on top of Mermaid's own strict +// sanitization. CSS keeps only local url(#id) references; label text is untouched. +function sanitizeMermaidSvg(svg: string): string { + if (!purifier) { + purifier = DOMPurify(window); + purifier.addHook("uponSanitizeElement", (node, data) => { + if (data.tagName === "style" && node.textContent) { + node.textContent = node.textContent.replace(REMOTE_CSS_URL, "none"); + } + }); + purifier.addHook("uponSanitizeAttribute", (_node, data) => { + if (data.attrName === "style") + data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none"); + }); + } + return purifier.sanitize(svg, { + ADD_TAGS: ["foreignObject"], + HTML_INTEGRATION_POINTS: { foreignobject: true }, + FORBID_ATTR: ["href", "xlink:href", "src", "srcset"], + FORBID_TAGS: ["a", "img", "image", "script"], + USE_PROFILES: { svg: true, svgFilters: true, html: true }, + }); +} + +// Mermaid also lazy-loads diagram chunks inside render(); losing the network +// there is worth a retry, unlike a syntax error. +const CHUNK_LOAD_ERROR = /dynamically imported module|importing a module script|failed to fetch/i; + +async function renderMermaid( + source: string, + theme: "light" | "dark", +): Promise { + const id = `mermaid-diagram-${nextDiagramId++}`; + let mermaid: Mermaid; + try { + mermaid = await loadMermaid(); + } catch { + return { status: "error", message: "Mermaid failed to load.", retryable: true }; + } + try { + // initialize() mutates global config, so renders run one at a time. + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + // HTML labels and theme CSS are mounted while Mermaid lays the diagram + // out, before sanitizing, so diagram directives must not set them. + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "htmlLabels", + "themeCSS", + ], + htmlLabels: false, + flowchart: { htmlLabels: false }, + theme: theme === "dark" ? "dark" : "default", + fontFamily: getComputedStyle(document.body).fontFamily, + }); + const { svg } = await mermaid.render(id, source); + return { status: "rendered", svg: sanitizeMermaidSvg(svg) }; + } catch (error) { + const message = error instanceof Error ? error.message : "The diagram could not be rendered."; + return { status: "error", message, retryable: CHUNK_LOAD_ERROR.test(message) }; + } finally { + document.getElementById(`d${id}`)?.remove(); + } +} + +function evictSettledRenders() { + for (const [key, result] of renderCache) { + if (renderCache.size <= MAX_CACHED_RENDERS) return; + if (settledRenders.has(result)) renderCache.delete(key); + } +} + +function mermaidRenderKey(source: string, theme: "light" | "dark") { + return `${theme}\n${source}`; +} + +function mermaidRenderPromise(source: string, theme: "light" | "dark") { + const key = mermaidRenderKey(source, theme); + const cached = renderCache.get(key); + if (cached) { + renderCache.delete(key); + renderCache.set(key, cached); + return cached; + } + const result = renderQueue.then(() => renderMermaid(source, theme)); + renderQueue = result; + void result.then(() => { + settledRenders.add(result); + evictSettledRenders(); + }); + renderCache.set(key, result); + evictSettledRenders(); + return result; +} + +let expandedImageUrl: string | null = null; + +/** + * Converts a rendered diagram into a standalone image with fixed size and background. + * A blob URL, unlike a data URL, passes the desktop connect-src policy that media + * save and copy actions fetch through. Only one diagram is expanded at a time, so + * the previous URL is released. + */ +function mermaidImageUrl(svg: string): string { + const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml"); + const element = svgDocument.documentElement; + const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number); + if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) { + element.setAttribute("width", String(viewBox[2])); + element.setAttribute("height", String(viewBox[3])); + } + element.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + element.style.maxWidth = "none"; + element.style.backgroundColor = getComputedStyle(document.body).backgroundColor; + if (expandedImageUrl) URL.revokeObjectURL(expandedImageUrl); + expandedImageUrl = URL.createObjectURL( + new Blob([new XMLSerializer().serializeToString(element)], { type: "image/svg+xml" }), + ); + return expandedImageUrl; +} + +/** Suspends until the diagram renders; failures show the parser message above the source. */ +export function MermaidDiagram({ + source, + theme, + onExpand, +}: { + source: string; + theme: "light" | "dark"; + onExpand: (imageUrl: string) => void; +}) { + const [, setAttempt] = useState(0); + const result = use(mermaidRenderPromise(source.trim(), theme)); + + if (result.status === "error") { + return ( +
+
+

Unable to render diagram: {result.message}

+ {result.retryable ? ( + + ) : null} +
+
+          {source}
+        
+
+ ); + } + + return ( +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8315c8a24db9..963f68b03ae3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + dompurify: + specifier: ^3.4.16 + version: 3.4.16 effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) @@ -716,6 +719,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + mermaid: + specifier: ^11.17.2 + version: 11.17.2 react: specifier: 19.2.6 version: 19.2.6 @@ -1169,6 +1175,9 @@ packages: react-devtools-core: optional: true + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + '@anthropic-ai/claude-agent-sdk@0.3.276': resolution: {integrity: sha512-Dic43v4uuGLhibPAArWy3RSfq3zHdn+4Oh2AI2/18a5tlbNKkvChEpazyp8c+grUJvzxkzt0jETCa1wrADbkyw==} engines: {node: '>=18.0.0'} @@ -1816,6 +1825,9 @@ packages: '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1876,6 +1888,9 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} @@ -3084,6 +3099,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3403,6 +3424,9 @@ packages: '@material/material-color-utilities@0.3.0': resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -5341,6 +5365,99 @@ packages: '@types/culori@4.0.1': resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -5365,6 +5482,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -5448,6 +5568,9 @@ packages: '@types/three@0.180.0': resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -5601,6 +5724,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/config@0.3.0': resolution: {integrity: sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ==} hasBin: true @@ -6657,6 +6783,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -6738,6 +6868,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -6794,6 +6930,162 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.3: + resolution: {integrity: sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6801,6 +7093,9 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + dbus-next@0.10.2: resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==} @@ -6873,6 +7168,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6943,6 +7241,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7237,6 +7538,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -7682,6 +7986,9 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -7913,6 +8220,9 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -8051,6 +8361,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -8061,6 +8374,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -8292,9 +8612,16 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -8312,6 +8639,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -8414,6 +8747,9 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -8500,6 +8836,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -8585,6 +8926,9 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + meshoptimizer@0.22.0: resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} @@ -9187,6 +9531,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -9317,6 +9664,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -9816,6 +10169,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9829,10 +10185,16 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -10132,6 +10494,9 @@ packages: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -10178,6 +10543,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -10358,6 +10726,10 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -10624,6 +10996,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -11145,6 +11521,11 @@ snapshots: '@types/react': 19.3.0 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.1 + '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) @@ -12029,6 +12410,8 @@ snapshots: '@blazediff/core@1.10.0': {} + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -12070,6 +12453,8 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@chevrotain/types@11.1.2': {} + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 @@ -13406,6 +13791,14 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': @@ -13683,6 +14076,10 @@ snapshots: '@material/material-color-utilities@0.3.0': {} + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) @@ -14773,7 +15170,7 @@ snapshots: '@shikijs/types@4.3.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} @@ -15314,6 +15711,123 @@ snapshots: '@types/culori@4.0.1': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -15345,6 +15859,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -15441,6 +15957,9 @@ snapshots: fflate: 0.8.3 meshoptimizer: 0.22.0 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -15533,6 +16052,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/config@0.3.0': dependencies: '@vercel/routing-utils': 6.2.0 @@ -16618,6 +17142,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -16699,6 +17225,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-dirname@0.1.0: optional: true @@ -16762,6 +17296,190 @@ snapshots: culori@4.0.2: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.3 + + cytoscape-fcose@2.2.0(cytoscape@3.34.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.3 + + cytoscape@3.34.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@7.0.0(@noble/hashes@1.8.0): dependencies: whatwg-mimetype: 5.0.0 @@ -16771,6 +17489,8 @@ snapshots: date-fns@4.4.0: {} + dayjs@1.11.23: {} + dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4): dependencies: '@nornagon/put': 0.0.8 @@ -16835,6 +17555,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} denque@2.1.0: @@ -16896,6 +17620,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -17097,6 +17825,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.52.0: {} + es6-error@4.1.1: optional: true @@ -17676,6 +18406,10 @@ snapshots: path-expression-matcher: 1.6.2 strnum: 2.4.2 + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + fastest-levenshtein@1.0.16: {} fb-dotslash@0.5.8: {} @@ -17952,6 +18686,8 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hachure-fill@0.5.2: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -18135,7 +18871,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true iconv-lite@0.7.2: dependencies: @@ -18150,6 +18885,8 @@ snapshots: immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -18159,6 +18896,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -18372,10 +19113,16 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -18398,6 +19145,10 @@ snapshots: lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} leven@3.1.0: {} @@ -18483,6 +19234,8 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -18556,6 +19309,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -18752,6 +19507,31 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.3) + cytoscape-fcose: 2.2.0(cytoscape@3.34.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.16 + es-toolkit: 1.52.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.2 + meshoptimizer@0.22.0: {} metro-babel-transformer@0.87.1: @@ -19653,6 +20433,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-expression-matcher@1.6.2: {} @@ -19769,6 +20551,13 @@ snapshots: pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -20386,6 +21175,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -20431,6 +21222,13 @@ snapshots: rope-sequence@1.3.4: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -20441,6 +21239,8 @@ snapshots: transitivePeerDependencies: - supports-color + rw@1.3.3: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -20791,6 +21591,8 @@ snapshots: strict-uri-encode@2.0.0: {} + strictdom@1.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -20845,6 +21647,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.4.0: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -21018,6 +21822,8 @@ snapshots: ts-algebra@2.0.0: {} + ts-dedent@2.3.0: {} + tslib@2.8.1: {} type-fest@0.13.1: @@ -21240,6 +22046,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@14.0.2: {} + uuid@7.0.3: {} valibot@1.2.0(typescript@7.0.2): diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 7e9e4f5db83c..3376a841e78d 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -599,6 +599,33 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)"] } + }, + { + "license": "MIT", + "name": "fastdom", + "sourceUrl": "https://github.com/wilsonpage/fastdom", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2016 Wilson Page"] + } + }, + { + "license": "MIT", + "name": "strictdom", + "sourceUrl": "https://github.com/wilsonpage/strictdom", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2013 Wilson Page"] + } + }, + { + "license": "MIT", + "name": "khroma", + "sourceUrl": "https://github.com/fabiospampinato/khroma", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019-present Fabio Spampinato, Andrew Maney"] + } } ] } From f1dcd93931b322d587a7253856f44625a6afbce0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:15:20 -0700 Subject: [PATCH 52/82] feat(web): Nightly tells you to get the beta mobile app (#15070) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/NightlyMobileBeta.tsx | 159 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 16 +- apps/web/src/routes/__root.tsx | 3 + docs/user/install.md | 8 + 4 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/NightlyMobileBeta.tsx diff --git a/apps/web/src/components/NightlyMobileBeta.tsx b/apps/web/src/components/NightlyMobileBeta.tsx new file mode 100644 index 000000000000..45e11c27e159 --- /dev/null +++ b/apps/web/src/components/NightlyMobileBeta.tsx @@ -0,0 +1,159 @@ +import { parseSemver } from "@t3tools/shared/semver"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { SmartphoneIcon } from "lucide-react"; +import { useEffect } from "react"; + +import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { AndroidIcon, AppleIcon } from "./Icons"; +import { SettingsRow } from "./settings/settingsLayout"; +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; +import { QRCodeSvg } from "./ui/qr-code"; +import { toastManager } from "./ui/toast"; + +// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are +// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta +// app. Delete this file when the store apps move to V2. See #14871. + +/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */ +export const IS_NIGHTLY_BUILD = + parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly"; + +const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd"; +const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta"; +const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code"; + +const ROW_ID = "nightly-mobile-beta"; +const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1"; + +// Guards against a second toast from a remount or a Strict Mode effect replay. +let noticeShown = false; + +function isNoticeDismissed(): boolean { + try { + return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true; + } catch { + return false; + } +} + +function dismissNotice() { + try { + setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean); + } catch { + // Storage is unavailable. The notice shows again on the next launch. + } +} + +/** + * One-time Nightly toast that points to the beta mobile app. Any close (the + * corner button, swipe, Dismiss, or the action) hides it for good on this client. + */ +export function NightlyMobileBetaNotice() { + const navigate = useNavigate(); + + useEffect(() => { + if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return; + noticeShown = true; + const toastId = toastManager.add({ + title: "Nightly needs the beta mobile app", + description: + "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.", + timeout: 0, + onClose: dismissNotice, + actionProps: { + children: "Get the beta app", + onClick: () => { + toastManager.close(toastId); + void navigate({ to: "/settings/general", hash: ROW_ID }); + }, + }, + data: { + leadingIcon: , + actionLayout: "stacked-end", + secondaryActionProps: { + children: "Dismiss", + onClick: () => toastManager.close(toastId), + }, + secondaryActionVariant: "ghost", + }, + }); + }, [navigate]); + + return null; +} + +/** QR code plus a copy button for one beta link. */ +function BetaLinkQr({ url, label }: { url: string; label: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + return ( +
+
+ +
+ +
+ ); +} + +/** Settings → General → About row with the beta app links. Render it only for Nightly. */ +export function NightlyMobileBetaRow() { + return ( + + + }> + + iPhone + + +
+
+ TestFlight beta +

Scan with your iPhone camera.

+
+ +
+
+
+ + }> + + Android + + +
+
+ Google Play beta +

+ Use the same Google account for both steps. Step 2 can take up to an hour to + work after you join the group. +

+
+
+
+

1. Join the group

+ +
+
+

2. Become a tester

+ +
+
+
+
+
+
+ } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8c5a3a935c5a..1104f875f6c5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -52,6 +52,7 @@ import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; +import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -282,6 +283,9 @@ function AboutVersionSection() { const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; + // Show the beta app links as soon as someone picks Nightly, before the update installs. + const showNightlyMobileBeta = + IS_NIGHTLY_BUILD || (hasDesktopBridge && selectedUpdateChannel === "nightly"); const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { @@ -502,6 +506,7 @@ function AboutVersionSection() { } /> ) : null} + {showNightlyMobileBeta ? : null} ); } @@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() { {isElectron || HOSTED_APP_CHANNEL ? ( ) : ( - } - description="Current version of the application." - /> + <> + } + description="Current version of the application." + /> + {IS_NIGHTLY_BUILD ? : null} + )} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b5cefd1342ea..c8963ea635ee 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; @@ -242,6 +243,8 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} + {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} diff --git a/docs/user/install.md b/docs/user/install.md index 3a9fb3296257..77b09863d746 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -98,6 +98,14 @@ Install T3 Code from the The phone connects to a server on another machine. Follow [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. +Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also +shows these links as QR codes in **Settings → General → Mobile app**. + +- **iPhone and iPad:** join the [TestFlight beta](https://testflight.apple.com/join/XgaxaRtd). +- **Android:** join the [beta group](https://groups.google.com/g/t3-code-v2-beta). With the same + Google account, open the [Google Play testing page](https://play.google.com/apps/testing/com.t3tools.t3code) + and become a tester. + If the app crashes during launch, open Settings → Diagnostics on the next launch that succeeds. It lists startup crashes from the last 7 days with the error and component stack that store crash reports leave out. Copy the report and paste it From a21702d0e58ad20263dc965d84472bb6d7eb38c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:28:20 -0700 Subject: [PATCH 53/82] test(server): ACP adapter tests no longer race the prompt settle (#15330) Takes over #14876. Co-authored-by: tris203 Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/AcpAdapterV2.test.ts | 278 +++++------------- 1 file changed, 80 insertions(+), 198 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 146924586763..b6cb0be45acf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4925,9 +4925,13 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -4950,7 +4954,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -4986,28 +4989,13 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); // The still-running subagent defers finalize after session/prompt returns. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); let terminalStatus: string | null = null; while (terminalStatus === null) { @@ -5033,10 +5021,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5072,7 +5064,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5107,28 +5098,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let subagentTurnItemId: string | null = null; let firstTerminalStatus: string | null = null; @@ -5193,10 +5169,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5230,7 +5210,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5272,28 +5251,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5352,7 +5316,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const secondPromptWireReturned = yield* Deferred.make(); const releaseSecondPromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); @@ -5361,7 +5324,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5395,7 +5363,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5456,28 +5423,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5601,7 +5553,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const baseClock = yield* Clock.Clock; const finalizationClockRead = yield* Deferred.make(); const releaseFinalizationClockRead = yield* Deferred.make(); @@ -5629,7 +5580,12 @@ describe("AcpAdapterV2", () => { let sessionUpdateHandler: | Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5668,7 +5624,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5719,18 +5674,7 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); yield* TestClock.adjust("1 second"); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); @@ -6451,14 +6395,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6492,7 +6440,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6548,28 +6495,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -6752,14 +6684,18 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const continuationRequests: Array = []; - const protocolEvents = yield* Queue.bounded(256); const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6795,7 +6731,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6875,18 +6810,13 @@ describe("AcpAdapterV2", () => { }, }); yield* Deferred.succeed(releasePromptCompletion, undefined); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7223,7 +7153,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-pending-continuation"; @@ -7231,7 +7160,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7265,7 +7199,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7321,28 +7254,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7506,14 +7424,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-root-then-child"; let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7547,7 +7469,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7603,28 +7524,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7760,12 +7666,16 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; let cancelCalled = false; let runtimeOrdinalSeen = 0; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7812,7 +7722,6 @@ describe("AcpAdapterV2", () => { runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; }, - protocolEvents, wrapCancel: (cancel) => Effect.sync(() => { cancelCalled = true; @@ -7853,28 +7762,13 @@ describe("AcpAdapterV2", () => { ); // The still-running subagent defers finalize after session/prompt returns, // so the interrupt below hits a settled turn held open for background work. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isFalse( cancelCalled, "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", @@ -13725,10 +13619,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -13762,7 +13660,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -13799,32 +13696,17 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: firstProviderTurnId, + requestRuntimeRestart: true, + }); let subagentStatus: string | null = null; let firstTerminalStatus: string | null = null; From b05adb70b00feac0883b2e364256100cfab4b6ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:55:47 -0700 Subject: [PATCH 54/82] feat(usage): fold preview model IDs into the model they belong to (#15333) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/usage/UsageService.ts | 18 +- .../server/src/usage/usageAggregation.test.ts | 41 ++- apps/server/src/usage/usageAggregation.ts | 35 +- .../components/usage/UsagePriceOverrides.tsx | 331 +++++++++++------- .../components/usage/usagePriceTable.test.ts | 59 +++- .../src/components/usage/usagePriceTable.ts | 38 ++ .../usage/usagePriceTargets.test.ts | 32 ++ .../src/components/usage/usagePriceTargets.ts | 34 +- docs/user/usage.md | 5 + packages/client-runtime/src/state/server.ts | 15 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/settings.ts | 11 + packages/shared/src/serverSettings.ts | 9 + 14 files changed, 471 insertions(+), 160 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5381645e9fcf..16a4a93b3f20 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () { environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, + usageModelAliases: true, threadPinning: true, threadPinReorder: true, threadActiveReorder: true, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index dc3121fc0024..ca56c36028bb 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -821,6 +821,7 @@ export const make = Effect.gen(function* () { ...hourlyWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + modelAliases: resolveModelAliases(settings.usageModelAliases), }); const sources: UsageSource[] = []; @@ -925,17 +926,13 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window and custom prices, so concurrent identical requests (the usage + * In-flight scans by window and usage settings, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over * the same corpus twice. */ const inflightScans = new Map>(); - const scanKey = ( - input: UsageSummaryInput, - priceOverrides: ServerSettingsValue["usagePriceOverrides"], - cursorKeychainUsageEnabled: boolean, - ): string => + const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -943,13 +940,14 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - priceOverrides, - cursorKeychainUsageEnabled, + settings.usagePriceOverrides, + settings.usageModelAliases, + settings.cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); + const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 9fea9cd96644..a27a9b101cc3 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -253,6 +253,25 @@ describe("UsageAggregator", () => { expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("folds a mapped model into its target and prices it there", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + }); + aggregator.add(record()); + aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); + const result = aggregator.finish(); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.model).toBe("claude-fable-5"); + expect(result.buckets[0]?.records).toBe(2); + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); + expect(result.buckets[0]?.unpricedRecords).toBe(0); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), @@ -263,3 +282,23 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("resolveModelAliases", () => { + it("follows chains to the final model and drops chains that enter a loop", () => { + expect( + resolveModelAliases({ + "preview[1m]": "preview", + preview: "example-model", + loop: "back", + back: "loop", + intoLoop: "loop", + self: "self", + }), + ).toEqual( + new Map([ + ["preview[1m]", "example-model"], + ["preview", "example-model"], + ]), + ); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 0652ca4b7dcf..f2923871a323 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -89,11 +89,35 @@ export interface AggregateOptions { readonly untilDay: string; readonly rates: RateTable; readonly priceOverrides?: RateTable; + /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */ + readonly modelAliases?: ReadonlyMap; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; } +/** + * Resolves user model mappings to their final target, so `a -> b` and + * `b -> c` both land on `c`. A model whose chain enters a loop is left + * unmapped. + */ +export function resolveModelAliases( + aliases: Readonly>, +): ReadonlyMap { + const resolved = new Map(); + for (const model of Object.keys(aliases)) { + const seen = new Set([model]); + let target = aliases[model]!; + while (Object.hasOwn(aliases, target) && !seen.has(target)) { + seen.add(target); + target = aliases[target]!; + } + // Stopping on a mapped model means the chain entered a loop. + if (!Object.hasOwn(aliases, target)) resolved.set(model, target); + } + return resolved; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ @@ -147,7 +171,8 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord, sourcePath?: string): boolean { + add(input: UsageRecord, sourcePath?: string): boolean { + const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -217,6 +242,14 @@ export class UsageAggregator { return true; } + /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + #mapModel(record: UsageRecord): UsageRecord { + const model = this.#options.modelAliases?.get(record.model); + if (model === undefined) return record; + const { rateModel: _rateModel, ...rest } = record; + return { ...rest, model }; + } + /** * Records arrive file by file in time order, so most land in the bucket the * previous record used. Checking that first skips building and hashing a diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index c9af1c0e1770..0b52687c81b0 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { USAGE_PRICE_FIELDS } from "./usagePriceForm"; import { isEmptyUsagePriceDraft, + usageAliasCell, usagePriceCell, usagePriceTableChanges, usagePriceTableErrors, @@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => environmentId, label: environment.entry.target.label, prices: settings?.usagePriceOverrides ?? null, + aliases: + environment.serverConfig?.environment.capabilities.usageModelAliases === true + ? (settings?.usageModelAliases ?? null) + : null, unavailable: environment.connection.phase !== "connected" ? "Offline" @@ -91,7 +96,10 @@ type SaveAttempt = { >; }; -/** Edits custom model prices. `initialModel` opens with a new row for that model. */ +/** + * Edits custom model prices and mappings. A mapped model's usage counts as its + * target model. `initialModel` opens with a new row for that model. + */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, @@ -108,12 +116,13 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - // A model that already has a custom price somewhere is edited in its existing row. + // A model that already has a custom price or mapping is edited in its existing row. const [drafts, setDrafts] = useState(() => initialModel === undefined || selected.some( (environment) => - environment.prices !== null && Object.hasOwn(environment.prices, initialModel), + Object.hasOwn(environment.prices ?? {}, initialModel) || + Object.hasOwn(environment.aliases ?? {}, initialModel), ) ? [] : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], @@ -124,7 +133,12 @@ export function UsagePriceOverrides({ const nextRowId = useRef(0); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const customModels = [ - ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))), + ...new Set( + selected.flatMap((environment) => [ + ...Object.keys(environment.prices ?? {}), + ...Object.keys(environment.aliases ?? {}), + ]), + ), ].sort(); const models = [ ...new Set([ @@ -191,19 +205,31 @@ export function UsagePriceOverrides({ ); setAttempt(null); }; + // An existing row edited back to its saved values has nothing left to save. + const matchesSaved = ( + row: UsagePriceDraft, + cell: { value: string; placeholder: string }, + value: string, + ) => + !row.isNew && + cell.placeholder !== "Mixed" && + cell.placeholder !== "Unavailable" && + value === cell.value; + const updateOrDropDraft = (draft: UsagePriceDraft) => { + if (!draft.isNew && Object.keys(draft.values).length === 0 && draft.alias === undefined) + setDrafts((previous) => previous.filter((entry) => entry.id !== draft.id)); + else updateDraft(draft); + }; const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => { const values = { ...row.values, [field]: value }; const original = usagePriceCell(selected, row.model, field); - if ( - !row.isNew && - original.placeholder !== "Mixed" && - original.placeholder !== "Unavailable" && - value === original.value - ) - delete values[field]; - if (!row.isNew && Object.keys(values).length === 0) - setDrafts((previous) => previous.filter((entry) => entry.id !== row.id)); - else updateDraft({ ...row, values }); + if (matchesSaved(row, original, value)) delete values[field]; + updateOrDropDraft({ ...row, values }); + }; + const editAlias = (row: UsagePriceDraft, value: string) => { + const { alias: _alias, ...rest } = row; + const original = usageAliasCell(selected, row.model); + updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value }); }; const save = async (retry = false) => { if (pending) return; @@ -214,7 +240,7 @@ export function UsagePriceOverrides({ (destination) => environments.find( (environment) => environment.environmentId === destination.environmentId, - ) ?? { ...destination, prices: null, unavailable: "Environment removed" }, + ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" }, ); const changes = new Map( targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]), @@ -257,11 +283,11 @@ export function UsagePriceOverrides({ if (!pending) onOpenChange(open); }} > - + Custom model prices - Prices apply to all past and future usage on the environments you select. + Prices and mappings apply to all past and future usage on the environments you select. @@ -326,9 +352,10 @@ export function UsagePriceOverrides({ ) : ( <>
- +
- + + {USAGE_PRICE_FIELDS.map((field) => ( ))} @@ -337,6 +364,7 @@ export function UsagePriceOverrides({ Model ID + Map to {USAGE_PRICE_FIELDS.map((field) => ( {field.label} ))} @@ -360,132 +388,157 @@ export function UsagePriceOverrides({ {rows.length === 0 ? ( - +

{selected.some((environment) => environment.prices === null) ? "Some environment prices are unavailable." - : "No custom prices. Add a row to override automatic pricing."} + : "No custom prices or mappings. Add a row to set one."}

) : ( - rows.map((row) => ( - - - {row.isNew ? ( - { - if (node && focusRowRef.current === row.id) { - node.focus(); - focusRowRef.current = null; + rows.map((row) => { + const aliasCell = usageAliasCell(selected, row.model); + const alias = (row.alias ?? aliasCell.value).trim(); + return ( + + + {row.isNew ? ( + { + if (node && focusRowRef.current === row.id) { + node.focus(); + focusRowRef.current = null; + } + }} + aria-label="New model ID" + aria-invalid={ + (row.model.trim() !== "" && errors.has(row.id)) || undefined + } + list="usage-price-models" + placeholder="Model ID" + autoComplete="off" + spellCheck={false} + disabled={locked} + onChange={(event) => + updateDraft({ ...row, model: event.target.value }) } - }} - aria-label="New model ID" - aria-invalid={ - (row.model.trim() !== "" && errors.has(row.id)) || undefined - } - list="usage-price-models" - placeholder="Model ID" - autoComplete="off" - spellCheck={false} - disabled={locked} - onChange={(event) => - updateDraft({ ...row, model: event.target.value }) - } - /> + /> + ) : ( + + {row.model} + + )} + {errors.has(row.id) && + (row.model.trim() !== "" || + Object.values(row.values).some((value) => value !== "")) ? ( +

+ {errors.get(row.id)} +

+ ) : null} +
+ {row.removed ? ( + + + Automatic pricing after saving + + ) : ( - - {row.model} - + + editAlias(row, event.target.value)} + /> + )} - {errors.has(row.id) && - (row.model.trim() !== "" || - Object.values(row.values).some((value) => value !== "")) ? ( -

- {errors.get(row.id)} -

- ) : null} -
- {row.removed ? ( - - - Automatic pricing after saving - - - ) : ( - USAGE_PRICE_FIELDS.map((field) => { - const cell = usagePriceCell(selected, row.model, field.key); - return ( - - - editCell(row, field.key, event.target.value) - } - /> - - ); - }) - )} - - - } - disabled={locked} - aria-label={ - row.removed - ? `Undo reset for ${row.model}` - : row.isNew - ? "Remove new model" - : `Reset price for ${row.model} to automatic` - } - onClick={() => { - if (row.isNew) - setDrafts((previous) => - previous.filter((entry) => entry.id !== row.id), - ); - else if (row.removed) { - if (Object.keys(row.values).length === 0) + {row.removed ? null : alias !== "" ? ( + + + Counted as {alias} + + + ) : ( + USAGE_PRICE_FIELDS.map((field) => { + const cell = usagePriceCell(selected, row.model, field.key); + return ( + + + editCell(row, field.key, event.target.value) + } + /> + + ); + }) + )} + + + } + disabled={locked} + aria-label={ + row.removed + ? `Undo reset for ${row.model}` + : row.isNew + ? "Remove new model" + : `Reset price for ${row.model} to automatic` + } + onClick={() => { + if (row.isNew) setDrafts((previous) => previous.filter((entry) => entry.id !== row.id), ); - else updateDraft({ ...row, removed: false }); - } else updateDraft({ ...row, removed: true }); - }} - > - {row.isNew ? : } - - - {row.removed - ? "Undo reset" - : row.isNew - ? "Remove row" - : "Reset to automatic"} - - - -
- )) + else if (row.removed) + updateOrDropDraft({ ...row, removed: false }); + else updateDraft({ ...row, removed: true }); + }} + > + {row.isNew ? ( + + ) : ( + + )} + + + {row.removed + ? "Undo reset" + : row.isNew + ? "Remove row" + : "Reset to automatic"} + + + +
+ ); + }) )}
@@ -497,8 +550,20 @@ export function UsagePriceOverrides({
Date: Sat, 3 Oct 2026 20:46:32 -0300 Subject: [PATCH 62/82] fix(server): restarts keep delegated tasks, queued threads, and stops intact (#15323) --- .../src/mcp/OrchestratorMcpService.test.ts | 102 ++- apps/server/src/mcp/OrchestratorMcpService.ts | 17 +- .../DelegatedCompletionDelivery.test.ts | 624 ++++++++++++++++++ .../src/orchestration-v2/EffectWorker.test.ts | 51 +- .../src/orchestration-v2/EffectWorker.ts | 15 +- .../src/orchestration-v2/Orchestrator.ts | 303 ++++++--- .../ProjectionRecovery.test.ts | 67 ++ .../orchestration-v2/ProjectionStore.test.ts | 55 ++ .../src/orchestration-v2/ProjectionStore.ts | 44 +- ...ProviderRuntimeRecoveryPerformance.test.ts | 510 ++++++++++++++ .../ProviderRuntimeRecoveryService.test.ts | 148 +++++ .../ProviderRuntimeRecoveryService.ts | 99 ++- .../ProviderSessionManager.ts | 22 + .../ProviderTurnStartService.ts | 5 +- .../RestartBackgroundNote.test.ts | 108 ++- .../orchestration-v2/RestartBackgroundNote.ts | 80 ++- .../RestartContinuation.test.ts | 217 ++++++ .../orchestration-v2/RestartContinuation.ts | 71 +- .../SubagentProjection.test.ts | 22 + .../orchestration-v2/SubagentProjection.ts | 3 +- .../ThreadManagementService.ts | 4 + .../testkit/ProviderReplayHarness.ts | 4 + .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/scheduling/Scheduler.test.ts | 24 + apps/server/src/scheduling/Scheduler.ts | 7 +- apps/server/src/serverRuntimeStartup.test.ts | 11 +- apps/server/src/serverRuntimeStartup.ts | 11 + docs/internals/server-updates.md | 21 +- .../shared/src/orchestrationV2ThreadError.ts | 19 +- 29 files changed, 2494 insertions(+), 172 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3f927c283ef8..eae4ffa8c0b1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -11,10 +11,12 @@ import { type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -52,7 +54,22 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const childProjection = { thread: { id: childThreadId }, - runs: [{ id: childRunId, ordinal: 1, status: "completed" }], + runs: [ + { + id: childRunId, + ordinal: 1, + status: "completed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + }, + { + id: RunId.make("run:mcp-ack-continuation"), + ordinal: 2, + status: "failed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:02:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), + }, + ], contextTransfers: [], messages: [], subagents: [], @@ -126,6 +143,8 @@ describe("OrchestratorMcpService", () => { const result = yield* service.taskStatus(scope, taskId); assert.equal(result.status, "completed"); assert.equal(result.summary, "terminal result"); + assert.equal(result.latestTerminalRunId, childRunId); + assert.equal(result.latestTerminalStatus, "completed"); const commandIds = yield* Ref.get(acknowledgementCommandIds); assert.equal(commandIds.length, 2); assert.notEqual(commandIds[0], commandIds[1]); @@ -133,6 +152,87 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("reports a restart-cut child as working until its continuation settles", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-restart-parent"); + const childThreadId = ThreadId.make("thread:mcp-restart-child"); + const taskId = NodeId.make("node:mcp-restart-task"); + const dispatched = yield* Ref.make(0); + let awaitsRestart = true; + let readFails = true; + const parentProjection = { + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: "running", + result: null, + completionDelivery: { state: "pending" }, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + thread: { id: childThreadId }, + runs: [{ id: RunId.make("run:mcp-restart-child"), ordinal: 1, status: "cancelled" }], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + delegatedTaskResultPending: () => + readFails + ? Effect.fail(new OrchestratorProjectionError({ threadId: childThreadId })) + : Effect.succeed(awaitsRestart), + dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-restart"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-restart", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const failed = yield* service.taskStatus(scope, taskId).pipe(Effect.flip); + assert.equal(failed.code, "orchestration_error"); + assert.equal(yield* Ref.get(dispatched), 0); + readFails = false; + const held = yield* service.taskStatus(scope, taskId); + assert.equal(held.status, "running"); + assert.equal(held.workState, "working"); + assert.isNull(held.summary); + // Acknowledging the cut run would suppress the real result's wake. + assert.equal(yield* Ref.get(dispatched), 0); + awaitsRestart = false; + const settled = yield* service.taskStatus(scope, taskId); + assert.equal(settled.status, "cancelled"); + assert.equal(yield* Ref.get(dispatched), 1); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 9c7323ce546a..83a7a4cbfde6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -52,6 +52,7 @@ import { type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -412,7 +413,10 @@ function latestTerminalResultRun( run.status !== "rolled_back" && (run.id === delegatedRun?.id || run.startedAt !== null), ) - .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + .reduce( + (latest, run) => (latest === undefined || runRanAfter(run, latest) ? run : latest), + undefined, + ); } function canExposeTaskRunResult(run: OrchestrationV2Run | undefined): run is OrchestrationV2Run { @@ -1055,7 +1059,16 @@ const make = Effect.gen(function* () { messages: [...childControls.messages, ...resultRecords.messages], turnItems: resultRecords.turnItems, }; - const workState = task.result !== null ? "result_available" : progress.state; + // A restart cut the child's run and its continuation has not settled, or + // the child started working again after this read. + const heldForRestart = + task.result === null && + progress.state === "result_available" && + (yield* threadManagement + .delegatedTaskResultPending(task.childThreadId) + .pipe(Effect.mapError(threadManagementFailure))); + const workState = + task.result !== null ? "result_available" : heldForRestart ? "working" : progress.state; const status = task.result !== null ? taskStatusForRun( diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index a64dc21cec91..e11338775462 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -7,6 +7,7 @@ import { MessageId, type ModelSelection, NodeId, + type OrchestrationV2Run, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -35,6 +36,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { continueRestartedRun } from "./RestartContinuation.ts"; import { OrchestrationV2EventSinkLayerLive, OrchestrationV2LayerLive, @@ -384,6 +386,98 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { }), ); + it.effect("acceptance batches a settled_only sibling once its spawning run ended", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("mailbox-mixed"); + const runId = RunId.make("mailbox-mixed-parent"); + const taskId = NodeId.make("mailbox-mixed-always"); + const siblingId = NodeId.make("mailbox-mixed-settled-only"); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + yield* seedParentWithTerminalTask({ + threadId, + runId, + projectId: ProjectId.make("mailbox-mixed-project"), + rootNodeId: NodeId.make("mailbox-mixed-root"), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const task = projection.subagents[0]!; + const spawningRun = projection.runs.find((row) => row.id === runId)!; + // A restart cut the spawning run; its continuation is the live run now. + yield* sink.write({ + events: [ + { + ...runEvent({ threadId, runId, ordinal: 1, status: "cancelled", now }), + payload: { ...spawningRun, status: "cancelled" as const, completedAt: now }, + }, + runEvent({ + threadId, + runId: RunId.make("mailbox-mixed-continuation"), + ordinal: 2, + status: "running", + now, + }), + { + id: EventId.make("mailbox-mixed-message"), + type: "message.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId, + nodeId: task.parentNodeId, + role: "user", + text: "Background task finished", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, + }, + { + id: EventId.make(`event:${siblingId}`), + type: "subagent.updated" as const, + threadId, + runId, + nodeId: siblingId, + occurredAt: now, + payload: { + ...task, + id: siblingId, + completionWake: "settled_only" as const, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("accept-mixed"), + threadId, + messageId, + }); + const accepted = yield* orchestrator.getThreadProjection(threadId); + const cohort = accepted.runs.find((row) => row.id === runId)?.delegatedCompletion; + assert.deepEqual(cohort?.delivery?.taskIds, [siblingId]); + assert.equal( + accepted.subagents.find((row) => row.id === siblingId)?.completionDelivery?.state, + "claimed", + ); + }), + ); + it.effect("builds completion text and metadata from the same live cohort", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -652,3 +746,533 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { }), ); }); + +// Runtime reconciliation writes restart cancellations under this command +// prefix, which the live terminal-run listener skips. +const reconcileCommandId = (name: string) => CommandId.make(`command:runtime-reconcile:${name}`); + +const parentProviderThreadId = (threadId: ThreadId) => + ProviderThreadId.make(`provider-thread:${String(threadId).replace("thread:", "")}`); + +const runEvent = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly status: OrchestrationV2Run["status"]; + readonly now: DateTime.Utc; + readonly providerThreadId?: ProviderThreadId; + readonly userMessageId?: MessageId; + readonly delegatedCompletion?: OrchestrationV2Run["delegatedCompletion"]; +}) => ({ + id: EventId.make(`event:${input.runId}:${input.status}`), + type: "run.updated" as const, + threadId: input.threadId, + runId: input.runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: input.now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: input.providerThreadId ?? null, + userMessageId: input.userMessageId ?? MessageId.make(`message:${input.runId}`), + rootNodeId: null, + activeAttemptId: null, + status: input.status, + requestedAt: input.now, + startedAt: input.now, + completedAt: input.status === "running" ? null : input.now, + checkpointId: null, + contextHandoffId: null, + ...(input.delegatedCompletion === undefined + ? {} + : { delegatedCompletion: input.delegatedCompletion }), + }, +}); + +/** A running app-owned task whose child thread's first run the restart cancelled. */ +const seedRestartCancelledChild = (input: { + readonly parentThreadId: ThreadId; + readonly projectId: ProjectId; + readonly parentRunId: RunId; + readonly rootNodeId: NodeId; + readonly name: string; + readonly completionWake: "always" | "settled_only"; + readonly continuationPending: boolean; + /** "completed" seeds a settled turn whose background work the restart cancelled. */ + readonly runStatus?: "cancelled" | "completed"; + readonly now: DateTime.Utc; +}) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const taskId = NodeId.make(`node:${input.name}`); + const childThreadId = ThreadId.make(`thread:${input.name}`); + const childRunId = RunId.make(`run:${input.name}:1`); + yield* eventSink.write({ + commandId: CommandId.make(`command:seed-child:${input.name}`), + events: [ + { + id: EventId.make(`event:${input.name}:thread`), + type: "thread.created", + threadId: childThreadId, + occurredAt: input.now, + payload: { + createdBy: "agent", + creationSource: "server", + id: childThreadId, + projectId: input.projectId, + title: input.name, + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.parentThreadId, + relationshipToParent: "subagent", + rootThreadId: input.parentThreadId, + }, + forkedFrom: { type: "node", nodeId: taskId }, + createdAt: input.now, + updatedAt: input.now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + id: EventId.make(`event:${input.name}:task`), + type: "subagent.updated", + threadId: input.parentThreadId, + runId: input.parentRunId, + nodeId: taskId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: input.now, + payload: { + id: taskId, + threadId: input.parentThreadId, + runId: input.parentRunId, + parentNodeId: input.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: `Run ${input.name}.`, + title: null, + model: null, + completionWake: input.completionWake, + status: "running", + result: null, + startedAt: input.now, + completedAt: null, + updatedAt: input.now, + }, + }, + ], + }); + yield* eventSink.writeWithEffects({ + commandId: reconcileCommandId(input.name), + events: [ + runEvent({ + threadId: childThreadId, + runId: childRunId, + ordinal: 1, + status: input.runStatus ?? "cancelled", + now: input.now, + }), + ], + effects: input.continuationPending + ? [ + { + id: `effect:restart-continuation:${childRunId}`, + commandId: reconcileCommandId(input.name), + threadId: childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: childRunId }, + }, + ] + : [], + }); + return { taskId, childThreadId, childRunId }; + }); + +it.layer(TestLayer)("delegated tasks across a server restart", (it) => { + it.effect("holds a restart-cancelled child for its continuation's result", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:restart-parent"); + const projectId = ProjectId.make("project:restart-parent"); + const runId = RunId.make("run:restart-parent"); + const rootNodeId = NodeId.make("node:restart-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:restart-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = ( + name: string, + continuationPending: boolean, + runStatus?: "cancelled" | "completed", + ) => + seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name, + completionWake: "always", + continuationPending, + ...(runStatus === undefined ? {} : { runStatus }), + now, + }); + const resumed = yield* child("restart-resumed-child", true); + const stopped = yield* child("restart-stopped-child", false); + // Settled with only background work left: its interim reply is not the result. + const backgrounded = yield* child("restart-backgrounded-child", true, "completed"); + // A second restart cut the first continuation before it started. + const recut = yield* child("restart-recut-child", false); + const recutContinuationId = RunId.make("run:restart-recut-child:2"); + const recutCommandId = CommandId.make("command:restart-recut-child:reconcile"); + const recutRun = runEvent({ + threadId: recut.childThreadId, + runId: recutContinuationId, + ordinal: 2, + status: "cancelled", + now, + }); + yield* eventSink.writeWithEffects({ + commandId: recutCommandId, + events: [ + { + ...recutRun, + payload: { + ...recutRun.payload, + startedAt: null, + restartContinuationOfRunId: recut.childRunId, + }, + }, + ], + effects: [ + { + id: `effect:restart-continuation:${recutContinuationId}`, + commandId: recutCommandId, + threadId: recut.childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: recutContinuationId }, + }, + ], + }); + + yield* orchestrator.recoverDelegatedTasks; + + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = (id: NodeId) => recovered.subagents.find((row) => row.id === id); + assert.equal(task(stopped.taskId)?.status, "cancelled"); + assert.equal(task(stopped.taskId)?.completionDelivery?.state, "claimed"); + assert.equal(task(resumed.taskId)?.status, "running"); + assert.isNull(task(resumed.taskId)?.result ?? null); + assert.equal(task(backgrounded.taskId)?.status, "running"); + assert.isNull(task(backgrounded.taskId)?.result ?? null); + assert.equal(task(recut.taskId)?.status, "running"); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(recut.childThreadId)); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(resumed.childThreadId)); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + // A replayed first continuation settling must not release the second one's hold. + yield* orchestrator.recoverDelegatedTask(recut.childThreadId, recut.childRunId); + const replayed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(replayed.subagents.find((row) => row.id === recut.taskId)?.status, "running"); + // A caller that read the cancelled run before the child resumed sees it as pending. + yield* eventSink.write({ + commandId: CommandId.make("command:restart-stopped-child:resumed"), + events: [ + runEvent({ + threadId: stopped.childThreadId, + runId: RunId.make("run:restart-stopped-child:2"), + ordinal: 2, + status: "running", + now, + }), + ], + }); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + assert.isFalse( + recovered.contextTransfers.some( + (transfer) => transfer.sourceThreadId === resumed.childThreadId, + ), + ); + + // The continuation's own run finishing settles the task with its result. + const afterSequence = yield* eventSink.latestSequence(); + const continuationRunId = RunId.make("run:restart-resumed-child:2"); + yield* eventSink.write({ + commandId: CommandId.make("command:restart-resumed-child:completed"), + events: [ + { + id: EventId.make("event:restart-resumed-child:result"), + type: "message.updated", + threadId: resumed.childThreadId, + runId: continuationRunId, + occurredAt: now, + payload: { + id: MessageId.make("message:restart-resumed-child:result"), + threadId: resumed.childThreadId, + runId: continuationRunId, + nodeId: null, + role: "assistant", + text: "Finished after the restart.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + runEvent({ + threadId: resumed.childThreadId, + runId: continuationRunId, + ordinal: 2, + status: "completed", + now, + }), + ], + }); + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === resumed.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const finished = yield* orchestrator.getThreadProjection(threadId); + const finishedTask = finished.subagents.find((row) => row.id === resumed.taskId); + assert.equal(finishedTask?.status, "completed"); + assert.equal(finishedTask?.result, "Finished after the restart."); + }), + ); + + it.effect("settles a restart-cancelled child whose continuation declines to start", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:restart-declined-parent"); + const projectId = ProjectId.make("project:restart-declined-parent"); + const runId = RunId.make("run:restart-declined-parent"); + const rootNodeId = NodeId.make("node:restart-declined-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:restart-declined-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "restart-declined-child", + completionWake: "always", + continuationPending: true, + now, + }); + yield* orchestrator.recoverDelegatedTasks; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.subagents.find((row) => row.id === child.taskId)?.status, "running"); + + yield* continueRestartedRun({ + threadId: child.childThreadId, + sourceRunId: child.childRunId, + }).pipe( + Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: false })), + ); + + const settled = yield* orchestrator.getThreadProjection(threadId); + const task = settled.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "cancelled"); + assert.equal(task?.completionDelivery?.state, "claimed"); + }), + ); + + // A running provider turn does not prove the provider consumed the delivery + // (Claude marks the turn running before it reads the prompt), so a cut + // delivery is offered again even when a continuation resumes that turn. + it.effect("re-offers a cut delivery even when a continuation resumes its turn", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const seed = (name: string, continuationPending: boolean) => + Effect.gen(function* () { + const threadId = ThreadId.make(`thread:${name}`); + const runId = RunId.make(`run:${name}`); + const taskId = NodeId.make(`node:${name}-task`); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + const deliveryRunId = RunId.make(`run:${name}:delivery`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`project:${name}`), + runId, + rootNodeId: NodeId.make(`node:${name}-root`), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + yield* eventSink.writeWithEffects({ + commandId: reconcileCommandId(name), + events: [ + { + id: EventId.make(`event:${name}:delivery-message`), + type: "message.updated", + threadId, + runId: deliveryRunId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId: deliveryRunId, + nodeId: null, + role: "user", + text: `Delegated task ${taskId} reached a terminal state.`, + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, + }, + runEvent({ + threadId, + runId: deliveryRunId, + ordinal: 2, + status: "cancelled", + now, + providerThreadId: parentProviderThreadId(threadId), + userMessageId: messageId, + }), + ], + effects: continuationPending + ? [ + { + id: `effect:restart-continuation:${deliveryRunId}`, + commandId: reconcileCommandId(name), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: deliveryRunId }, + }, + ] + : [], + }); + return { threadId, runId, taskId }; + }); + const cut = yield* seed("delivery-cut", false); + const resumed = yield* seed("delivery-resumed", true); + + yield* orchestrator.recoverDelegatedTasks; + + for (const seeded of [cut, resumed]) { + const projection = yield* orchestrator.getThreadProjection(seeded.threadId); + assert.deepEqual( + projection.subagents.find((row) => row.id === seeded.taskId)?.completionDelivery?.state, + "claimed", + ); + const delivery = projection.runs.find((row) => row.id === seeded.runId)?.delegatedCompletion + ?.delivery; + assert.equal(delivery?.generation, 2); + assert.deepEqual(delivery?.taskIds, [seeded.taskId]); + } + }), + ); + + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:settled-only-restart"); + const projectId = ProjectId.make("project:settled-only-restart"); + const runId = RunId.make("run:settled-only-restart"); + const rootNodeId = NodeId.make("node:settled-only-restart-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:settled-only-restart-settled"), + deliveryState: "delivered", + now, + }); + // The restart cut the turn blocked in delegate_task(wait); its + // continuation is a new, live run. + yield* eventSink.write({ + commandId: reconcileCommandId("settled-only-restart"), + events: [ + runEvent({ + threadId, + runId, + ordinal: 1, + status: "cancelled", + now, + providerThreadId: parentProviderThreadId(threadId), + userMessageId: MessageId.make(`message:seed-user:${threadId}`), + delegatedCompletion: { disposition: "open", nextGeneration: 2, delivery: null }, + }), + runEvent({ + threadId, + runId: RunId.make("run:settled-only-restart:continuation"), + ordinal: 2, + status: "running", + now, + providerThreadId: parentProviderThreadId(threadId), + }), + ], + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "settled-only-restart-child", + completionWake: "settled_only", + continuationPending: false, + now, + }); + + yield* orchestrator.recoverDelegatedTasks; + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + projection.subagents.find((row) => row.id === child.taskId)?.completionDelivery?.state, + "claimed", + ); + assert.deepEqual( + projection.runs.find((row) => row.id === runId)?.delegatedCompletion?.delivery?.taskIds, + [child.taskId], + ); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index af2667a8cf8e..5f7db32b1194 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -79,6 +79,8 @@ function restartEffect( function makeExecutorLayer(input: { readonly events: Ref.Ref>; readonly failFirstStart?: Ref.Ref; + readonly threads?: Partial; + readonly continueAfterRestart?: boolean; }) { const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); const dependencies = Layer.mergeAll( @@ -148,8 +150,10 @@ function makeExecutorLayer(input: { Layer.provide( Layer.mergeAll( dependencies, - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - ServerSettings.layerTest(), + Layer.mock(ThreadManagementService.ThreadManagementService)(input.threads ?? {}), + ServerSettings.layerTest( + input.continueAfterRestart === true ? { continueThreadsAfterServerUpdate: true } : {}, + ), ), ), ); @@ -753,3 +757,46 @@ it.effect("safely retries after replacement cleanup succeeds and start fails", ( ]); }), ); + +it.effect("settles a delegated child once its restart continuation fails for good", () => + Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const events = yield* Ref.make>([]); + const recovered = yield* Ref.make>([]); + const layer = makeExecutorLayer({ + events, + continueAfterRestart: true, + threads: { + getThreadRecords: () => Effect.fail(new Error("provider instance removed") as never), + recoverDelegatedTask: (childThreadId) => + Ref.update(recovered, (ids) => [...ids, childThreadId]), + }, + }); + const effect: EffectOutbox.OrchestrationEffectV2 = { + id: `effect:restart-continuation:${runId}`, + commandId: CommandId.make("command:restart-continuation-failure"), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: runId }, + status: "running", + attemptCount: 1, + availableAt: timestamp, + leaseOwner: "test-worker", + leaseExpiresAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + lastError: null, + }; + yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + assert.isTrue( + Exit.isFailure(yield* Effect.exit(executor.execute(effect, { willRetry: true }))), + ); + assert.deepEqual(yield* Ref.get(recovered), []); + assert.isTrue( + Exit.isFailure(yield* Effect.exit(executor.execute(effect, { willRetry: false }))), + ); + assert.deepEqual(yield* Ref.get(recovered), [threadId]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 093806059981..149494739182 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -108,13 +108,17 @@ export const executorLayer: Layer.Layer< execute: (effect, options) => { const willRetry = options?.willRetry ?? false; switch (effect.request.type) { - case "provider-runtime.continue": - return continueRestartedRun({ - threadId: effect.threadId, - sourceRunId: effect.request.sourceRunId, - }).pipe( + case "provider-runtime.continue": { + const sourceRunId = effect.request.sourceRunId; + return continueRestartedRun({ threadId: effect.threadId, sourceRunId }).pipe( Effect.provideService(ThreadManagementService.ThreadManagementService, threads), Effect.provideService(ServerSettings.ServerSettingsService, settings), + // A continuation that will never run still owes a delegated parent a result. + Effect.tapError(() => + willRetry + ? Effect.void + : threads.recoverDelegatedTask(effect.threadId, sourceRunId), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ @@ -124,6 +128,7 @@ export const executorLayer: Layer.Layer< }), ), ); + } case "provider-session.detach": return providerSessions .detach({ diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1fd4d7a7b826..112735188d1b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,6 +1,7 @@ import { latestExecutedRun, latestRootProviderFailure, + runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; @@ -77,6 +78,7 @@ import { notificationTurnItem } from "./Notification.ts"; import { isRestartNoteSource } from "./RestartBackgroundNote.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { @@ -242,6 +244,17 @@ export interface OrchestratorV2DispatchResult { export interface OrchestratorV2Shape { readonly resumeQueuedRuns: Effect.Effect; + /** Startup pass that settles delegated-task results and deliveries runs left behind. */ + readonly recoverDelegatedTasks: Effect.Effect; + /** Settles a delegated child whose restart continuation of `sourceRunId` declined or failed. */ + readonly recoverDelegatedTask: (threadId: ThreadId, sourceRunId: RunId) => Effect.Effect; + /** + * Whether a delegated child's apparent result is not final yet: a restart + * continuation is pending, or the child is working again. + */ + readonly delegatedTaskResultPending: ( + childThreadId: ThreadId, + ) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2ServerCommand, ) => Effect.Effect; @@ -338,7 +351,8 @@ function wakeWorkStartedAt( return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; } -function isNativeMaintenanceCommand(message: { +/** A native /compact or /logout turn: provider maintenance, not agent work. */ +export function isNativeMaintenanceCommand(message: { readonly text: string; readonly attachments: ReadonlyArray; readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; @@ -718,6 +732,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const idAllocator = yield* IdAllocatorV2; const projects = yield* ProjectStore.ProjectStoreV2; const projectionStore = yield* ProjectionStoreV2; + const effectOutbox = yield* EffectOutbox.EffectOutboxV2; const nextTurnItemOrdinal = ( projection: Pick & Partial>, @@ -4357,7 +4372,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || - projection.runs.some((run) => run.ordinal > source.ordinal) + // Held queued runs never started; they wait behind the continuation. + projection.runs.some( + (run) => run.id !== source.id && run.status !== "queued" && runRanAfter(run, source), + ) ) { // Preserve the current row so stale automatic deliveries receive an // accepted receipt without changing work or repeatedly retrying. @@ -6594,12 +6612,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // policy when the child terminalizes. Both writers hold this parent lock, // so a terminal task means finalize already committed the terminal row and // already made its offer decision under the pre-upgrade policy: under - // settled_only it offered iff the parent had no live run. Plan a delivery - // only when the parent has a live run now, which is precisely the case + // settled_only it offered iff the spawning run was not live. Plan a + // delivery only when that run is live now, which is precisely the case // where finalize skipped. The mailbox steers a capable active session or - // queues behind that run. When the parent is not live, finalize already - // offered and a second - // offer would wake the parent twice. (If the parent settled in between, + // queues behind that run. When that run is not live, finalize already + // offered and a second offer would wake the parent twice. (If the parent settled in between, // this skips a wake that finalize also skipped; a missed wake is cheaper // than a duplicate one, and the result is already in the projection.) const parentRun = @@ -6609,7 +6626,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const completionPlan = command.completionWake === "always" && isTerminalDelegatedTaskStatus(task.status) && - hasLiveRun(parentProjection) + parentRun !== undefined && + hasLiveRun({ runs: [parentRun] }) ? yield* planDelegatedCompletionDelivery({ parentProjection, parentRun, @@ -8494,6 +8512,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : undefined; }); + /** + * A run with a pending restart continuation has no outcome yet: restart + * reconciliation cancelled it mid-turn, or cancelled the background work a + * settled turn was waiting on. The continuation's own run settles it, or + * RestartContinuation recovers the thread when it declines to continue. + */ + const awaitsRestartContinuation = (run: OrchestrationV2Run) => + effectOutbox + .get(`effect:restart-continuation:${run.id}`) + .pipe( + Effect.map( + Option.exists((effect) => effect.status === "pending" || effect.status === "running"), + ), + ); + + /** + * A delegated child's result is held while its result run, or a run after it + * that a second restart cut before it started, still has a continuation pending. + */ + const childAwaitsRestartContinuation = ( + runs: ReadonlyArray, + resultRun: OrchestrationV2Run, + settledContinuationOf?: RunId, + ) => + Effect.forEach( + runs.filter( + (run) => + run.id !== settledContinuationOf && + (run.id === resultRun.id || runRanAfter(run, resultRun)), + ), + awaitsRestartContinuation, + ).pipe(Effect.map((pending) => pending.includes(true))); + const planDelegatedCompletionDelivery = Effect.fn( "orchestrationV2.planDelegatedCompletionDelivery", )(function* (input: { @@ -8541,9 +8592,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio offer: false, }; } + // settled_only defers to a blocking delegate_task wait, which lives only as + // long as the turn that spawned the task. Once that turn is over (a restart + // continues it as a new run), nothing else delivers the result. if ( (input.task.completionWake ?? "settled_only") === "settled_only" && - hasLiveRun(input.parentProjection) + hasLiveRun({ runs: [input.parentRun] }) ) { return { task: input.updatedTask, @@ -8694,9 +8748,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * delegated_task.wake-policy handler rewrites the same subagent row under * that lock with a full-row payload, and unserialized writers clobber each * other (stale policy on the terminal row, or a terminal row regressed to - * running). + * running). `settledContinuationOf` names the restart continuation that just + * declined or failed, so its own still-running effect does not hold the child. */ - const finalizeAppOwnedSubagent = (childThreadId: ThreadId) => + const finalizeAppOwnedSubagent = ( + childThreadId: ThreadId, + options?: { readonly settledContinuationOf?: RunId }, + ) => Effect.gen(function* () { const childControls = yield* projectionStore.getThreadRecords( childThreadId, @@ -8719,6 +8777,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (terminalStatus === null) { return; } + if ( + yield* childAwaitsRestartContinuation( + childControls.runs, + childRun, + options?.settledContinuationOf, + ) + ) { + return; + } const childResult = yield* projectionStore.getThreadRecords( childThreadId, @@ -9147,7 +9214,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); return; } - const parentIsLive = hasLiveRun(projection); + // settled_only tasks defer only to a blocking wait in their spawning run. + const parentIsLive = hasLiveRun({ runs: [parentRun] }); const pendingTaskIds = projection.thread.archivedAt === null && projection.thread.deletedAt === null ? projection.subagents @@ -9749,97 +9817,140 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.forkDetach, ); - // Recover child results from projections. Queue recovery instead holds - // unstarted runs until an explicit queue.resume command arrives. - yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - Effect.gen(function* () { - const thread = yield* projectionStore.getThreadShell(threadId); - const parentThreadId = thread?.lineage.parentThreadId; - if (parentThreadId === undefined || parentThreadId === null) return; - yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to recover terminal app-owned subagent", { - childThreadId: threadId, - cause, - }), - ), - ), - { concurrency: 8, discard: true }, - ), - ), - Effect.catchCause((cause) => - Effect.logWarning("Failed to inspect app-owned subagents during recovery", { - cause, - }), - ), - ); - yield* projectionStore.getRecoveryThreadIds("delegated-completions").pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - threadDispatch - .withLock( - threadId, - Effect.gen(function* () { - const projection = yield* projectionStore.getThreadRecords(threadId, [ - "runs", - "messages", - ]); - const terminalDeliveryRunIds = projection.runs - .filter((run) => delegatedTaskTerminalStatus(run.status) !== null) - .filter((run) => - projection.messages.some( - (message) => - message.id === run.userMessageId && - message.delegatedCompletion !== undefined, - ), - ) - .map((run) => run.id); - for (const runId of terminalDeliveryRunIds) { - yield* finalizeDelegatedCompletionDelivery(threadId, runId); - } - const refreshed = - terminalDeliveryRunIds.length === 0 - ? projection - : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { - messageRoles: ["user"], - }); - for (const run of refreshed.runs) { - if ( - run.delegatedCompletion?.delivery !== null && - run.delegatedCompletion !== undefined - ) { - yield* offerDelegatedCompletionDelivery(threadId, run.id); - } - } - }), - ) - .pipe( + // Settles child results and completion deliveries whose runs ended without + // the listener above: before this boot, or in runtime reconciliation, which + // it skips. Startup runs this after reconciliation and before the effect + // worker. Queue recovery instead holds unstarted runs until an explicit + // queue.resume command arrives. + const recoverDelegatedTasks = Effect.gen(function* () { + yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + Effect.gen(function* () { + const thread = yield* projectionStore.getThreadShell(threadId); + const parentThreadId = thread?.lineage.parentThreadId; + if (parentThreadId === undefined || parentThreadId === null) return; + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); + }).pipe( Effect.catchCause((cause) => - Effect.logWarning("Failed to recover delegated completion delivery", { - threadId, + Effect.logWarning("Failed to recover terminal app-owned subagent", { + childThreadId: threadId, cause, }), ), ), - { concurrency: 8, discard: true }, + { concurrency: 8, discard: true }, + ), ), - ), - Effect.catchCause((cause) => - Effect.logWarning("Failed to inspect delegated completion delivery during recovery", { - cause, - }), - ), - ); + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect app-owned subagents during recovery", { + cause, + }), + ), + ); + yield* projectionStore.getRecoveryThreadIds("delegated-completions").pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + threadDispatch + .withLock( + threadId, + Effect.gen(function* () { + const projection = yield* projectionStore.getThreadRecords(threadId, [ + "runs", + "messages", + ]); + const terminalDeliveryRunIds = projection.runs + .filter((run) => delegatedTaskTerminalStatus(run.status) !== null) + .filter((run) => + projection.messages.some( + (message) => + message.id === run.userMessageId && + message.delegatedCompletion !== undefined, + ), + ) + .map((run) => run.id); + for (const runId of terminalDeliveryRunIds) { + yield* finalizeDelegatedCompletionDelivery(threadId, runId); + } + const refreshed = + terminalDeliveryRunIds.length === 0 + ? projection + : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { + messageRoles: ["user"], + }); + for (const run of refreshed.runs) { + if ( + run.delegatedCompletion?.delivery !== null && + run.delegatedCompletion !== undefined + ) { + yield* offerDelegatedCompletionDelivery(threadId, run.id); + } + } + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to recover delegated completion delivery", { + threadId, + cause, + }), + ), + ), + { concurrency: 8, discard: true }, + ), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect delegated completion delivery during recovery", { + cause, + }), + ), + ); + }); + + const delegatedTaskResultPending = (childThreadId: ThreadId) => + Effect.gen(function* () { + const child = yield* projectionStore.getThreadRecords( + childThreadId, + ["runs", "messages", "subagents", "providerThreads", "providerTurns", "attempts"], + { messageRoles: ["user"] }, + ); + const progress = delegatedTaskProgress(child); + // A caller's older read saw a result; newer work since then means it is not final. + if (progress.state !== "result_available") return true; + if (progress.resultRun === undefined) return false; + return yield* childAwaitsRestartContinuation(child.runs, progress.resultRun); + }).pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), + ); + + const recoverDelegatedTask = (threadId: ThreadId, sourceRunId: RunId) => + Effect.gen(function* () { + const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); + if (parentThreadId === undefined) return; + yield* threadDispatch.withLock( + parentThreadId, + finalizeAppOwnedSubagent(threadId, { settledContinuationOf: sourceRunId }), + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to recover delegated task after restart", { + childThreadId: threadId, + cause, + }), + ), + ); return OrchestratorV2.of({ resumeQueuedRuns, + recoverDelegatedTasks, + recoverDelegatedTask, + delegatedTaskResultPending, dispatch: dispatchWithReceipt, getTimelinePage: (threadId, options) => projectionStore @@ -9933,6 +10044,7 @@ export const layer: Layer.Layer< | CommandPolicyV2 | CommandReceiptStoreV2 | ContextHandoffServiceV2 + | EffectOutbox.EffectOutboxV2 | EventSinkV2 | IdAllocatorV2 | ProjectStore.ProjectStoreV2 @@ -9956,6 +10068,9 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + recoverDelegatedTasks: Effect.void, + recoverDelegatedTask: () => Effect.void, + delegatedTaskResultPending: () => Effect.succeed(false), dispatch: (command) => Effect.fail( new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 8db18a0d5f08..617655e38fb1 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -420,6 +420,73 @@ it.effect("includes shared sessions and provider-owned background rosters in rec }).pipe(Effect.provide(TestLayer)), ); +it.effect("reads the run that owns a background roster, not a queued or resumed one", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const withRoster = Effect.fn(function* (threadId: ThreadId) { + yield* projections.apply({ + id: EventId.make(`event:${threadId}:roster`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id: ProviderThreadId.make(`provider-thread:${threadId}`), + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [ + { taskId: "background", description: "Still running", kind: "command" }, + ], + }, + }); + }); + const runIds = (threadId: ThreadId) => + projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.map((state) => state.runs.map((run) => run.id))); + // Settled with background work left, then a queued follow-up. + const settled = yield* createThread("roster-before-queue"); + const settledRun = yield* createRun(settled, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${settled}`), + }); + const queuedRun = yield* createRun(settled, "queued", { ordinal: 2, completedAt: null }); + yield* withRoster(settled); + assert.deepEqual(yield* runIds(settled), [settledRun, queuedRun]); + // A resumed queued run (ordinal 1) ended after a continuation (ordinal 2). + const resumed = yield* createThread("roster-after-resume"); + const resumedRun = yield* createRun(resumed, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${resumed}`), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"), + }); + const continuationRun = yield* createRun(resumed, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${resumed}`), + ordinal: 2, + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }); + yield* withRoster(resumed); + assert.deepEqual(yield* runIds(resumed), [resumedRun, continuationRun]); + // Without a roster, settled history is not read. + const quiet = yield* createThread("no-roster-before-queue"); + yield* createRun(quiet, "completed"); + const quietQueued = yield* createRun(quiet, "queued", { ordinal: 2, completedAt: null }); + assert.deepEqual(yield* runIds(quiet), [quietQueued]); + }).pipe(Effect.provide(TestLayer)), +); + it.effect("marks fork descendants unreadable when their source is missing or corrupt", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index c64fece9db07..319ca41ee132 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2276,8 +2276,33 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { }, }); yield* assertSummary("Plan limit reached.", "usage_limit"); + // A restart continuation ran ahead of the held queue and ended before the + // resumed failed run: the failure is still the latest executed run. + const aheadRunId = RunId.make("run:limit-shell:ran-ahead"); + yield* store.apply({ + id: EventId.make("event:limit-shell:ran-ahead"), + type: "run.created", + threadId, + runId: aheadRunId, + nodeId: NodeId.make("node:limit-shell:ran-ahead"), + driver, + providerInstanceId, + occurredAt: now, + payload: { + ...original, + id: aheadRunId, + ordinal: original.ordinal + 3, + rootNodeId: NodeId.make("node:limit-shell:ran-ahead"), + userMessageId: MessageId.make("message:limit-shell:ran-ahead"), + status: "completed", + startedAt: DateTime.subtract(now, { minutes: 10 }), + completedAt: DateTime.subtract(now, { minutes: 5 }), + }, + }); + yield* assertSummary("Plan limit reached.", "usage_limit"); const sql = yield* SqlClient.SqlClient; // The rest of this case treats the failed run as the latest run. + yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${aheadRunId}`; yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${queuedRunId}`; yield* assertSummary("Plan limit reached.", "usage_limit"); yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${cancelledRunId}`; @@ -4372,6 +4397,36 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { released && projectThreadAwarenessV2({ environmentId, project, thread: released }); assert.equal(state?.phase, "completed"); assert.equal(state?.updatedAt, DateTime.formatIso(releasedAt)); + + // A later provider must not hide this owner's roster from restart recovery. + yield* store.apply({ + id: EventId.make("event:held-completion:monitor-restarted"), + type: "provider-thread.updated", + threadId, + occurredAt: releasedAt, + payload: { ...providerThread, updatedAt: releasedAt }, + }); + const newerRunId = RunId.make("run:held-completion:new-provider"); + yield* store.apply({ + id: EventId.make("event:held-completion:new-provider"), + type: "run.created", + threadId, + runId: newerRunId, + occurredAt: releasedAt, + payload: { + ...(yield* store.getThreadProjection(threadId)).runs[0]!, + id: newerRunId, + ordinal: 2, + providerThreadId: ProviderThreadId.make("provider-thread:held-completion:new-provider"), + requestedAt: releasedAt, + startedAt: releasedAt, + completedAt: releasedAt, + }, + }); + assert.deepEqual( + (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + [runId, newerRunId], + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index f35bbfc348c8..d5a7adc53fd3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -522,8 +522,10 @@ function needsRecovery( } case "runtime": return ( - projection.runs.some((run) => - ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), + projection.runs.some( + (run) => + ["preparing", "starting", "running", "waiting"].includes(run.status) || + (run.status === "queued" && run.queueHeld !== true), ) || projection.runtimeRequests.some((request) => request.status === "pending") || projection.providerSessions.some( @@ -3307,7 +3309,10 @@ export const layer: Layer.Layer = latest.status = 'cancelled' AND json_extract(latest.payload_json, '$.startedAt') IS NULL ) - ORDER BY latest.ordinal DESC, latest.run_id DESC LIMIT 1 + -- latestExecutedRun: the run that ended last (runRanAfter). + ORDER BY latest.completed_at IS NULL DESC, latest.completed_at DESC, + latest.ordinal DESC, latest.run_id DESC + LIMIT 1 ) AND r.status = 'failed' INNER JOIN orchestration_v2_projection_turn_items item ON item.turn_item_id = ( SELECT error.turn_item_id FROM orchestration_v2_projection_turn_items error @@ -3433,7 +3438,15 @@ export const layer: Layer.Layer = ELSE 0 END ) SELECT thread_id FROM orchestration_v2_projection_runs - WHERE status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + WHERE status IN ('preparing', 'starting', 'running', 'waiting') + UNION + -- A held queue already went through recovery; rereading it on + -- every boot costs a projection read per held thread. + SELECT thread_id FROM orchestration_v2_projection_runs + WHERE status = 'queued' + AND CASE WHEN json_valid(payload_json) + THEN json_extract(payload_json, '$.queueHeld') IS NOT 1 + ELSE 1 END UNION SELECT thread_id FROM orchestration_v2_projection_runtime_requests WHERE status = 'pending' @@ -3805,6 +3818,24 @@ export const layer: Layer.Layer = WHERE latest.thread_id = ${threadId} ORDER BY latest.ordinal DESC LIMIT 1 ) + -- Retain the latest run for each provider thread with lost + -- background work, including owners used before a handoff. + OR run.run_id IN ( + SELECT ( + SELECT ended.run_id FROM orchestration_v2_projection_runs AS ended + WHERE ended.thread_id = ${threadId} + AND ended.provider_thread_id = roster.provider_thread_id + AND ended.status NOT IN ('queued', 'rolled_back') + ORDER BY ended.completed_at IS NULL DESC, ended.completed_at DESC, + ended.ordinal DESC + LIMIT 1 + ) + FROM orchestration_v2_projection_provider_threads AS roster + WHERE roster.thread_id = ${threadId} + AND CASE WHEN json_valid(roster.payload_json) + THEN json_array_length(roster.payload_json, '$.pendingBackgroundTasks') > 0 + ELSE 0 END + ) OR run.run_id IN ( SELECT item.run_id FROM orchestration_v2_projection_turn_items AS item WHERE item.thread_id = ${threadId} @@ -4899,7 +4930,10 @@ export const layer: Layer.Layer = candidate.status = 'cancelled' AND json_extract(candidate.payload_json, '$.startedAt') IS NULL ) - ORDER BY candidate.ordinal DESC, candidate.run_id DESC LIMIT 1 + -- latestExecutedRun: the run that ended last (runRanAfter). + ORDER BY candidate.completed_at IS NULL DESC, candidate.completed_at DESC, + candidate.ordinal DESC, candidate.run_id DESC + LIMIT 1 ) AND blocked.status = 'failed' WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ location === "active" diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts new file mode 100644 index 000000000000..b000351508aa --- /dev/null +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts @@ -0,0 +1,510 @@ +import { assert, it } from "@effect/vitest"; +import { + CheckpointScopeId, + CommandId, + EventId, + MessageId, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as IdAllocator from "./IdAllocator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; + +// Startup recovery cost for restart continuation. The default case is small +// enough for CI and asserts correctness; T3_BENCH_RECOVERY=1 adds the matrix. +// `it.live` keeps a real clock: each reconcile gets a fresh command id, so the +// second recover cannot hide behind command receipt dedup. + +const stores = Layer.mergeAll( + EventStore.layer, + ProjectionStore.layer, + EffectOutbox.layer, + IdAllocator.layer, +).pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const TestLayer = ProviderRuntimeRecovery.layer.pipe( + Layer.provideMerge(EventSink.layer.pipe(Layer.provideMerge(stores))), + Layer.provideMerge(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true })), +); + +const providerInstanceId = ProviderInstanceId.make("codex"); +const driver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; +const projectId = ProjectId.make("project:recovery-bench"); +const ITEMS_PER_SETTLED_THREAD = 10; +const OPEN_ITEMS_PER_ACTIVE_THREAD = 3; +const WAITING_THREADS = 3; +const SEED_BATCH = 500; + +type UnstampedEvent = OrchestrationV2DomainEvent extends infer Event + ? Event extends OrchestrationV2DomainEvent + ? Omit + : never + : never; + +interface Scenario { + readonly settled: number; + readonly active: number; +} + +interface Seeded { + readonly unheldQueuedThreads: number; + readonly waitingThreads: number; +} + +const seedScenario = Effect.fn(function* (scenario: Scenario) { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + let eventIndex = 0; + const apply = (event: UnstampedEvent) => + projections.apply({ + ...event, + id: EventId.make(`event:bench:${eventIndex++}`), + occurredAt: now, + } as OrchestrationV2DomainEvent); + + const thread = (id: ThreadId, overrides: Partial = {}) => + apply({ + type: "thread.created", + threadId: id, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId, + title: id, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + ...overrides, + }, + }); + + const run = ( + threadId: ThreadId, + ordinal: number, + status: OrchestrationV2Run["status"], + overrides: Partial = {}, + ) => { + const runId = RunId.make(`run:${threadId}:${ordinal}`); + return apply({ + type: "run.created", + threadId, + runId, + payload: { + id: runId, + threadId, + ordinal, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${runId}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: status === "queued" ? null : now, + completedAt: status === "completed" ? now : null, + checkpointId: null, + contextHandoffId: null, + ...overrides, + }, + }).pipe(Effect.as(runId)); + }; + + const commandItem = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly status: "running" | "completed"; + readonly nodeId?: NodeId; + readonly providerThreadId?: ProviderThreadId; + readonly providerTurnId?: ProviderTurnId; + }) => + apply({ + type: "turn-item.updated", + threadId: input.threadId, + runId: input.runId, + payload: { + id: TurnItemId.make(`item:${input.runId}:${input.ordinal}`), + threadId: input.threadId, + runId: input.runId, + nodeId: input.nodeId ?? null, + providerThreadId: input.providerThreadId ?? null, + providerTurnId: input.providerTurnId ?? null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal, + type: "command_execution", + status: input.status, + title: `Command ${input.ordinal}`, + input: `echo ${input.ordinal}`, + output: input.status === "completed" ? `${input.ordinal}\n` : "", + ...(input.status === "completed" ? { exitCode: 0 } : {}), + startedAt: now, + completedAt: input.status === "completed" ? now : null, + updatedAt: now, + }, + }); + + const settledThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:settled:${index}`); + yield* thread(threadId); + const runId = yield* run(threadId, 1, "completed"); + for (let ordinal = 1; ordinal <= ITEMS_PER_SETTLED_THREAD; ordinal += 1) { + yield* commandItem({ threadId, runId, ordinal, status: "completed" }); + } + // ~5% of threads carry a queued follow-up; half are already held, and an + // earlier recovery already handled those, so only unheld ones are candidates. + if (index % 20 === 0) { + const queueHeld = index % 40 === 0; + yield* run(threadId, 2, "queued", { queueHeld }); + return !queueHeld; + } + return false; + }); + + const activeThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:active:${index}`); + const providerSessionId = ProviderSessionId.make(`session:bench:${index}`); + const providerThreadId = ProviderThreadId.make(`provider-thread:bench:${index}`); + const runId = RunId.make(`run:${threadId}:1`); + const attemptId = RunAttemptId.make(`attempt:bench:${index}`); + const nodeId = NodeId.make(`node:bench:${index}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:bench:${index}`); + yield* thread(threadId, { activeProviderThreadId: providerThreadId }); + yield* apply({ + type: "provider-session.attached", + threadId, + driver, + providerInstanceId, + payload: { + id: providerSessionId, + driver, + providerInstanceId, + status: "ready", + cwd: "/workspace", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* apply({ + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + payload: { + id: providerThreadId, + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId, + providerSessionId, + nativeThreadRef: { driver, nativeId: `native:bench:${index}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [], + }, + }); + yield* run(threadId, 1, "running", { + providerThreadId, + rootNodeId: nodeId, + activeAttemptId: attemptId, + }); + yield* apply({ + type: "run-attempt.created", + threadId, + runId, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId, + providerThreadId, + providerTurnId, + reason: "initial", + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "node.updated", + threadId, + runId, + nodeId, + providerInstanceId, + payload: { + id: nodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId: nodeId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "provider-turn.updated", + threadId, + runId, + nodeId, + providerInstanceId, + payload: { + id: providerTurnId, + providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: { driver, nativeId: `native-turn:bench:${index}`, strength: "strong" }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + for (let ordinal = 1; ordinal <= OPEN_ITEMS_PER_ACTIVE_THREAD; ordinal += 1) { + yield* commandItem({ + threadId, + runId, + ordinal, + status: "running", + nodeId, + providerThreadId, + providerTurnId, + }); + } + }); + + const waitingThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:waiting:${index}`); + yield* thread(threadId); + yield* run(threadId, 1, "completed"); + const runId = yield* run(threadId, 2, "waiting"); + yield* outbox.enqueue([ + { + id: `effect:bench:checkpoint:${runId}`, + commandId: CommandId.make(`command:effect:checkpoint.capture:${runId}`), + threadId, + request: { + type: "checkpoint.capture", + runId, + scopeId: CheckpointScopeId.make(`scope:bench:${index}`), + }, + }, + ]); + }); + + let unheldQueuedThreads = 0; + for (let start = 0; start < scenario.settled; start += SEED_BATCH) { + const end = Math.min(scenario.settled, start + SEED_BATCH); + yield* sql.withTransaction( + Effect.gen(function* () { + for (let index = start; index < end; index += 1) { + if (yield* settledThread(index)) unheldQueuedThreads += 1; + } + }), + ); + } + yield* sql.withTransaction( + Effect.gen(function* () { + for (let index = 0; index < scenario.active; index += 1) yield* activeThread(index); + for (let index = 0; index < WAITING_THREADS; index += 1) yield* waitingThread(index); + }), + ); + return { unheldQueuedThreads, waitingThreads: WAITING_THREADS } satisfies Seeded; +}); + +const timed = (effect: Effect.Effect) => + Effect.gen(function* () { + const start = performance.now(); + const value = yield* effect; + return [value, performance.now() - start] as const; + }); + +const continuationEffectCount = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_effect_outbox + WHERE effect_id LIKE 'effect:restart-continuation:%' AND status = 'pending' + `; + return Number(rows[0]?.count ?? 0); +}); + +interface Measurement { + readonly settled: number; + readonly active: number; + readonly seedMs: number; + readonly candidates: number; + readonly selectMs: number; + readonly prepareMs: number; + readonly shutdownMs: number; + readonly startupAfterShutdownMs: number; + readonly crashRecoverMs: number; + readonly secondRecoverMs: number; +} + +/** A graceful restart: prepare intent, reconcile on shutdown, recover on boot. */ +const measureGraceful = Effect.fn(function* (scenario: Scenario) { + const [seeded, seedMs] = yield* timed(seedScenario(scenario)); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const [candidates, selectMs] = yield* timed(projections.getRecoveryThreadIds("runtime")); + assert.equal( + candidates.length, + scenario.active + seeded.unheldQueuedThreads + seeded.waitingThreads, + "runtime recovery candidates", + ); + const [, prepareMs] = yield* timed(recovery.prepareForShutdown); + assert.equal(yield* continuationEffectCount, scenario.active, "prepared continuations"); + const [, shutdownMs] = yield* timed(recovery.reconcile("shutdown")); + const [, startupAfterShutdownMs] = yield* timed(recovery.recover); + assert.equal(yield* continuationEffectCount, scenario.active, "continuations after boot"); + return { + seedMs, + candidates: candidates.length, + selectMs, + prepareMs, + shutdownMs, + startupAfterShutdownMs, + }; +}); + +/** A crash: no shutdown hook ran, so boot recovery records the continuations. */ +const measureCrash = Effect.fn(function* (scenario: Scenario) { + yield* seedScenario(scenario); + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const [summary, crashRecoverMs] = yield* timed(recovery.recover); + assert.equal(summary.terminalizedRuns, scenario.active, "terminalized active runs"); + assert.equal(summary.stoppedSessions, scenario.active, "stopped active sessions"); + assert.equal(yield* continuationEffectCount, scenario.active, "recorded continuations"); + const [, secondRecoverMs] = yield* timed(recovery.recover); + assert.equal(yield* continuationEffectCount, scenario.active, "second recover is idempotent"); + return { crashRecoverMs, secondRecoverMs }; +}); + +const measure = Effect.fn(function* (scenario: Scenario) { + const graceful = yield* measureGraceful(scenario).pipe(Effect.provide(Layer.fresh(TestLayer))); + const crash = yield* measureCrash(scenario).pipe(Effect.provide(Layer.fresh(TestLayer))); + return { ...scenario, ...graceful, ...crash } satisfies Measurement; +}); + +const formatTable = (rows: ReadonlyArray) => { + const ms = (value: number) => value.toFixed(1); + const perActive = (value: number, active: number) => (value / Math.max(active, 1)).toFixed(2); + const header = [ + "N", + "A", + "seed", + "cand", + "select", + "prepare", + "prep/A", + "shutdown", + "boot-after", + "crash-recover", + "recover/A", + "recover-2", + ]; + const lines = rows.map((row) => [ + String(row.settled), + String(row.active), + ms(row.seedMs), + String(row.candidates), + ms(row.selectMs), + ms(row.prepareMs), + perActive(row.prepareMs, row.active), + ms(row.shutdownMs), + ms(row.startupAfterShutdownMs), + ms(row.crashRecoverMs), + perActive(row.crashRecoverMs, row.active), + ms(row.secondRecoverMs), + ]); + const widths = header.map((cell, column) => + Math.max(cell.length, ...lines.map((line) => line[column]!.length)), + ); + return [header, ...lines] + .map((line) => line.map((cell, column) => cell.padStart(widths[column]!)).join(" ")) + .join("\n"); +}; + +it.live( + "restart recovery records one continuation per active thread and is idempotent", + () => + Effect.gen(function* () { + const row = yield* measure({ settled: 500, active: 20 }); + yield* Console.log(`provider runtime recovery (ms)\n${formatTable([row])}`); + }), + 60_000, +); + +it.live.skipIf(process.env.T3_BENCH_RECOVERY !== "1")( + "restart recovery scales with active threads, not settled history", + () => + Effect.gen(function* () { + const rows: Array = []; + for (const settled of [5_000, 50_000]) { + for (const active of [10, 100, 1_000]) { + rows.push(yield* measure({ settled, active })); + yield* Console.log(`provider runtime recovery (ms)\n${formatTable(rows)}`); + } + } + }), + 3_600_000, +); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 8df85818a761..b36239de290d 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -1272,3 +1272,151 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +it.effect("leaves delegated tasks to their own child threads after process loss", () => { + const threadId = ThreadId.make("thread_recovery_delegation"); + const settledRunId = RunId.make("run_recovery_delegation_settled"); + const runningRunId = RunId.make("run_recovery_delegation_running"); + const providerThreadId = ProviderThreadId.make("provider_thread_recovery_delegation"); + const claudeInstanceId = ProviderInstanceId.make("claude"); + const driver = ProviderDriverKind.make("claude"); + const settledTaskId = NodeId.make("node_recovery_delegation_settled"); + const runningTaskId = NodeId.make("node_recovery_delegation_running"); + const nativeSubagentId = NodeId.make("node_recovery_native_subagent"); + const delegatedTask = (id: NodeId, runId: RunId) => ({ + id, + runId, + origin: "app_owned", + childThreadId: ThreadId.make(`thread:delegated-task:${id}`), + driver, + providerInstanceId: claudeInstanceId, + status: "running", + }); + const subagentItem = (subagentId: NodeId, runId: RunId, origin: string) => ({ + id: TurnItemId.make(`turn_item:${subagentId}`), + runId, + nodeId: subagentId, + providerThreadId, + type: "subagent", + status: "running", + subagentId, + origin, + childThreadId: + origin === "app_owned" ? ThreadId.make(`thread:delegated-task:${subagentId}`) : null, + providerInstanceId: claudeInstanceId, + title: `subagent ${subagentId}`, + }); + let committedInput: Parameters[0] | null = + null; + const projection = { + thread: { id: threadId, providerInstanceId: claudeInstanceId }, + runtimeRequests: [], + providerSessions: [], + providerThreads: [ + { + id: providerThreadId, + driver, + providerInstanceId: claudeInstanceId, + ownerNodeId: null, + status: "idle", + pendingBackgroundTasks: [], + }, + ], + providerTurns: [], + runs: [ + { + id: settledRunId, + ordinal: 1, + status: "completed", + providerThreadId, + providerInstanceId: claudeInstanceId, + }, + { + id: runningRunId, + ordinal: 2, + status: "running", + providerThreadId, + providerInstanceId: claudeInstanceId, + }, + ], + attempts: [], + nodes: [ + { id: settledTaskId, runId: settledRunId, status: "running", kind: "subagent" }, + { id: runningTaskId, runId: runningRunId, status: "running", kind: "subagent" }, + { id: nativeSubagentId, runId: settledRunId, status: "running", kind: "subagent" }, + ], + subagents: [ + delegatedTask(settledTaskId, settledRunId), + delegatedTask(runningTaskId, runningRunId), + { + ...delegatedTask(nativeSubagentId, settledRunId), + origin: "provider_native", + childThreadId: null, + }, + ], + messages: [], + turnItems: [ + subagentItem(settledTaskId, settledRunId, "app_owned"), + subagentItem(runningTaskId, runningRunId, "app_owned"), + subagentItem(nativeSubagentId, settledRunId, "provider_native"), + ], + } as unknown as OrchestrationV2ThreadProjection; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(projection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committedInput = input; + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + listByCommandId: () => Effect.succeed([]), + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + + return Effect.gen(function* () { + yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); + const events = committedInput?.events ?? []; + const touched = events.flatMap((event) => + event.type === "turn-item.updated" + ? event.payload.type === "subagent" + ? [event.payload.subagentId] + : [] + : event.type === "subagent.updated" || event.type === "node.updated" + ? [event.payload.id] + : [], + ); + // The cut run itself is cancelled; only the provider-native subagent dies with it. + assert.isTrue( + events.some( + (event) => + event.type === "run.updated" && + event.payload.id === runningRunId && + event.payload.status === "cancelled", + ), + ); + assert.notInclude(touched, settledTaskId); + assert.notInclude(touched, runningTaskId); + assert.include(touched, nativeSubagentId); + // The note lists only provider-native work: delegated tasks report back on their own. + const noted = events.flatMap((event) => + event.type === "run.background-work-cancelled" + ? event.payload.restartCancelledBackgroundWork.map((work) => work.label) + : [], + ); + assert.deepEqual(noted, [`subagent ${nativeSubagentId}`]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 67ae22f1883b..98287944dee1 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -1,12 +1,15 @@ +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, type OrchestrationV2DomainEvent, type ProviderThreadId, type OrchestrationV2RestartCancelledBackgroundWork, + type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -93,6 +96,23 @@ function isNonterminalNodeStatus(status: string): boolean { return status === "pending" || status === "running" || status === "waiting"; } +/** + * A delegate_task child. Its own thread is reconciled and continued on its own + * and reports back through the app, so it is not provider background work. + */ +function isAppOwnedDelegation(task: { + readonly origin: OrchestrationV2Subagent["origin"]; + readonly childThreadId: ThreadId | null; +}): boolean { + return task.origin === "app_owned" && task.childThreadId !== null; +} + +function isAppOwnedDelegationItem( + item: OrchestrationV2ThreadProjection["turnItems"][number], +): boolean { + return item.type === "subagent" && isAppOwnedDelegation(item); +} + function providerThreadHasPendingBackgroundTasks( providerThread: OrchestrationV2ThreadProjection["providerThreads"][number], ): boolean { @@ -138,7 +158,11 @@ function providerThreadsWithOpenBackgroundWork( ): ReadonlySet { const ids = new Set(); for (const item of projection.turnItems ?? []) { - if (!isBackgroundCapableTurnItemType(item.type) || !isNonterminalTurnItemStatus(item.status)) + if ( + !isBackgroundCapableTurnItemType(item.type) || + !isNonterminalTurnItemStatus(item.status) || + isAppOwnedDelegationItem(item) + ) continue; const providerThreadId = item.providerThreadId ?? @@ -166,7 +190,7 @@ function latestStartedRun( run.providerThreadId === providerThreadId && run.status !== "queued" && run.status !== "rolled_back" && - (latest === undefined || run.ordinal > latest.ordinal) + (latest === undefined || runRanAfter(run, latest)) ? run : latest, undefined, @@ -222,6 +246,13 @@ export const make = Effect.gen(function* () { const requests = projection.runtimeRequests.filter( (request) => request.status === "pending" && request.responseCapability.type !== "message", ); + // Delegated task rows, items and nodes stay open: the child settles them. + const delegatedTaskNodeIds = new Set([ + ...(projection.subagents ?? []).filter(isAppOwnedDelegation).map((subagent) => subagent.id), + ...(projection.turnItems ?? []).flatMap((item) => + item.type === "subagent" && isAppOwnedDelegation(item) ? [item.subagentId] : [], + ), + ]); const detail = `Cancelled because the server ${trigger === "startup" ? "restarted" : "shut down"} before the provider work completed.`; const commandId = CommandId.make( `command:runtime-reconcile:${trigger}:${projection.thread.id}:${DateTime.formatIso(now)}`, @@ -333,6 +364,7 @@ export const make = Effect.gen(function* () { (candidate) => candidate.runId === run.id && !messageRequestNodeIds.has(candidate.id) && + !delegatedTaskNodeIds.has(candidate.id) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -351,6 +383,7 @@ export const make = Effect.gen(function* () { for (const subagent of projection.subagents.filter( (candidate) => candidate.runId === run.id && + !isAppOwnedDelegation(candidate) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -404,6 +437,7 @@ export const make = Effect.gen(function* () { (candidate) => candidate.runId === run.id && (candidate.nodeId === null || !messageRequestNodeIds.has(candidate.nodeId)) && + !isAppOwnedDelegationItem(candidate) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -436,7 +470,7 @@ export const make = Effect.gen(function* () { if (!isBackgroundCapableTurnItemType(item.type)) { continue; } - if (!isNonterminalTurnItemStatus(item.status)) { + if (!isNonterminalTurnItemStatus(item.status) || isAppOwnedDelegationItem(item)) { continue; } const providerInstanceId = resolveStaleBackgroundItemProviderInstanceId(item, projection); @@ -763,32 +797,41 @@ export const make = Effect.gen(function* () { if (!enabled) return; const threadIds = yield* projections.getRecoveryThreadIds("runtime"); for (const threadId of threadIds) { - const projection = yield* projections.getRuntimeRecoveryProjection(threadId); - if ( - !resolveProjectSettings(enabled, projection.thread.projectId).settings - .continueThreadsAfterServerUpdate - ) - continue; - // Shutdown reconciliation cancels the background work below, so a - // settled thread's continuation must be captured while it is still open. - const run = restartContinuationRun( - projection, - providerThreadsWithOpenBackgroundWork(projection), + yield* Effect.gen(function* () { + const projection = yield* projections.getRuntimeRecoveryProjection(threadId); + if ( + !resolveProjectSettings(enabled, projection.thread.projectId).settings + .continueThreadsAfterServerUpdate + ) + return; + // Shutdown reconciliation cancels the background work below, so a + // settled thread's continuation must be captured while it is still open. + const run = restartContinuationRun( + projection, + providerThreadsWithOpenBackgroundWork(projection), + ); + if (!run) return; + const commandId = CommandId.make(`command:restart-prepare:${run.id}`); + yield* eventSink.writeWithEffects({ + commandId, + events: [], + effects: [ + { + id: `effect:restart-continuation:${run.id}`, + commandId, + threadId, + request: { type: "provider-runtime.continue", sourceRunId: run.id }, + }, + ], + }); + }).pipe( + // One failing thread must not cost the threads after it their continuation. + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("Failed to prepare a restart continuation", { threadId, cause }), + ), ); - if (!run) continue; - const commandId = CommandId.make(`command:restart-prepare:${run.id}`); - yield* eventSink.writeWithEffects({ - commandId, - events: [], - effects: [ - { - id: `effect:restart-continuation:${run.id}`, - commandId, - threadId, - request: { type: "provider-runtime.continue", sourceRunId: run.id }, - }, - ], - }); } }).pipe( Effect.mapError((cause) => new ProviderRuntimeRecoveryError({ operation: "reconcile", cause })), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index c1039c2a0207..20e240e79c58 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -362,6 +362,25 @@ export const layerWithOptions = ( }, ); const layerScope = yield* Effect.scope; + // Ctrl+C, or a stop that signals the whole process group, reaches the + // provider CLIs with the server. They report their own background work + // stopped before shutdown captures restart continuations, so provider + // events after the signal are dropped; restart recovery owns that state. + const shutdownSignal = { received: false }; + const onShutdownSignal = () => { + shutdownSignal.received = true; + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("SIGINT", onShutdownSignal); + process.on("SIGTERM", onShutdownSignal); + }), + () => + Effect.sync(() => { + process.off("SIGINT", onShutdownSignal); + process.off("SIGTERM", onShutdownSignal); + }), + ); const sessions = yield* Ref.make(new Map()); // One retry per released entry, so a later release with the same id // cannot drop cleanup for threads only the earlier session served. @@ -1542,6 +1561,7 @@ export const layerWithOptions = ( let stoppedByProvider = false; return entry.runtime.events.pipe( Stream.runForEach((event) => { + if (shutdownSignal.received) return Effect.void; if ( event.type === "provider_session.updated" && event.providerSession.status === "stopped" @@ -1600,6 +1620,8 @@ export const layerWithOptions = ( Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { + // A provider that exits on the shutdown signal is released by shutdown. + if (shutdownSignal.received) return; const current = (yield* Ref.get(sessions)).get( sessionKey(entry.runtime.providerSessionId), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index ce2088d911d4..a3e3b122ca45 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -953,6 +953,7 @@ export const layer: Layer.Layer< run, projection.runs, projection.providerTurns, + projection.attempts, ); const restartCancelledWork = pendingRestartCancelledBackgroundWork({ runs: projection.runs, @@ -967,9 +968,7 @@ export const layer: Layer.Layer< .map((candidate) => candidate.id), ), run, - runAttemptIds: projection.attempts - .filter((candidate) => candidate.runId === run.id) - .map((candidate) => candidate.id), + attempts: projection.attempts, }); const restartNote = restartCancelledWork.length === 0 diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index f5215c1f3b6b..2f22ed583a91 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -6,12 +6,15 @@ import { RunId, type OrchestrationV2Run, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { cancelledRosterTaskWork, cancelledTurnItemWork, + isRestartNoteContinuation, pendingRestartCancelledBackgroundWork, restartCancelledBackgroundWorkNote, + restartContinuationNote, mergeRestartCancelledBackgroundWork, } from "./RestartBackgroundNote.ts"; @@ -51,7 +54,7 @@ it("keeps the note for the provider thread that lost the work across a provider providerTurns: runs.filter((source) => source.id !== target.id).map(turnFor), compactionMessageIds: new Set(), run: target, - runAttemptIds: target.activeAttemptId === null ? [] : [target.activeAttemptId], + attempts: [{ id: target.activeAttemptId!, runId: target.id }], }); // Codex never lost the work, so it neither receives nor consumes the note. @@ -62,6 +65,30 @@ it("keeps the note for the provider thread that lost the work across a provider assert.deepEqual(pending(later, [root, onCodex, backOnClaude, later]), []); }); +it("delivers a resumed queued run's note even after a higher-ordinal run", () => { + // Run 3 ran ahead of held run 2; run 2 then resumed, lost background work in + // a second restart, and its continuation was superseded by a new message. + const ranFirst = run(3, claudeThread, { + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }); + const resumed = run(2, claudeThread, { + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"), + restartCancelledBackgroundWork: lost, + }); + const next = run(4, claudeThread); + const runs = [resumed, ranFirst, next]; + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs, + providerTurns: [resumed, ranFirst].map(turnFor), + compactionMessageIds: new Set(), + run: next, + attempts: [{ id: next.activeAttemptId!, runId: next.id }], + }), + lost, + ); +}); + it("bounds the note so it cannot crowd out the turn's context", () => { const work = Array.from({ length: 25 }, (_, index) => ({ kind: "shell" as const, @@ -123,7 +150,7 @@ it("does not repeat the note when a steer restarts the run on a new attempt", () ], compactionMessageIds: new Set(), run: steered, - runAttemptIds, + attempts: runAttemptIds.map((id) => ({ id, runId: steered.id })), }); // The first attempt reached the provider with the note; its replacement must not repeat it. @@ -131,3 +158,80 @@ it("does not repeat the note when a steer restarts the run on a new attempt", () // A first attempt that never reached the provider did not deliver it. assert.deepEqual(pending([firstAttempt, RunAttemptId.make("attempt:2b")], false), lost); }); + +it("counts a prompted mid-turn or chained continuation as delivering the note", () => { + const cut = run(1, claudeThread, { status: "cancelled", restartCancelledBackgroundWork: lost }); + const cutTurn = { ...turnFor(cut), status: "cancelled" as const }; + const continuation = run(2, claudeThread, { restartContinuationOfRunId: cut.id }); + // A turn cut mid-way that lost work is prompted with the note, not resumed natively. + assert.isTrue(isRestartNoteContinuation(continuation, [cut, continuation], [cutTurn], [])); + assert.isFalse( + isRestartNoteContinuation( + continuation, + [{ ...cut, restartCancelledBackgroundWork: [] }, continuation], + [cutTurn], + [], + ), + ); + const later = run(3, claudeThread); + const pending = (delivered: boolean) => + pendingRestartCancelledBackgroundWork({ + runs: [cut, continuation, later], + providerTurns: [cutTurn, ...(delivered ? [turnFor(continuation)] : [])], + compactionMessageIds: new Set(), + run: later, + attempts: [{ id: later.activeAttemptId!, runId: later.id }], + }); + assert.deepEqual(pending(true), []); + // A continuation that never reached the provider delivered nothing. + assert.deepEqual(pending(false), lost); + + // Its own continuation carries the original note forward. + const unstarted = { ...continuation, status: "cancelled" as const }; + const chained = run(3, claudeThread, { restartContinuationOfRunId: unstarted.id }); + assert.deepEqual(restartContinuationNote(unstarted, [cut, unstarted], [cutTurn], []), { + work: lost, + settled: false, + }); + assert.isTrue(isRestartNoteContinuation(chained, [cut, unstarted, chained], [cutTurn], [])); + // Claude announces running before accepting the prompt. A second restart + // can cancel that turn without delivering anything to the provider. + for (const status of ["running", "cancelled"] as const) { + const turns = [cutTurn, { ...turnFor(unstarted), status }]; + assert.deepEqual(restartContinuationNote(unstarted, [cut, unstarted], turns, []), { + work: lost, + settled: false, + }); + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs: [cut, unstarted, later], + providerTurns: turns, + compactionMessageIds: new Set(), + run: later, + attempts: [{ id: later.activeAttemptId!, runId: later.id }], + }), + lost, + ); + } + + // A steer replaced the attempt after its completed turn delivered the note. + const steered = { ...unstarted, activeAttemptId: RunAttemptId.make("attempt:replacement") }; + const attempts = [{ id: unstarted.activeAttemptId!, runId: steered.id }]; + const turns = [ + cutTurn, + turnFor(unstarted), + { ...turnFor(steered), status: "cancelled" as const }, + ]; + assert.deepEqual(restartContinuationNote(steered, [cut, steered], turns, attempts).work, []); + assert.isFalse(isRestartNoteContinuation(chained, [cut, steered, chained], turns, attempts)); + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs: [cut, steered, later], + providerTurns: turns, + compactionMessageIds: new Set(), + run: later, + attempts, + }), + [], + ); +}); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 50ea52895e77..11b2cfe37c08 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -3,10 +3,13 @@ import type { OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, OrchestrationV2Run, + OrchestrationV2RunAttempt, OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; +type Attempt = Pick; const MAX_LABEL_LENGTH = 160; @@ -120,25 +123,69 @@ export function isRestartNoteSource( ); } -/** A restart continuation whose prompt is the note rather than a resume. */ +/** + * The note a restart continuation of `source` carries, and whether the turn it + * continues had settled (the note is then the whole prompt). A continuation cut + * before its provider turn completed may not have delivered its own note: + * adapters can announce a running turn before accepting the prompt. Carry the + * note forward until a completed turn proves delivery. + */ +export function restartContinuationNote( + source: OrchestrationV2Run, + runs: ReadonlyArray, + providerTurns: ReadonlyArray>, + attempts: ReadonlyArray, +): { readonly work: ReadonlyArray; readonly settled: boolean } { + const completedAttempts = new Set( + providerTurns.filter((turn) => turn.status === "completed").map((turn) => turn.runAttemptId), + ); + const completedRuns = new Set( + attempts.filter((attempt) => completedAttempts.has(attempt.id)).map((attempt) => attempt.runId), + ); + let current = source; + let work = current.restartCancelledBackgroundWork ?? []; + const visited = new Set([current.id]); + while ( + current.restartContinuationOfRunId !== undefined && + !completedRuns.has(current.id) && + !(current.activeAttemptId !== null && completedAttempts.has(current.activeAttemptId)) + ) { + const previous = runs.find((candidate) => candidate.id === current.restartContinuationOfRunId); + if (previous === undefined || visited.has(previous.id)) break; + visited.add(previous.id); + current = previous; + work = mergeRestartCancelledBackgroundWork(current.restartCancelledBackgroundWork ?? [], work); + } + return { work, settled: isRestartNoteSource(current, providerTurns) }; +} + +/** + * A restart continuation prompted with the note rather than a native resume. + * A turn cut mid-way that lost background work is prompted too, so the note + * is delivered with it; only a continuation without a note resumes natively. + */ export function isRestartNoteContinuation( run: Pick, runs: ReadonlyArray, providerTurns: ReadonlyArray>, + attempts: ReadonlyArray, ): boolean { const source = run.restartContinuationOfRunId === undefined ? undefined : runs.find((candidate) => candidate.id === run.restartContinuationOfRunId); - return source !== undefined && isRestartNoteSource(source, providerTurns); + return ( + source !== undefined && + restartContinuationNote(source, runs, providerTurns, attempts).work.length > 0 + ); } /** * Work cancelled by a restart that the run's provider thread has not been told * about yet. The note belongs to the provider thread that lost the work: turns * on another provider (after a switch) neither owe it nor deliver it. A later - * run on the same provider thread delivers it once its attempt reaches the - * provider, so the pending set is derived rather than cleared. Compactions and + * completed turn on the same provider thread proves delivery, so the pending + * set is derived rather than cleared. Compactions and * resumed turns carry no note, and a rolled-back run left native history, so * none of them counts as delivery. A note continuation's own prompt is the note. */ @@ -150,13 +197,14 @@ export function pendingRestartCancelledBackgroundWork(input: { OrchestrationV2Run, | "id" | "ordinal" + | "completedAt" | "userMessageId" | "providerThreadId" | "restartContinuationOfRunId" | "activeAttemptId" >; - /** Every attempt id of `run`; a steer replaces the attempt but not the run. */ - readonly runAttemptIds: ReadonlyArray; + /** Include earlier attempts: a steer replaces the attempt but not the run. */ + readonly attempts: ReadonlyArray; }): ReadonlyArray { const isCompaction = (run: typeof input.run) => input.compactionMessageIds.has(run.userMessageId); // The current run prepends the note unless it is a compaction or a @@ -170,7 +218,7 @@ export function pendingRestartCancelledBackgroundWork(input: { const providerThreadId = input.run.providerThreadId; const deliveredAttemptIds = new Set( input.providerTurns - .filter((turn) => turn.providerThreadId === providerThreadId) + .filter((turn) => turn.providerThreadId === providerThreadId && turn.status === "completed") .map((turn) => turn.runAttemptId), ); const sameThread = input.runs.filter((run) => run.providerThreadId === providerThreadId); @@ -179,23 +227,29 @@ export function pendingRestartCancelledBackgroundWork(input: { candidate.id !== input.run.id && candidate.activeAttemptId !== null && candidate.status !== "rolled_back" && - deliveredAttemptIds.has(candidate.activeAttemptId) && + (deliveredAttemptIds.has(candidate.activeAttemptId) || + input.attempts.some( + (attempt) => attempt.runId === candidate.id && deliveredAttemptIds.has(attempt.id), + )) && !isCompaction(candidate) && (candidate.restartContinuationOfRunId === undefined || - isRestartNoteContinuation(candidate, input.runs, input.providerTurns)), + isRestartNoteContinuation(candidate, input.runs, input.providerTurns, input.attempts)), ); // A steer restarts this run on a new attempt: an earlier attempt that already // reached the provider delivered the note, so the replacement must not repeat it. - const alreadyDelivered = input.runAttemptIds.some( - (attemptId) => attemptId !== input.run.activeAttemptId && deliveredAttemptIds.has(attemptId), + const alreadyDelivered = input.attempts.some( + (attempt) => + attempt.runId === input.run.id && + attempt.id !== input.run.activeAttemptId && + deliveredAttemptIds.has(attempt.id), ); if (alreadyDelivered) return []; return sameThread .filter( (source) => - source.ordinal < input.run.ordinal && + runRanAfter(input.run, source) && (source.restartCancelledBackgroundWork?.length ?? 0) > 0 && - !prompted.some((later) => later.ordinal > source.ordinal), + !prompted.some((later) => runRanAfter(later, source)), ) .reduce>( (work, source) => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index c282fa7a26d6..e7cd4b9476a7 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -11,6 +12,7 @@ import { ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; @@ -201,6 +203,7 @@ it.effect("prompts a settled thread's continuation with the note of its lost wor Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); return Effect.succeed({} as never); @@ -246,6 +249,7 @@ it.effect("does not continue a failed run that lost background work", () => Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); return Effect.succeed({} as never); @@ -328,6 +332,7 @@ it.effect("does not duplicate delivery and yields to newer user work or opt-out" >[0][] = []; const threads = Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); if (command.type === "message.dispatch") @@ -499,6 +504,7 @@ it.effect("does not cancel or resume a run that completes while shutdown intent ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: () => Effect.sync(() => { dispatched = true; @@ -511,3 +517,214 @@ it.effect("does not cancel or resume a run that completes while shutdown intent assert.isFalse(dispatched); }), ); + +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + Effect.gen(function* () { + const texts: Array = []; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, + dispatch: (command) => { + if (command.type === "message.dispatch") texts.push(command.text); + return Effect.succeed({} as never); + }, + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + return texts; + }); + +const cutMidTurn = (extra: Record = {}) => { + const base = makeProjection(); + return { + ...base, + runs: [ + { + ...base.runs[0]!, + status: "cancelled", + userMessageId: MessageId.make("message:user"), + ...extra, + }, + ], + providerTurns: [{ ...base.providerTurns[0]!, status: "cancelled" }], + } as unknown as OrchestrationV2ThreadProjection; +}; + +const queuedFollowUp = { + id: RunId.make("run:queued"), + ordinal: 2, + providerInstanceId: instanceId, + providerThreadId, + status: "queued", + queueHeld: true, +}; + +it.effect("continues a cut run past queued follow-ups, which stay held", () => + Effect.gen(function* () { + const live = makeProjection(); + assert.equal( + restartContinuationRun({ + ...live, + runs: [...live.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection)?.id, + runId, + ); + const cut = cutMidTurn(); + const texts = yield* continuationTexts({ + ...cut, + runs: [...cut.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, ["Continue where you left off."]); + }), +); + +it.effect("continues a resumed queued run that ran after an earlier continuation", () => + Effect.gen(function* () { + // The continuation (ordinal 3) ran ahead of the held queue, then the user + // resumed the queued run (ordinal 1 here) and the server restarted again. + const finishedContinuation = { + id: RunId.make("run:earlier-continuation"), + ordinal: 3, + providerInstanceId: instanceId, + providerThreadId, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }; + const live = makeProjection(); + assert.equal( + restartContinuationRun({ + ...live, + runs: [...live.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection)?.id, + runId, + ); + const cut = cutMidTurn({ completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z") }); + const texts = yield* continuationTexts({ + ...cut, + runs: [...cut.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, ["Continue where you left off."]); + }), +); + +it.effect("does not continue a run the user asked to stop before the restart", () => + Effect.gen(function* () { + const texts = yield* continuationTexts({ + ...cutMidTurn(), + turnItems: [{ id: "turn-item:interrupt", runId, type: "run_interrupt_request" }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, []); + }), +); + +it.effect("does not continue a cut /compact or /logout turn", () => + Effect.gen(function* () { + for (const text of ["/compact", " /LOGOUT "]) { + const texts = yield* continuationTexts({ + ...cutMidTurn(), + messages: [{ id: MessageId.make("message:user"), text, attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, [], text); + } + const texts = yield* continuationTexts({ + ...cutMidTurn(), + messages: [{ id: MessageId.make("message:user"), text: "/compact later", attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.lengthOf(texts, 1); + }), +); + +it.effect("tells a turn cut mid-way about the background work it lost", () => + Effect.gen(function* () { + const texts = yield* continuationTexts( + cutMidTurn({ + restartCancelledBackgroundWork: [{ kind: "subagent", label: "Background reviewer" }], + }), + ); + assert.lengthOf(texts, 1); + assert.include(texts[0]!, "Background reviewer"); + assert.isTrue(texts[0]!.endsWith("Continue where you left off.")); + }), +); + +it.effect( + "carries the note forward when its continuation was cut before reaching the provider", + () => + Effect.gen(function* () { + const base = makeProjection(); + const original = { + ...base.runs[0]!, + id: RunId.make("run:original"), + status: "completed", + activeAttemptId: RunAttemptId.make("attempt:original"), + restartCancelledBackgroundWork: [{ kind: "shell", label: "sleep 25 && echo DONE" }], + }; + const projection = { + ...base, + runs: [ + original, + { + ...base.runs[0]!, + ordinal: 2, + status: "cancelled", + restartContinuationOfRunId: original.id, + }, + ], + // The original turn settled; the continuation's attempt never started one. + providerTurns: [ + { + ...base.providerTurns[0]!, + runAttemptId: original.activeAttemptId, + status: "completed", + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const texts = yield* continuationTexts(projection); + assert.lengthOf(texts, 1); + assert.include(texts[0]!, "sleep 25 && echo DONE"); + assert.notInclude(texts[0]!, "Continue where"); + }), +); + +it.effect("prepares later threads' continuations when one thread fails", () => + Effect.gen(function* () { + const brokenThreadId = ThreadId.make("thread:broken"); + const writes: Parameters[0][] = []; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([brokenThreadId, threadId]), + getRuntimeRecoveryProjection: (id) => + id === brokenThreadId + ? Effect.fail( + new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id }), + ) + : Effect.succeed(makeProjection()), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeWithEffects: (input) => + Effect.sync(() => { + writes.push(input); + return []; + }), + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({}), + Layer.mock(EffectOutbox.EffectOutboxV2)({}), + ), + ), + ); + yield* recovery.prepareForShutdown; + assert.deepEqual( + writes.map((write) => write.effects[0]?.request), + [{ type: "provider-runtime.continue", sourceRunId: runId }], + ); + }), +); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 216b830de41e..f91d1a1e5f73 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -1,3 +1,4 @@ +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, @@ -11,12 +12,16 @@ import * as Effect from "effect/Effect"; import type { ProjectionRuntimeRecoveryState } from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { isNativeMaintenanceCommand } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isRestartNoteSource, restartCancelledBackgroundWorkNote, + restartContinuationNote, } from "./RestartBackgroundNote.ts"; +const CONTINUE_PROMPT = "Continue where you left off."; + /** * The run a restart continuation resumes, if any: an unfinished root run, or a * settled one whose own provider thread lost background work in the restart @@ -30,8 +35,12 @@ export function restartContinuationRun( cancelledWorkProviderThreadIds: ReadonlySet = new Set(), ): OrchestrationV2Run | undefined { if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; + // Queued runs never started; recovery holds them behind the cut run. const run = projection.runs.reduce( - (latest, candidate) => (!latest || candidate.ordinal > latest.ordinal ? candidate : latest), + (latest, candidate) => + candidate.status !== "queued" && (!latest || runRanAfter(candidate, latest)) + ? candidate + : latest, undefined, ); if (!run) return; @@ -98,7 +107,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const messageId = MessageId.make(`message:restart-continuation:${input.sourceRunId}`); const projection = yield* threads.getThreadRecords( input.threadId, - ["messages", "runs", "providerTurns"], + ["messages", "runs", "providerTurns", "attempts"], { messageIds: [messageId] }, ); if ( @@ -114,17 +123,54 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const noteSource = source !== undefined && isRestartNoteSource(source, projection.providerTurns); if (!source || (source.status !== "cancelled" && !noteSource)) return; - // A user submission after reconciliation takes precedence over an automatic prompt. - if (projection.runs.some((run) => run.ordinal > source.ordinal)) return; + // A user submission after reconciliation takes precedence over an automatic + // prompt. Queued runs never started and stay held behind this one. + if ( + projection.runs.some( + (run) => run.id !== source.id && run.status !== "queued" && runRanAfter(run, source), + ) + ) + return; if (projection.thread.providerInstanceId !== source.providerInstanceId) return; + const sourceRecords = yield* threads.getThreadRecords( + input.threadId, + ["messages", "turnItems"], + { + messageIds: [source.userMessageId], + turnItemRunIds: [source.id], + turnItemTypes: ["run_interrupt_request"], + }, + ); + // The user asked this run to stop before the restart cut it. + if ( + sourceRecords.turnItems.some( + (item) => item.runId === source.id && item.type === "run_interrupt_request", + ) + ) + return; + const sourceMessage = sourceRecords.messages.find( + (message) => message.id === source.userMessageId, + ); + if (sourceMessage !== undefined && isNativeMaintenanceCommand(sourceMessage)) return; + const note = restartContinuationNote( + source, + projection.runs, + projection.providerTurns, + projection.attempts, + ); + const noteText = + note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: noteSource - ? restartCancelledBackgroundWorkNote(source.restartCancelledBackgroundWork ?? []) - : "Continue where you left off.", + text: + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, @@ -133,4 +179,15 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta restartContinuationOfRunId: input.sourceRunId, }); }, + // A delegated child this declined to continue still owes its parent a + // result. Once a continuation run exists this is a no-op; that run settles it. + (effect, input) => + effect.pipe( + Effect.andThen( + Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* threads.recoverDelegatedTask(input.threadId, input.sourceRunId); + }), + ), + ), ); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index dac4345c92dc..23d180102cd3 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -166,6 +166,28 @@ function taskFixture() { return { projection: { ...projection, runs: [run] }, run }; } +it("reports the run that ended last, not the highest ordinal", () => { + const { projection, run } = taskFixture(); + // A restart continuation (ordinal 4) ran ahead of held queued runs 2 and 3. + const ended = (ordinal: number, completedAt: string): OrchestrationV2Run => ({ + ...run, + id: RunId.make(`run:${ordinal}`), + ordinal, + completedAt: DateTime.makeUnsafe(completedAt), + }); + const progress = delegatedTaskProgress({ + ...projection, + runs: [ + { ...run, status: "cancelled" }, + ended(4, "2026-07-24T10:00:00.000Z"), + ended(2, "2026-07-24T10:05:00.000Z"), + ended(3, "2026-07-24T10:10:00.000Z"), + ], + }); + assert.equal(progress.state, "result_available"); + assert.equal(progress.resultRun?.ordinal, 3); +}); + it("waits for nested work and retains the report across monitor acknowledgements", () => { const { projection, run } = taskFixture(); assert.equal( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index 8b3abf78fe85..fa912dfda0ba 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -16,6 +16,7 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as DateTime from "effect/DateTime"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; @@ -239,7 +240,7 @@ export function delegatedTaskProgress(projection: { projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); const resultRun = workRuns .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) - .toSorted((a, b) => b.ordinal - a.ordinal)[0]; + .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; return { state: active || resultRun === undefined diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index b489f6890967..3bb397728060 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -313,6 +313,8 @@ export interface ThreadManagementServiceShape { input: ThreadManagementInterruptInput, ) => Effect.Effect; readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; + readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; + readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; readonly streamStoredEvents: Orchestrator.OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: Orchestrator.OrchestratorV2["Service"]["streamStoredEventsFrom"]; readonly streamDomainEvents: Orchestrator.OrchestratorV2["Service"]["streamDomainEvents"]; @@ -736,6 +738,8 @@ const make = Effect.gen(function* () { waitForThread, interruptThread, getThreadEventSequence: orchestrator.getThreadEventSequence, + recoverDelegatedTask: orchestrator.recoverDelegatedTask, + delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, streamDomainEvents: orchestrator.streamDomainEvents, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index f4a1201f1f3e..053825ad6962 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -433,6 +433,8 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( dispatch: orchestrator.dispatch, getThreadRecords: orchestrator.getThreadRecords, getThreadProjection: orchestrator.getThreadProjection, + recoverDelegatedTask: orchestrator.recoverDelegatedTask, + delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, }); }), ).pipe(Layer.provide(orchestratorProvided)); @@ -497,6 +499,8 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( Orchestrator.OrchestratorV2, Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + // As in serverRuntimeStartup: after runtime recovery, before the worker. + yield* orchestrator.recoverDelegatedTasks; yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); return orchestrator; }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index a90226f67c7e..003403fc5c09 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -206,6 +206,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( waitForThread: unused, interruptThread: unused, getThreadEventSequence: unused, + recoverDelegatedTask: unused, + delegatedTaskResultPending: unused, streamStoredEvents: Stream.empty, streamStoredEventsFrom: () => Stream.empty, streamDomainEvents: options.domainEvents ?? Stream.empty, diff --git a/apps/server/src/scheduling/Scheduler.test.ts b/apps/server/src/scheduling/Scheduler.test.ts index dd3ac3948a12..fbbf15ced75e 100644 --- a/apps/server/src/scheduling/Scheduler.test.ts +++ b/apps/server/src/scheduling/Scheduler.test.ts @@ -6,6 +6,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerActivation from "../serverActivation.ts"; import * as Scheduler from "./Scheduler.ts"; it.effect("keeps other sources running after a source defects", () => @@ -84,3 +85,26 @@ it.effect("unregisters closed sources and interrupts their in-flight work", () = assert.equal(yield* Ref.get(runs), 1); }).pipe(Effect.provide(Scheduler.layer)), ); + +it.effect("holds due work and the clock until startup recovery activates the server", () => + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const runs = yield* Ref.make(0); + const ran = yield* Deferred.make(); + yield* Effect.gen(function* () { + const scheduler = yield* Scheduler.Scheduler; + yield* scheduler.register( + "due", + Ref.update(runs, (n) => n + 1).pipe(Effect.andThen(Deferred.succeed(ran, undefined))), + ); + yield* TestClock.adjust("10 seconds"); + assert.equal(yield* Ref.get(runs), 0); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(ran); + }).pipe( + Effect.provide(Scheduler.layer), + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + ); + assert.isAtLeast(yield* Ref.get(runs), 1); + }), +); diff --git a/apps/server/src/scheduling/Scheduler.ts b/apps/server/src/scheduling/Scheduler.ts index 957ba11e2ea0..e2214094cffb 100644 --- a/apps/server/src/scheduling/Scheduler.ts +++ b/apps/server/src/scheduling/Scheduler.ts @@ -6,6 +6,8 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; +import { forkParked } from "../serverActivation.ts"; + /** Sources load due work from their durable state; registration owns its execution lifetime. */ export class Scheduler extends Context.Service< Scheduler, @@ -47,14 +49,15 @@ const make = Effect.gen(function* () { return next; }), ); - yield* run; + // Due work waits for startup recovery, which would cancel runs it started. + yield* forkParked(run); }); const tick = Ref.get(sources).pipe( Effect.flatMap((current) => Effect.forEach(current.values(), (run) => run, { discard: true })), ); // One clock for all due-work sources. A slow source cannot block another // source or overlap itself, and no extra missed-tick backlog is queued. - yield* Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forever, Effect.forkScoped); + yield* forkParked(Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forever)); return Scheduler.of({ register }); }); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index a89d010fab75..9aa6ebfaa45f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -31,11 +31,20 @@ it.effect("starts without scanning or rebuilding projection history", () => const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ importLegacyShells: record("import"), recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), + recoverDelegatedTasks: record("delegated"), startEffectWorker: record("worker"), autoBootstrap: record("bootstrap").pipe(Effect.as({ projectId: "project-1" })), }); - assert.deepEqual(yield* Ref.get(calls), ["import", "recover", "worker", "bootstrap"]); + // Delegated recovery reads the runs recovery terminalizes, and settles them + // before the worker runs restart continuations that would otherwise race it. + assert.deepEqual(yield* Ref.get(calls), [ + "import", + "recover", + "delegated", + "worker", + "bootstrap", + ]); assert.deepEqual(result, { recovery: { closedRequests: 2 }, bootstrap: { projectId: "project-1" }, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e3f92058e70c..c06ff1159d87 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,6 +32,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; @@ -386,21 +387,26 @@ export function runOrderedV2StartupPhases< Bootstrap, ImportError, RecoveryError, + DelegationError, WorkerError, BootstrapError, ImportContext, RecoveryContext, + DelegationContext, WorkerContext, BootstrapContext, >(input: { readonly importLegacyShells: Effect.Effect; readonly recover: Effect.Effect; + /** Settles delegated tasks whose runs recovery just terminalized. */ + readonly recoverDelegatedTasks: Effect.Effect; readonly startEffectWorker: Effect.Effect; readonly autoBootstrap: Effect.Effect; }) { return Effect.gen(function* () { yield* input.importLegacyShells; const recovery = yield* input.recover; + yield* input.recoverDelegatedTasks; yield* input.startEffectWorker; const bootstrap = yield* input.autoBootstrap; return { recovery, bootstrap } as const; @@ -413,6 +419,7 @@ const make = (options?: StartupOptions) => const keybindings = yield* Keybindings.Keybindings; const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const orchestrator = yield* Orchestrator.OrchestratorV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; @@ -509,6 +516,10 @@ const make = (options?: StartupOptions) => ), ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + recoverDelegatedTasks: runStartupPhase( + "orchestration-v2.delegated-tasks.recover", + orchestrator.recoverDelegatedTasks, + ), startEffectWorker: runStartupPhase( "orchestration-v2.effect-worker.start", startEffectWorkerWithRelay({ diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index e40675a14162..34fa5aa47b09 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -61,7 +61,9 @@ stopped backends and replays the failure for the same token. Restart continuation is an environment-owned preference, off by default. The [v2 recovery service](../../apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts) requires matching durable run, provider thread, session, and native resume identity. -Ordinary queued work, finished runs, and background-only work do not qualify. +Queued runs never started, so recovery holds them and continues the run they wait +behind. A finished run qualifies only when the restart cancelled its background work; +its continuation tells the provider what will not report back. Recovery retires effects tied to the lost process and records continuation intent in the durable outbox. That intent survives another restart before provider startup. @@ -71,7 +73,16 @@ closing providers, then reconciles after ingestion has stopped so a late complet cannot be overwritten by a stale cancellation. The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts) -rechecks the preference, archive state, provider selection, and newer user work before -dispatching. Stable command and message IDs prevent duplicate submissions after an -outbox retry. Codex resumes without adding provider prompt text; other adapters receive -the continuation message through their normal turn path. +rechecks the preference, archive state, provider selection, newer user work, a stop +the user requested, and maintenance turns such as `/compact` before dispatching. Stable +command and message IDs prevent duplicate submissions after an outbox retry. Codex +resumes without adding provider prompt text unless the turn lost background work; +other adapters receive the continuation message through their normal turn path. + +Delegated tasks (`delegate_task` child threads) are reconciled as their own threads, +never as the parent's background work. The orchestrator settles child results and +completion deliveries in a startup pass after reconciliation, because the terminal-run +listener ignores reconciliation's cancellations. A cancelled child whose restart +continuation is still pending in the outbox is not a result yet; the continuation's run +settles it, or the handler settles it when it declines to continue. Schedulers wait for +activation so they cannot start runs that reconciliation would then cancel. diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index 2abec628c615..ca8dcceaa912 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -55,11 +55,28 @@ export function latestExecutedRun( for (const run of runs) { if (run.status === "queued") continue; if (run.status === "cancelled" && run.startedAt === null) continue; - if (latest === null || run.ordinal > latest.ordinal) latest = run; + if (latest === null || runRanAfter(run, latest)) latest = run; } return latest; } +/** + * Whether started `run` ran after `other`. Ordinals follow submission, but a + * run can start ahead of a held queue (a restart continuation, or a message + * sent while the queue is held), so a queued run resumed later can have a + * lower ordinal than one that already ended. An unfinished run is the latest. + */ +export function runRanAfter( + run: Pick, + other: Pick, +): boolean { + const end = (candidate: typeof run) => + !candidate.completedAt + ? Number.POSITIVE_INFINITY + : DateTime.toEpochMillis(candidate.completedAt); + return end(run) === end(other) ? run.ordinal > other.ordinal : end(run) > end(other); +} + /** * The latest run that actually started, when it stopped because the * subscription limit was reached. Queued messages after that run must stay From a976f8c74ca4eab6e16bb47b5fa446154b743371 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 20:47:41 -0300 Subject: [PATCH 63/82] fix(web): sending past the resume banner compacts first (#15290) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 31 ++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e02eb60c9008..8c4c0bb1a456 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8733,7 +8733,18 @@ export default function ChatView(props: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); - const shouldQueueBehindActiveRun = phase === "running" && dispatchMode === "queue"; + // Sending past the resume banner compacts first so the turn does not resend the stale + // history. The message queues behind the /compact run; steering into it is rejected, + // and a held queue would strand it. + const compactBeforeSend = + resumeCompactionBannerItem !== null && + !compactDisabled && + !hasHeldQueuedRuns && + multipleModelSelections === null && + messageTextForSend.toLowerCase() !== "/compact"; + const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode; + const shouldQueueBehindActiveRun = + compactBeforeSend || (phase === "running" && dispatchMode === "queue"); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -9299,6 +9310,22 @@ export default function ChatView(props: ChatViewProps) { failure = turnAttachmentsResult; } + if (failure === null && compactBeforeSend) { + const compactResult = await startThreadTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, + modelSelection: ctxSelectedModelSelection, + runtimeMode, + interactionMode: sendInteractionMode, + }, + }); + if (compactResult._tag === "Failure") { + failure = compactResult; + } + } + let backgroundDraftOpened = false; let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { @@ -9376,7 +9403,7 @@ export default function ChatView(props: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode: sendInteractionMode, - dispatchMode, + dispatchMode: turnDispatchMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, From 44bd4c9cafa61a415f76fbb261f321a158660577 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 20:57:59 -0300 Subject: [PATCH 64/82] fix(codex): resume archived native sessions (#15389) --- .../Adapters/CodexAdapterV2.test.ts | 155 ++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 44 +++-- 2 files changed, 185 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 3145e33dabd1..85748fa056e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2597,6 +2597,161 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each( + ( + [ + ["recovers archived-session errors", "session saved-thread is archived", 0, ""], + [ + "recovers unarchive hints", + "Run `codex unarchive saved-thread` to unarchive it first.", + 0, + "", + ], + ["preserves missing-thread errors", "thread not found", 3, "thread not found"], + ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"], + ["preserves authentication errors", "authentication failed", 3, "authentication failed"], + [ + "preserves archived-workspace errors", + "workspace is archived", + 3, + "workspace is archived", + ], + [ + "preserves unrelated archive-path errors", + "permission denied reading archived_sessions/saved-thread", + 3, + "permission denied reading archived_sessions/saved-thread", + ], + [ + "propagates unarchive missing-thread errors", + "session saved-thread is archived", + 4, + "thread not found", + ], + [ + "propagates unarchive archived errors", + "session saved-thread is archived", + 4, + "session saved-thread is archived", + ], + [ + "propagates retry missing-thread errors", + "session saved-thread is archived", + 5, + "thread not found", + ], + [ + "does not retry archived errors twice", + "session saved-thread is archived", + 5, + "session saved-thread is archived", + ], + ] as const + ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), + )("$name", ({ name, resumeError, failAt, finalError }) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "saved-thread"; + const params = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const entries: Array = [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "resume archived thread", + frame: { id: 3, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "resume error", + frame: { id: 3, error: { code: -32600, message: resumeError } }, + }, + ]; + if (failAt !== 3) { + entries.push( + { + type: "expect_outbound", + label: "unarchive same thread", + frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "unarchive result", + frame: + failAt === 4 + ? { id: 4, error: { code: -32600, message: finalError } } + : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, + }, + ); + } + if (failAt === 0 || failAt === 5) { + entries.push( + { + type: "expect_outbound", + label: "retry identical resume", + frame: { id: 5, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "retry result", + frame: + failAt === 5 + ? { id: 5, error: { code: -32600, message: finalError } } + : { + id: 5, + result: { + thread: { + id: nativeThreadId, + updatedAt: 1782622450, + turns: [{ type: "unknown-history-item" }], + }, + }, + }, + }, + ); + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: name, entries }), + ); + const resume = harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + if (failAt !== 0) { + const error = yield* Effect.flip(resume); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.nestedPropertyVal(error, "cause.errorMessage", finalError); + assert.nestedPropertyVal( + error, + "cause.method", + failAt === 4 ? "thread/unarchive" : "thread/resume", + ); + assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); + return; + } + const resumed = yield* resume; + assert.equal(resumed.id, harness.providerThread.id); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.deepEqual( + resumed.nativeConversationHeadRef, + harness.providerThread.nativeConversationHeadRef, + ); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("continues an interrupted native thread with empty input and reasoning summaries", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 19c3486f5af7..8da6ffe4357d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -5429,23 +5429,39 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi resumeThread: (threadInput) => Effect.gen(function* () { const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread); - + // excludeTurns is not in the generated request schema yet. + const resume = client.raw.request("thread/resume", { + threadId: nativeThreadId, + excludeTurns: true, + ...codexThreadRuntimeParams({ + threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, + ...(threadInput.modelSelection === undefined + ? {} + : { modelSelection: threadInput.modelSelection }), + ...(threadInput.runtimePolicy === undefined + ? {} + : { runtimePolicy: threadInput.runtimePolicy }), + }), + }); const response = yield* ensureInitialized.pipe( Effect.andThen( - // excludeTurns is not in the generated request schema yet. - client.raw.request("thread/resume", { - threadId: nativeThreadId, - excludeTurns: true, - ...codexThreadRuntimeParams({ - threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, - ...(threadInput.modelSelection === undefined - ? {} - : { modelSelection: threadInput.modelSelection }), - ...(threadInput.runtimePolicy === undefined - ? {} - : { runtimePolicy: threadInput.runtimePolicy }), + resume.pipe( + Effect.catchTags({ + CodexAppServerRequestError: (cause) => { + if ( + !/\bsession \S+ is archived\b|\bcodex unarchive\b/i.test( + cause.errorMessage, + ) + ) { + return Effect.fail(cause); + } + // Keep the session's history without decoding the unarchive response. + return client.raw + .request("thread/unarchive", { threadId: nativeThreadId }) + .pipe(Effect.andThen(resume)); + }, }), - }), + ), ), Effect.flatMap(decodeCodexResumeMetadata), ); From f2cc80a7a44ee05c1f39442779f2b719e31faeb5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 3 Oct 2026 20:10:58 -0400 Subject: [PATCH 65/82] feat(web): morph composer and panel action icons (#14924) Co-authored-by: maria-rcks --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.tsx | 12 +- .../src/components/DiffFilePathCopyButton.tsx | 9 +- apps/web/src/components/DiffPanel.tsx | 27 ++--- apps/web/src/components/MorphIcon.tsx | 6 + apps/web/src/components/RightPanelTabs.tsx | 13 +-- .../src/components/chat/ChangedFilesTree.tsx | 29 ++--- .../chat/ComposerPrimaryActions.tsx | 15 +-- .../components/chat/ExpandedImageDialog.tsx | 11 +- .../src/components/chat/MessageCopyButton.tsx | 9 +- .../src/components/chat/MessagesTimeline.tsx | 11 +- .../components/chat/PanelLayoutControls.tsx | 16 +-- .../components/chat/ThreadDetailsPrRows.tsx | 9 +- .../src/components/chat/WorktreeSetupCard.tsx | 6 +- .../components/device/DeviceControlsRail.tsx | 6 +- .../web/src/components/device/DeviceSetup.tsx | 8 +- .../web/src/components/diffs/DiffFileTree.tsx | 12 +- .../files/AttachmentFilePreview.tsx | 28 ++--- .../src/components/files/FileBrowserPanel.tsx | 12 +- .../src/components/files/FilePreviewPanel.tsx | 15 ++- .../components/onboarding/WelcomeWizard.tsx | 5 +- .../pullRequest/PullRequestCodeTab.tsx | 19 ++-- .../PullRequestReviewAnnotation.tsx | 19 ++-- .../settings/DiagnosticsSettings.tsx | 14 +-- .../settings/ProviderInstanceCard.tsx | 13 +-- .../settings/ResourceTelemetryDiagnostics.tsx | 7 +- .../components/settings/ThemeEditorPanel.tsx | 13 +-- apps/web/src/components/ui/sidebar.tsx | 5 +- apps/web/src/components/ui/toast.tsx | 48 +++----- apps/web/src/routes/__root.tsx | 6 +- pnpm-lock.yaml | 103 +++++++++++++++++- 31 files changed, 278 insertions(+), 230 deletions(-) create mode 100644 apps/web/src/components/MorphIcon.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 12bd0c28929f..ffccf206f910 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,8 +48,10 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "lucide": "^0.564.0", "lucide-react": "^0.564.0", "mermaid": "^11.17.2", + "morphicons": "^1.7.1", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d31b189a9c53..5358e253af4f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -5,10 +5,8 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - CheckIcon, ChevronRightIcon, CodeIcon, - CopyIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -16,10 +14,8 @@ import { InfoIcon, LightbulbIcon, MailIcon, - Maximize2Icon, MessageSquareIcon, MessageSquareWarningIcon, - Minimize2Icon, OctagonAlertIcon, PlayIcon, PresentationIcon, @@ -29,6 +25,7 @@ import { WrapTextIcon, type LucideIcon, } from "lucide-react"; +import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import type { AssetResource, EnvironmentId, @@ -123,6 +120,7 @@ import { import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { ContextChip } from "./ContextChip"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; @@ -849,7 +847,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { /> } > - {expanded ? : } + {expandLabel} @@ -869,7 +867,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { /> } > - {copied ? : } + {copyLabel} @@ -1063,7 +1061,7 @@ function MarkdownCodeBlock({ /> } > - {copied ? : } + {copyLabel} diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx index 5e78949336cf..ab42f126189e 100644 --- a/apps/web/src/components/DiffFilePathCopyButton.tsx +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -1,12 +1,14 @@ -import { CheckIcon, CopyIcon } from "lucide-react"; +import { Check, Copy } from "lucide"; 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 }) { @@ -30,7 +32,10 @@ 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 32f5bfabcbaf..50df89109e05 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,15 +12,13 @@ import { ArrowRightIcon, CheckIcon, ChevronDownIcon, - ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, Columns2Icon, FolderTreeIcon, PilcrowIcon, Rows3Icon, TextWrapIcon, } from "lucide-react"; +import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -58,6 +56,7 @@ import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/Ann import { DiffFileTree } from "./diffs/DiffFileTree"; 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 { @@ -873,11 +872,10 @@ export default function DiffPanel({ /> } > - {allDiffFilesCollapsed ? ( - - ) : ( - - )} + {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"} @@ -1133,15 +1131,10 @@ export default function DiffPanel({ /> } > - {collapsed ? ( - - ) : ( - - )} + {collapsed ? "Expand diff" : "Collapse diff"} diff --git a/apps/web/src/components/MorphIcon.tsx b/apps/web/src/components/MorphIcon.tsx new file mode 100644 index 000000000000..053921dde42f --- /dev/null +++ b/apps/web/src/components/MorphIcon.tsx @@ -0,0 +1,6 @@ +import { MorphIcon as BaseMorphIcon, type MorphIconProps } from "morphicons/react"; + +/** Renders `lucide` icon data and morphs between shapes when `icon` changes. */ +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 d3c3d5c31ded..914028f7c8cd 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -23,9 +23,8 @@ import { Globe2, Plus, TerminalSquare, - Volume2, - VolumeOff, } from "lucide-react"; +import { Volume2, VolumeOff } from "lucide"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, @@ -44,6 +43,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; 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"; @@ -1149,11 +1149,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { .catch(() => undefined); }} > - {audio === "muted" ? ( - - ) : ( - - )} + } /> diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 39d17cd3de9b..a1cb0dac252a 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,20 +6,15 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../../lib/turnDiffTree"; -import { - ChevronsDownUpIcon, - ChevronsUpDownIcon, - ChevronRightIcon, - FileDiffIcon, - FolderIcon, - FolderClosedIcon, -} from "lucide-react"; +import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { MorphIcon } from "~/components/MorphIcon"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -86,11 +81,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { /> } > - {allDirectoriesExpanded ? ( - - ) : ( - - )} + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} @@ -195,11 +189,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { isExpanded && "rotate-90", )} /> - {isExpanded ? ( - - ) : ( - - )} + {node.name} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 76156ffda49c..81bce817fe4a 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,12 +1,7 @@ import { memo, type MouseEventHandler, type PointerEventHandler } from "react"; -import { - CheckIcon, - ChevronDownIcon, - ChevronLeftIcon, - CornerUpRightIcon, - ListPlusIcon, - PlayIcon, -} from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react"; +import { CornerUpRight, ListPlus } from "lucide"; +import { MorphIcon } from "~/components/MorphIcon"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "../../shortcutModifierState"; @@ -315,10 +310,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
@@ -2438,7 +2439,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract {row.label} - + }) { const ctx = use(TimelineRowCtx); - const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return ( diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 41cd19fb4013..ff6f00e28272 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,10 +1,6 @@ -import { - Maximize2Icon, - Minimize2Icon, - PanelBottomIcon, - PanelRightIcon, - SquareMenuIcon, -} from "lucide-react"; +import { PanelBottomIcon, PanelRightIcon, SquareMenuIcon } from "lucide-react"; +import { Maximize2, Minimize2 } from "lucide"; +import { MorphIcon } from "~/components/MorphIcon"; import { memo, type ReactElement } from "react"; import type { ThreadPanelPresentation } from "../../rightPanelLayout"; @@ -163,11 +159,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl variant="ghost" size="sm" > - {maximized ? ( - - ) : ( - - )} + } /> diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index a18110444e1f..fdc2f0faf5b0 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -5,7 +5,7 @@ import { threadPullRequestKeyOf, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; -import { MinusIcon, PlusIcon } from "lucide-react"; +import { Minus, Plus } from "lucide"; import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; @@ -13,6 +13,7 @@ import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/ope import { useProjects } from "~/state/entities"; import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; +import { MorphIcon } from "~/components/MorphIcon"; import { linkedPullRequestSnapshotStatus, prStatusIndicator } from "../ThreadStatusIndicators"; import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; @@ -96,11 +97,7 @@ export function ThreadDetailsPrRows({ tone="muted" className="w-full active:scale-100" > - {expanded ? ( - - ) : ( - - )} + {expanded ? "Show less" : `Show ${rest.length} more`} diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 0d0a0bc1b415..91980be7d863 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -6,8 +6,6 @@ import { import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { CheckIcon, - ChevronDownIcon, - ChevronRightIcon, CircleAlertIcon, CircleIcon, LaptopIcon, @@ -15,9 +13,11 @@ import { TerminalIcon, XIcon, } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide"; import { useEffect, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Spinner } from "~/components/ui/spinner"; import { MiddleTruncate } from "../ui/middle-truncate"; import { observeVisibleAnimation } from "~/lib/visibleAnimation"; @@ -409,7 +409,7 @@ export function WorktreeSetupCard({ aria-expanded={detailsOpen} onClick={() => setDetailsOpen((open) => !open)} > - {detailsOpen ? : } + Details {showTerminal ? ( diff --git a/apps/web/src/components/device/DeviceControlsRail.tsx b/apps/web/src/components/device/DeviceControlsRail.tsx index 3b9e8bf96c36..3ff3968b5ce0 100644 --- a/apps/web/src/components/device/DeviceControlsRail.tsx +++ b/apps/web/src/components/device/DeviceControlsRail.tsx @@ -6,7 +6,6 @@ import { Keyboard, Box, Maximize, - Moon, MoreHorizontal, PictureInPicture2, Power, @@ -14,12 +13,13 @@ import { SlidersHorizontal, Smartphone, Square, - Sun, Type, X, } from "lucide-react"; +import { Moon, Sun } from "lucide"; import type { ReactNode } from "react"; import { Button } from "~/components/ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Menu, MenuItem, @@ -132,7 +132,7 @@ export function DeviceControlsRail(props: { disabled={controls.disabled || !settings?.appearance} onClick={() => void controls.act({ type: "setAppearance", value: nextAppearance })} > - {settings?.appearance === "dark" ? : } + -

{props.platform}

diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 9108c0556cc9..b7060cfece45 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -1,6 +1,6 @@ import type { GitStatusEntry } from "@pierre/trees"; import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown } from "lucide"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTheme } from "~/hooks/useTheme"; @@ -10,6 +10,7 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; import { Button } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { buildDiffFileTreeUpdates, @@ -189,11 +190,10 @@ export function DiffFileTree({ /> } > - {allDirectoriesExpanded ? ( - - ) : ( - - )} + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 3152e4f4a9c5..2d939d714ed8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -3,22 +3,13 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; -import { - CheckIcon, - ChevronRightIcon, - Code2, - CopyIcon, - DownloadIcon, - Eye, - Table2, - Trash2Icon, - WrapTextIcon, - XIcon, -} from "lucide-react"; +import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from "lucide-react"; +import { Check, Code2, Copy, Eye, Table2 } from "lucide"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; +import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -305,13 +296,10 @@ export function AttachmentFilePreview(props: { pressed={rendered} onPress={() => setRendered((value) => !value)} > - {rendered ? ( - - ) : renderedMode === "table" ? ( - - ) : ( - - )} + ) : null} {showsRawText ? ( @@ -328,7 +316,7 @@ export function AttachmentFilePreview(props: { label={isCopied ? "Copied" : content.truncated ? "Copy preview" : "Copy contents"} onPress={() => copyToClipboard(content.text, undefined)} > - {isCopied ? : } + ) : null} {url ? ( diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 69e8714a3298..ad134abf8904 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -6,11 +6,12 @@ import type { import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown } from "lucide"; import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; +import { MorphIcon } from "~/components/MorphIcon"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; @@ -516,11 +517,10 @@ export default function FileBrowserPanel({ /> } > - {allDirectoriesExpanded ? ( - - ) : ( - - )} + {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 313ccf0b7df8..8fff3edccc45 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -21,7 +21,8 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { Code2, Eye, FolderTree, Globe2, Table2, WrapTextIcon } from "lucide-react"; +import { FolderTree, Globe2, WrapTextIcon } from "lucide-react"; +import { Code2, Eye, Table2 } from "lucide"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -30,6 +31,7 @@ import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; +import { MorphIcon } from "~/components/MorphIcon"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; @@ -1140,13 +1142,10 @@ export default function FilePreviewPanel({ ); }} > - {rendered ? ( - - ) : renderedMode === "table" ? ( - - ) : ( - - )} + ) : null} {showsRawText ? ( diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 219210023e2e..a50a99193585 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -26,11 +26,11 @@ import { CheckIcon, ChevronRightIcon, CloudIcon, - CopyIcon, LinkIcon, MonitorIcon, TerminalIcon, } from "lucide-react"; +import { Check, Copy } from "lucide"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; @@ -77,6 +77,7 @@ import { T3Wordmark } from "../T3Wordmark"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; +import { MorphIcon } from "~/components/MorphIcon"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Input } from "../ui/input"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; @@ -1744,7 +1745,7 @@ function CommandBlock({ aria-label="Copy command" onClick={() => copyToClipboard(command, undefined)} > - {isCopied ? : } +
); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 251f3cd49f95..ae8a2b2d344e 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -13,8 +13,6 @@ import type { import { ChevronDownIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, Columns2Icon, FolderTreeIcon, InfoIcon, @@ -24,6 +22,7 @@ import { TextWrapIcon, TriangleAlertIcon, } from "lucide-react"; +import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import { useAtomRefresh } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -64,6 +63,7 @@ import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { @@ -791,11 +791,7 @@ function PullRequestCodeTab({ toggleFile(item.id); }} > - {collapsed ? ( - - ) : ( - - )} + ); }, @@ -1249,11 +1245,10 @@ function PullRequestCodeTab({ /> } > - {allFilesCollapsed ? ( - - ) : ( - - )} + {allFilesCollapsed ? "Expand all files" : "Collapse all files"} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 1ba8ccba3780..fd36435962f1 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -9,19 +9,15 @@ import type { PullRequestThreadCommentsResult, PullRequestThreadComment, } from "@t3tools/contracts"; -import { - CheckCircle2Icon, - CircleIcon, - HammerIcon, - MessageSquareIcon, - Trash2Icon, -} from "lucide-react"; +import { HammerIcon, MessageSquareIcon, Trash2Icon } from "lucide-react"; +import { Circle, CircleCheck } from "lucide"; import { useRef, useState } from "react"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { PullRequestEditButton } from "./PullRequestEditButton"; import { Textarea } from "../ui/textarea"; import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; @@ -219,11 +215,10 @@ export function ReviewThreadCard({ onPointerDown={(event) => event.stopPropagation()} >
- {thread.isResolved ? ( - - ) : ( - - )} + ) : (
; @@ -197,6 +198,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); const searchInputRef = useRef(null); const modelListRef = useRef(null); + const pickerContentRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useClientSettings((s) => s.favorites ?? []); const activeEntry = props.instanceEntries.find( @@ -699,6 +701,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ), [visibleModels], ); + const [modelListContentSize, setModelListContentSize] = useState( + () => filteredItemKeys.length * MODEL_LIST_ESTIMATED_ITEM_SIZE, + ); + const [searchHeight, setSearchHeight] = useState(0); + useLayoutEffect( + () => modelListRef.current?.getState().listen("totalSize", setModelListContentSize), + [], + ); + // Fit the list to its rows plus the combobox list `py-1` and LegendList `py-1.5`. + const modelListHeight = + filteredItemKeys.length === 0 ? 0 : `calc(${modelListContentSize}px + var(--spacing) * 5)`; const updateModelListScrollFades = useCallback(() => { const scrollElement = modelListRef.current?.getScrollableNode(); if (!(scrollElement instanceof HTMLElement)) { @@ -815,7 +828,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -890,7 +906,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={searchInputRef} placeholder="Search models..." value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + if (!isSearching) setSearchHeight(pickerContentRef.current?.offsetHeight ?? 0); + setSearchQuery(e.target.value); + }} onKeyDown={(e) => { if ( showSidebar && @@ -944,7 +963,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> {/* Model list */} -
+
ref={modelListRef} @@ -1013,7 +1035,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> ); }} - estimatedItemSize={52} + estimatedItemSize={MODEL_LIST_ESTIMATED_ITEM_SIZE} drawDistance={480} recycleItems contentContainerClassName="pl-2 pr-px" From 2a455570e6ee70a59241c30126b50c7df1696a6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 17:59:58 -0700 Subject: [PATCH 72/82] test(server): replay checks a Claude subagent's thread takes its reported model (#15022) Co-authored-by: Claude Opus 5.5 (1M context) --- .../fixtures/claude_background_subagent_lifecycle/output.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index 0828f7f9bbec..4c91460bed69 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -180,6 +180,9 @@ export function assertClaudeBackgroundSubagentLifecycleOutput( const agentAChild = agentA?.childThreadId == null ? undefined : result.projections.get(agentA.childThreadId); assert.isDefined(agentAChild); + // The thread starts on the Agent call's "haiku"; the snapshot's model, which + // only arrives with the subagent's first reply, replaces it. + assert.equal(agentAChild.thread.modelSelection.model, AGENT_A_OBSERVED_MODEL); assert.deepEqual(assistantTexts(agentAChild), ["A_FIRST", "A_SECOND"]); assert.deepEqual(conversation(agentAChild), [ "user:Reply with exactly: A_FIRST", From dab26f582cf7826fec67fbed64b46f59f3421280 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:12:05 -0700 Subject: [PATCH 73/82] fix(web): subagent finish notifications look like subagent cards (#15281) --- .../src/components/chat/MessagesTimeline.tsx | 31 ++++++- .../src/components/chat/V2LifecycleRow.tsx | 91 +++++++++++++++++-- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f8d03ffec9d8..ba6e6800db59 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -265,6 +265,7 @@ import { isV2LifecycleItem, SubagentAvatar, SubagentElapsed, + SubagentNotificationLink, V2LifecycleRow, type HandoffTimelineRun, } from "./V2LifecycleRow"; @@ -4946,12 +4947,36 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray void) | undefined; -}) { +}; + +const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { + const ctx = use(TimelineRowCtx); + const item = props.workEntry.projectedItem?.item; + const childThreadId = + item?.type === "notification" ? notificationChildThreadId(item.source) : undefined; + if (item?.type !== "notification" || childThreadId === undefined) { + return ; + } + return ( + } + /> + ); +}); + +function WorkEntryLogRow(props: WorkEntryRowProps) { const { workEntry, workspaceRoot, displayLabel } = props; const ctx = use(TimelineRowCtx); const { threadRef, onImageExpand, timestampFormat } = ctx; @@ -5285,7 +5310,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ) : null} ); -}); +} function QuestionAnswerHistory({ answer, diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index ef9e8a3b6f8f..ea453a2b0c35 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -15,6 +15,7 @@ import { Fragment } from "react"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { ProviderDriverKind, + type OrchestrationV2Notification, type OrchestrationV2TurnItem, type ProviderInstanceId, type ServerProvider, @@ -326,6 +327,69 @@ export function SubagentAvatar({ ); } +/** The status a notification reported, frozen at the time it arrived. */ +const NOTIFICATION_OUTCOME_STATUS: Record< + OrchestrationV2Notification["outcome"], + OrchestrationV2TurnItem["status"] | null +> = { + completed: "completed", + failed: "failed", + cancelled: "cancelled", + updated: null, + unknown: null, +}; + +const NOTIFICATION_OUTCOME_LABEL: Record = { + completed: "Finished", + failed: "Failed", + cancelled: "Stopped", + updated: "Updated", + unknown: "Finished", +}; + +/** A notification about one subagent, drawn as that subagent's card. Renders `fallback` when the parent has no record of it. */ +export function SubagentNotificationLink(props: { + readonly parentRef: ScopedThreadRef; + readonly childThreadId: ThreadId; + readonly outcome: OrchestrationV2Notification["outcome"]; + readonly createdAt: string; + readonly timestampFormat: TimestampFormat; + readonly providerStatuses: ReadonlyArray; + readonly onOpenThread: (threadId: ThreadId) => void; + readonly fallback: ReactNode; +}) { + const agent = useAtomValue( + environmentThreadDetails.threadAtom(props.parentRef), + (thread) => + thread?.projection.subagents.find((agent) => agent.childThreadId === props.childThreadId) ?? + null, + ); + if (agent === null) return props.fallback; + return ( + provider.instanceId === agent.providerInstanceId, + )} + title={formatSubagentDisplayTitle(agent.title ?? "Subagent")} + result={agent.result} + progress={agent.progress} + startedAt={agent.startedAt} + completedAt={agent.completedAt} + threadId={props.childThreadId} + onOpenThread={props.onOpenThread} + event={{ + status: NOTIFICATION_OUTCOME_STATUS[props.outcome], + label: NOTIFICATION_OUTCOME_LABEL[props.outcome], + timestamp: formatShortTimestamp(props.createdAt, props.timestampFormat), + }} + /> + ); +} + function SubagentTimelineLink(props: { readonly parentRef: ScopedThreadRef; readonly subagentId: NodeId; @@ -339,35 +403,46 @@ function SubagentTimelineLink(props: { readonly completedAt: DateTime.Utc | null; readonly threadId: ThreadId | null; readonly onOpenThread: (threadId: ThreadId) => void; + /** Draws a past event about the subagent: its status then, and when it happened instead of elapsed time. */ + readonly event?: { + readonly status: OrchestrationV2TurnItem["status"] | null; + readonly label: string; + readonly timestamp: string; + }; }) { const agent = useAtomValue( environmentThreadDetails.threadAtom(props.parentRef), (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null, ); const threadId = props.threadId; - const status = agent?.status ?? props.status; - const statusLabel = subagentStatusVisual(status).label; + const liveStatus = agent?.status ?? props.status; + const status = props.event ? props.event.status : liveStatus; + const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label; const result = (agent?.result ?? props.result)?.trim(); const progress = (agent?.progress ?? props.progress)?.trim(); - const settled = SETTLED_SUBAGENT_STATUSES.has(status); + const settled = SETTLED_SUBAGENT_STATUSES.has(status ?? liveStatus); const rawDetail = settled ? result || progress : progress || result; const detail = rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null; const failed = status === "failed"; const timing = { - status, + status: liveStatus, startedAt: isoOrNull(agent?.startedAt ?? props.startedAt), completedAt: isoOrNull(agent?.completedAt ?? props.completedAt), }; const content = ( <> - + {props.title} - {detail !== null && status !== "completed" ? ( + {detail !== null && (props.event !== undefined || status !== "completed") ? ( - + {props.event ? props.event.timestamp : } {threadId !== null ? ( : null} model={agent?.model ?? null} - status={status} + status={liveStatus} result={agent?.result ?? props.result} progress={agent?.progress ?? props.progress} /> From 4543c7368cae6dfef6b2b206b5d8aeb709eeca34 Mon Sep 17 00:00:00 2001 From: Ryan Ilano Date: Sat, 3 Oct 2026 21:15:11 -0400 Subject: [PATCH 74/82] fix(web): thread status dot has an accessible name (#14587) --- apps/web/src/components/ThreadStatusIndicators.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 08cf6d435ca3..946834bb297e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -827,6 +827,7 @@ export function ThreadStatusLabel({ @@ -848,6 +849,7 @@ export function ThreadStatusLabel({ From c5bc98db03ef048e2d88010876b2b3e474ecfd90 Mon Sep 17 00:00:00 2001 From: Ryan Ilano Date: Sat, 3 Oct 2026 21:15:17 -0400 Subject: [PATCH 75/82] fix(web): legacy sidebar options button has a label (#14602) --- apps/web/src/components/LegacySidebar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 9749f6ac7849..3e1356f988a1 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2748,7 +2748,11 @@ function ProjectSortMenu({ } />} + render={ + } + /> + } > From 2b8bb0c1881583428adbc07e3b641b21067ab41a Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:39:40 -0700 Subject: [PATCH 76/82] fix(web): imported themes keep switches and focus rings visible (#14498) --- apps/web/src/vscodeThemeImport.test.ts | 151 +++++++++++++++++++++++++ apps/web/src/vscodeThemeImport.ts | 121 ++++++++++++++------ 2 files changed, 239 insertions(+), 33 deletions(-) diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 797346a6557b..03860b631480 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -142,6 +142,157 @@ describe("VS Code theme import", () => { ]); }); + it("prefers a visible input background over a transparent border", () => { + const catppuccin = parseVsCodeThemeFile({ + name: "Catppuccin Mocha", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "input.border": "#00000000", + "input.background": "#313244", + }, + }); + expect(asHex(catppuccin.colors.input)).toBe("#313244"); + }); + + it("keeps unchecked inputs distinct from the checked action color", () => { + const gruvbox = parseVsCodeThemeFile({ + name: "Gruvbox Light Soft", + type: "light", + colors: { + "editor.background": "#f2e5bc", + "button.background": "#45858880", + "input.background": "#45858880", + "input.border": "#928374", + }, + }); + expect( + contrastRatio(gruvbox.colors.input, gruvbox.colors.messageAction), + ).toBeGreaterThanOrEqual(1.1); + }); + + it("keeps the derived input distinct when a button reuses it", () => { + const theme = parseVsCodeThemeFile({ + name: "Derived input collision", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + focusBorder: "#89b4fa", + "button.background": "#525661", + }, + }); + + expect(asHex(theme.colors.messageAction)).toBe("#525661"); + expect(asHex(theme.colors.input)).not.toBe("#525661"); + expect(contrastRatio(theme.colors.input, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1); + expect(contrastRatio(theme.colors.input, theme.colors.messageAction)).toBeGreaterThanOrEqual( + 1.1, + ); + }); + + it("skips a transparent focus border for a visible accent key", () => { + const vitesse = parseVsCodeThemeFile({ + name: "Vitesse Dark", + type: "dark", + colors: { + "editor.background": "#121212", + focusBorder: "#00000000", + "button.background": "#4d9375", + }, + }); + expect(asHex(vitesse.colors.accent)).toBe("#4d9375"); + expect(asHex(vitesse.colors.focus)).toBe("#4d9375"); + expect(contrastRatio(vitesse.colors.focus, vitesse.colors.canvas)).toBeGreaterThanOrEqual(1.1); + }); + + it("keeps focus visible against an explicit raised surface", () => { + const theme = parseVsCodeThemeFile({ + name: "Raised focus", + type: "dark", + colors: { + "editor.background": "#000000", + focusBorder: "#111111", + "editorWidget.background": "#111111", + "button.background": "#4d9375", + }, + }); + expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual( + 1.1, + ); + expect(asHex(theme.colors.focus)).toBe("#4d9375"); + }); + + it("keeps the fallback focus visible against an explicit raised surface", () => { + const theme = parseVsCodeThemeFile({ + name: "Raised fallback focus", + type: "dark", + colors: { + "editor.background": "#121212", + "editorWidget.background": "#346bf1", + }, + }); + expect(asHex(theme.colors.focus)).toBe("#ffffff"); + expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1); + expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual( + 1.1, + ); + }); + + it("skips a transparent button background for the action color", () => { + const theme = parseVsCodeThemeFile({ + name: "Transparent button", + type: "dark", + colors: { + "editor.background": "#121212", + focusBorder: "#4d9375", + "button.background": "#00000000", + }, + }); + expect(asHex(theme.colors.messageAction)).toBe("#4d9375"); + }); + + it("skips a button background that matches the raised surface", () => { + const theme = parseVsCodeThemeFile({ + name: "Raised button", + type: "dark", + colors: { + "editor.background": "#121212", + focusBorder: "#4d9375", + "editorWidget.background": "#2a2d3a", + "button.background": "#2a2d3a", + }, + }); + expect(asHex(theme.colors.messageAction)).toBe("#4d9375"); + }); + + it("uses a visible default accent when the file has no usable accent key", () => { + const theme = parseVsCodeThemeFile({ + name: "No accent", + type: "dark", + colors: { "editor.background": "#121212", focusBorder: "#121212" }, + }); + expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1); + }); + + it("validates placeholders against the resolved raised surface", () => { + const lightPlus = parseVsCodeThemeFile({ + name: "Light Plus Shape", + type: "light", + colors: { + "editor.background": "#eaeff3", + "editor.foreground": "#1f1f1f", + "editorWidget.background": "#ffffff", + "input.placeholderForeground": "#767676", + }, + }); + expect( + contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised), + ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised)); + }); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index fc0fc2bf8cbc..2d6ff7f6aa80 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,5 +1,6 @@ import { createVividThemeColors, + getStandardThemeColors, getThemeModes, isReservedThemeId, parseThemeFile, @@ -16,10 +17,10 @@ import { * * VS Code themes describe editor chrome, not an app palette: they carry a few * hundred workbench keys, leave most of them unset, and freely use 8-digit - * hex with alpha for overlays. So the conversion derives a complete, contrast - * -solved palette from the theme's editor background and accent, then layers - * the workbench colors it did specify on top. Anything the file omits keeps - * the derived value instead of falling back to an unrelated palette. + * hex with alpha for overlays. The conversion derives a complete palette from + * a visible accent and the editor background, then layers usable workbench + * colors on top. Foregrounds must remain readable, while authored focus and + * control candidates must remain distinct from adjacent surfaces and states. */ type VsCodeRgba = { r: number; g: number; b: number; a: number }; @@ -212,26 +213,61 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; const appearance = resolveAppearance(value, canvas); - const accentColor = pick( + const canvasHex = toHex(canvas); + const raisedSurfaceCandidateHex = solidOver( + canvas, + "editorWidget.background", + "dropdown.background", + ); + + /** Accent and control candidates must clear this small separation floor + * from every adjacent surface or state checked by the importer. */ + const standsApart = (first: string, second: string) => + contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1; + + let accentColor: VsCodeRgba | null = null; + let accentHex: string | null = null; + for (const key of [ "focusBorder", "button.background", "textLink.foreground", "activityBarBadge.background", "progressBar.background", "badge.background", - ); - const canvasHex = toHex(canvas); - const accentHex = accentColor ? flattenOver(accentColor, canvas) : null; + ]) { + const candidate = parseVsCodeColor(colors[key]); + if (!candidate) continue; + const candidateHex = flattenOver(candidate, canvas); + if ( + !standsApart(candidateHex, canvasHex) || + (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex)) + ) + continue; + accentColor = candidate; + accentHex = candidateHex; + break; + } + if (!accentColor || !accentHex) { + const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!; + accentHex = + [standardAccentHex, "#ffffff", "#000000"].find( + (candidate) => + standsApart(candidate, canvasHex) && + (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)), + ) ?? standardAccentHex; + accentColor = parseVsCodeColor(accentHex)!; + } // The derived palette is the floor: every role starts contrast-solved, then // the theme's own workbench colors replace what it actually specified. The // floor derives from a muted accent -- the vivid engine carries the accent // hue into every surface, which washes an imported neutral palette (a gray // theme with a blue focusBorder would get blue code and text surfaces). - const mutedAccentHex = accentColor - ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas) - : null; - const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex); + const mutedAccentHex = flattenOver( + { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, + canvas, + ); + const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex); const sidebarHex = solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; const sidebar = hexToRgb(sidebarHex); @@ -258,6 +294,34 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; }; + const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised; + // The checked switch track maps to messageAction, so resolve it before + // choosing the input role used by the unchecked track. + const buttonHex = solidOver(canvas, "button.background"); + const actionHex = + buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex) + ? buttonHex + : accentHex; + const inputCandidates = [ + derived.input, + derived.surfaceRaised, + getStandardThemeColors(appearance).input, + "#000000", + "#ffffff", + "#808080", + ]; + let inputHex = + inputCandidates.find( + (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex), + ) ?? "#808080"; + for (const key of ["input.background", "input.border"]) { + const candidate = solidOver(canvas, key); + if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) { + inputHex = candidate; + break; + } + } + const overrides: Partial> = { canvas: canvasHex, text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), @@ -268,15 +332,14 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { "disabledForeground", ), surface: solidOver(canvas, "editorWidget.background") ?? derived.surface, - surfaceRaised: - solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised, + surfaceRaised: surfaceRaisedHex, surfaceOverlay: solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? derived.surfaceOverlay, border: solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border, - input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input, - placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"), + input: inputHex, + placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"), error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"), warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"), accentSurface: @@ -303,23 +366,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { terminalScrollbar: solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, }; - if (accentHex) { - overrides.accent = accentHex; - overrides.focus = accentHex; - // The button pair is the closest thing VS Code has to our action color. - const actionHex = solidOver(canvas, "button.background") ?? accentHex; - overrides.messageAction = actionHex; - overrides.messageActionForeground = readableOn( - actionHex, - derived.messageActionForeground, - "button.foreground", - ); - overrides.accentForeground = readableOn( - accentHex, - derived.accentForeground, - "button.foreground", - ); - } + overrides.accent = accentHex; + overrides.focus = accentHex; + overrides.messageAction = actionHex; + overrides.messageActionForeground = readableOn( + actionHex, + derived.messageActionForeground, + "button.foreground", + ); + overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground"); // Reuse the theme-file parser so ids, names, and color values go through the // same validation as a hand-written file. From 7bc161f8692dfb969489f40bf3eb5bd27bc01f10 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:40:10 -0700 Subject: [PATCH 77/82] fix(web): links to issues no longer strand the pull request viewer (#14242) --- .../AzureDevOpsPullRequestProvider.ts | 1 + .../BitbucketPullRequestProvider.ts | 6 +++++ .../pullRequest/ForgejoPullRequestProvider.ts | 4 ++- .../pullRequest/GitHubPullRequestProvider.ts | 1 + .../pullRequest/GitLabPullRequestProvider.ts | 1 + .../src/pullRequest/PullRequestProvider.ts | 8 +++++- .../pullRequest/PullRequestService.test.ts | 27 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 7 ++++- .../pullRequest/PullRequestDetailPanel.tsx | 9 ++++++- .../pullRequest/pullRequestDetail.logic.ts | 7 +++++ apps/web/src/state/query.ts | 7 +++-- packages/contracts/src/pullRequest.ts | 1 + 12 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index b11ba6e20563..2cdf78c56a14 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -117,6 +117,7 @@ export function azureDevOpsProviderFailure( if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index e0ee4c0a85b7..f553dfd13bc1 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -82,6 +82,12 @@ export function bitbucketProviderFailure( ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), }; } + if ( + (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && + error.status === 404 + ) { + return { reason: "not-found" }; + } return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts index 73d582c9042c..12d931279a74 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -89,7 +89,9 @@ export const make = Effect.gen(function* () { ? "unauthenticated" : error.reason === "rate-limit" ? "rate-limited" - : "failed", + : error.reason === "not-found" + ? "not-found" + : "failed", }), ), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5e70c2ee1ed4..a51825c2e11b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -115,6 +115,7 @@ export function gitHubProviderFailure( if (error._tag === "SourceControlRateLimitPausedError") { return { reason: "rate-limited", retryAt: error.retryAt }; } + if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index ad089382352a..45034b84787c 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -102,6 +102,7 @@ export function gitLabProviderFailure( if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index cc370637c601..f13af63304d2 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -52,7 +52,13 @@ export class PullRequestProviderError extends Schema.TaggedError + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequest", + reason: "not-found", + detail: "Pull request not found. Check the PR number or URL and try again.", + }), + ), + }), + ], + }); + + const error = yield* Effect.flip(service.detail(reference)); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found"); + }), +); + it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => Effect.gen(function* () { let failing = false; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 293318f91c4b..ec523d398237 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -477,7 +477,12 @@ function toPullRequestError( return (error) => isProviderUnusable(error) ? toUnavailableError(error) - : new PullRequestOperationError({ operation, detail: error.detail, cause: error }); + : new PullRequestOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), + cause: error, + }); } function withRateLimitBackoff( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5c82e03aa8ea..bf5bd62de91c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -144,6 +144,7 @@ import { handoffReviewComments, latestPullRequestReviewOutcomes, loadingPullRequestCheckoutCommand, + isPullRequestNotFound, isStackedPullRequestBase, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, @@ -2719,7 +2720,13 @@ export function PullRequestDetailPanel({ > {detailQuery.error && !detail ? ( (fals Atom.withLabel("web-environment-query:empty"), ); -export interface EnvironmentQueryView { +export interface EnvironmentQueryView { readonly data: A | null; readonly dataUpdatedAt: number; readonly error: string | null; + readonly failure: E | null; readonly isPending: boolean; readonly isSuccess: boolean; readonly refresh: () => void; @@ -25,7 +26,7 @@ export function formatEnvironmentQueryError(cause: Cause.Cause): string export function useEnvironmentQuery( atom: Atom.Atom> | null, -): EnvironmentQueryView { +): EnvironmentQueryView { const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); @@ -38,6 +39,8 @@ export function useEnvironmentQuery( ? (Option.getOrNull(result.previousSuccess)?.timestamp ?? 0) : 0, error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, + failure: + result._tag === "Failure" ? Option.getOrNull(Cause.findErrorOption(result.cause)) : null, isPending: atom !== null && result.waiting, isSuccess: result._tag === "Success", refresh, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index c8feaad9a41f..b17d0141daef 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1407,6 +1407,7 @@ export class PullRequestOperationError extends Schema.TaggedError Date: Sat, 3 Oct 2026 18:40:33 -0700 Subject: [PATCH 78/82] fix(web): repo/task breadcrumb no longer bounces when the sidebar collapses (#15046) --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5ef916db8398..aed3cddf649c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10782,7 +10782,7 @@ export default function ChatView(props: ChatViewProps) { ref={threadPanelPopoverAnchorRef} data-chat-header className={cn( - "relative bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none", + "relative bg-background [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out", isElectron ? cn( "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5", From f90b77d80971ea4855a42abfc89ce85466e1accb Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:40:58 -0700 Subject: [PATCH 79/82] fix(web): Pull request panel entry works for linked PRs (#15061) --- apps/web/src/components/ChatView.tsx | 23 ++++++++--- .../pullRequestDetail.logic.test.ts | 40 +++++++++++++++++++ .../pullRequest/pullRequestDetail.logic.ts | 29 ++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aed3cddf649c..a8eb8fd64df0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -266,7 +266,10 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { pullRequestPanelContext } from "./pullRequest/pullRequestDetail.logic"; +import { + pullRequestPanelContext, + threadPullRequestPanelTarget, +} from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; @@ -6776,12 +6779,20 @@ export default function ChatView(props: ChatViewProps) { }, ); }, [activeThreadReferenceCopyTarget]); + const pullRequestPanelTarget = activeThread + ? threadPullRequestPanelTarget({ + projectId: activeThread.projectId, + pullRequests: visiblePullRequests, + linkedPullRequest: linkedThreadPullRequest, + branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, + }) + : null; const addPullRequestSurface = useCallback(() => { - if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) + if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); - }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]); - const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null; + useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); + }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]); + const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -10535,6 +10546,8 @@ export default function ChatView(props: ChatViewProps) { projectId: activeThread.projectId, pullRequests: visiblePullRequests, linkedPullRequest: linkedThreadPullRequest, + branchPullRequest: + activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }, renderedRightPanelSurface, )} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 978ae69583df..3d5676ecbf55 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -35,6 +35,7 @@ import { isStackedPullRequestBase, loadingPullRequestCheckoutCommand, pullRequestPanelContext, + threadPullRequestPanelTarget, latestPullRequestReviewOutcomes, newestPullRequestCommitAt, mergePullRequestThreadComments, @@ -1535,6 +1536,45 @@ describe("pull request panel context beside a thread", () => { ); expect(pullRequestPanelContext({ projectId: null }, surface(3))).toBe("page"); }); + + describe("the Pull request entry's target", () => { + const legacy = (number: number) => ({ + projectId: ProjectId.make("proj-a"), + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + }); + + it("opens a linked pull request the legacy field never named, ahead of the branch PR", () => { + expect( + threadPullRequestPanelTarget({ + projectId: "proj-a", + pullRequests: [link(15046, { source: "agent" })], + linkedPullRequest: null, + branchPullRequest: legacy(30), + }), + ).toEqual({ ...legacy(15046), host: "github.com" }); + }); + + it("reuses the legacy reference only for the same pull request on the same host", () => { + const linkedPullRequest = legacy(15046); + const onHost = (host: string) => + threadPullRequestPanelTarget({ + projectId: "proj-a", + pullRequests: [link(15046, { host, url: `https://${host}/pingdotgg/t3code/pull/15046` })], + linkedPullRequest, + }); + expect(onHost("github.com")).toBe(linkedPullRequest); + expect(onHost("github.example.com")).toMatchObject({ host: "github.example.com" }); + }); + + it("falls back to the branch pull request when the thread holds no link", () => { + expect( + threadPullRequestPanelTarget({ projectId: "proj-a", branchPullRequest: legacy(3) }), + ).toEqual(legacy(3)); + expect(threadPullRequestPanelTarget({ projectId: "proj-a", pullRequests: [] })).toBeNull(); + }); + }); }); describe("which actions need the host read again after they run", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index cdca896e6074..00d3aafd2d2f 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -27,6 +27,8 @@ import { type VcsRef, } from "@t3tools/contracts"; import { + legacyThreadPullRequestKey, + resolveThreadCurrentPullRequestLink, threadPullRequestKeysEqual, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; @@ -237,6 +239,33 @@ export function pullRequestPanelContext( : "page"; } +export function threadPullRequestPanelTarget(thread: { + readonly projectId: string; + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; +}) { + const current = resolveThreadCurrentPullRequestLink(thread.pullRequests ?? []); + const legacy = thread.linkedPullRequest; + if ( + current !== null && + legacy != null && + threadPullRequestKeysEqual(current, legacyThreadPullRequestKey(legacy)) + ) { + return legacy; + } + if (current !== null) { + return { + projectId: thread.projectId, + host: current.host, + repository: current.repository, + number: current.number, + url: current.url, + }; + } + return legacy ?? thread.branchPullRequest ?? null; +} + /** Names where a pull-request task will land, without letting each surface guess independently. */ export function pullRequestHandoffLabels(inThisThread: boolean) { return inThisThread From 95edeb753b070bad191e10afe15918c969135225 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:08:42 -0700 Subject: [PATCH 80/82] fix(web): add context menu to draft threads in the sidebar (#10637) --- apps/web/src/components/Sidebar.tsx | 97 +++++++++++++++---- .../sidebar/SidebarThreadUndoNotice.tsx | 3 +- .../components/threadActionMenu.logic.test.ts | 27 +++++- .../src/components/threadActionMenu.logic.ts | 39 ++++++++ apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/hooks/showThreadUndoNotice.ts | 13 ++- apps/web/src/lib/discardComposerDraft.test.ts | 89 +++++++++++++++++ apps/web/src/lib/discardComposerDraft.ts | 79 +++++++++++++++ docs/user/keybindings.md | 3 +- docs/user/thread-sidebar.md | 3 +- 10 files changed, 326 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/discardComposerDraft.test.ts create mode 100644 apps/web/src/lib/discardComposerDraft.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ffea669cc1b7..716f4cad9cc7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { endThreadContextDrag, moveThreadContextDrag as moveThreadContextDragGhost, } from "./chat/threadContextDrag"; -import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; +import { discardComposerDraft } from "../lib/discardComposerDraft"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; + onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { - const { composer, draftId, onDiscard, onNavigate } = props; + const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { // preventDefault here would swallow Space's synthesized click and // navigate instead of discarding. if ((event.target as HTMLElement).closest("button")) return; - if (event.key === "Enter" || event.key === " ") { + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onContextMenu(draftId, { x: rect.left, y: rect.bottom }); + } else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onNavigate(draftId); } }, - [draftId, onNavigate], + [draftId, onContextMenu, onNavigate], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(draftId, { x: event.clientX, y: event.clientY }); + }, + [draftId, onContextMenu], ); const handleDiscard = useCallback( (event: ReactMouseEvent) => { @@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, )} onClick={handleActivate} + onContextMenu={handleContextMenu} onKeyDown={handleKeyDown} > {preview} @@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; + onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); - const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); // The open draft's row is FROZEN at the moment the draft became the route: // it stays visible (like a thread row) but never repaints while the user // types. A draft that was never navigated away from has no snapshot to @@ -979,16 +992,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { props.routeDraftId, props.scopedProjectKeys, ]); - const handleDiscard = useCallback( - (draftId: DraftId) => { - // The /draft/$draftId route redirects home on its own when the draft - // it renders disappears, so discarding the open draft needs no - // special-casing here. - releaseComposerDraftUploads(draftId); - clearDraftThread(draftId); - }, - [clearDraftThread], - ); if (drafts.length === 0) { return null; } @@ -1005,7 +1008,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} - onDiscard={handleDiscard} + // The /draft/$draftId route redirects home on its own when the + // draft it renders disappears, so discarding the open draft needs + // no special-casing here. + onDiscard={discardComposerDraft} + onContextMenu={props.onDraftContextMenu} /> ); })} @@ -1164,15 +1171,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Unsent composer text on this thread. The open thread shows its own // composer, so the marker only decorates rows you have navigated away from. const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; - const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); const handleDiscardDraftClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - releaseComposerDraftUploads(threadRef); - clearComposerContent(threadRef); + discardComposerDraft(threadRef); }, - [clearComposerContent, threadRef], + [threadRef], ); const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; @@ -4227,6 +4232,55 @@ export default function Sidebar() { ], ); + const handleDraftContextMenu = useCallback( + (draftId: DraftId, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + const session = useComposerDraftStore.getState().getDraftSession(draftId); + if (!api || !session || session.promotedTo) return; + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (ref) => + ref.environmentId === session.environmentId && ref.projectId === session.projectId, + ), + ); + const workspacePath = + session.worktreePath ?? + projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot; + const clicked = await settlePromise(() => + api.contextMenu.show( + buildDraftActionMenuItems({ + hasPath: Boolean(workspacePath), + hasBranch: Boolean(session.branch), + hasProject: projectGroup != null, + }), + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "project-settings": + if (projectGroup) openProjectSettings(projectGroup); + return; + case "copy-path": + if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + case "copy-branch": + if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch }); + return; + case "discard": { + // The menu can stay open while the draft sends; discarding a + // promoting draft would strand the send. + const current = useComposerDraftStore.getState().getDraftSession(draftId); + if (current && !current.promotedTo) discardComposerDraft(draftId); + return; + } + } + })(); + }, + [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey], + ); + const handleThreadContextMenu = useCallback( (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { void (async () => { @@ -5048,6 +5102,7 @@ export default function Sidebar() { scopedProjectKeys={scopedProjectKeys} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} + onDraftContextMenu={handleDraftContextMenu} />, ]; for (const item of sidebarListItems) { diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx index 8f26a62c89bf..7f3f5bfe77ec 100644 --- a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx @@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() { if (!notice) return null; const shortcut = shortcutLabelForCommand(keybindings, "thread.undo"); + const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`; return ( - {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "} + {notice.action} {notice.count} {noun},{" "} {shortcut ? `${shortcut} to undo` : "Undo"} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index c5ebeda55627..1f85eb1a4e2e 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic"; +import { + buildDraftActionMenuItems, + buildThreadActionMenuItems, + type ThreadActionMenuState, +} from "./threadActionMenu.logic"; const baseState: ThreadActionMenuState = { branch: null, @@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => { expect(archiveItem?.disabled).toBe(true); }); }); + +describe("buildDraftActionMenuItems", () => { + it("offers only the copy values the draft has", () => { + const items = buildDraftActionMenuItems({ hasPath: false, hasBranch: true, hasProject: true }); + expect(items[0]).toMatchObject({ id: "copy", disabled: false }); + expect(items[0]?.children?.map((item) => item.id)).toEqual(["copy-branch"]); + + const noCopy = buildDraftActionMenuItems({ + hasPath: false, + hasBranch: false, + hasProject: true, + }); + expect(noCopy[0]).toMatchObject({ id: "copy", disabled: true, children: [] }); + }); + + it("drops project settings without a project and keeps discard last", () => { + const items = buildDraftActionMenuItems({ hasPath: true, hasBranch: false, hasProject: false }); + expect(items.map((item) => item.id)).toEqual(["copy", "discard"]); + expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true }); + }); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index a1ed9c277a4f..1b9ada254f14 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -30,6 +30,45 @@ export type ThreadActionMenuId = | "archive" | "delete"; +export type DraftActionMenuId = + | "copy" + | "copy-path" + | "copy-branch" + | "project-settings" + | "discard"; + +/** Right-click menu for an unsent draft row in the sidebar. */ +export function buildDraftActionMenuItems(options: { + readonly hasPath: boolean; + readonly hasBranch: boolean; + readonly hasProject: boolean; +}): ReadonlyArray> { + return [ + { + id: "copy", + label: "Copy", + icon: "copy", + disabled: !options.hasPath && !options.hasBranch, + children: [ + ...(options.hasPath ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }] : []), + ...(options.hasBranch + ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] + : []), + ], + }, + ...(options.hasProject + ? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }] + : []), + { + id: "discard", + label: "Discard draft", + icon: "trash", + destructive: true, + separatorBefore: true, + }, + ]; +} + export interface ThreadActionMenuState { readonly branch: string | null; /** diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f3ad5f9aecf9..5e0e76730b9b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1504,7 +1504,7 @@ function normalizeComposerTarget( return target; } -function resolveComposerDraftKey( +export function resolveComposerDraftKey( state: ComposerThreadLookupState, target: ComposerThreadTarget, ): string | null { diff --git a/apps/web/src/hooks/showThreadUndoNotice.ts b/apps/web/src/hooks/showThreadUndoNotice.ts index ea296219487a..c8608136c3fa 100644 --- a/apps/web/src/hooks/showThreadUndoNotice.ts +++ b/apps/web/src/hooks/showThreadUndoNotice.ts @@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import * as ThreadUndo from "./threadUndo"; type UndoOptions = { - action: "Settled" | "Snoozed" | "Unpinned" | "Archived"; + action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded"; undo: () => Promise>; failureTitle: string; claim: ReturnType; + /** Runs once the action can no longer be undone. */ + commit?: () => void; }; type UndoNotice = { @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = []; let expiry: ReturnType | undefined; function refreshNotice() { - liveUndos = liveUndos.filter(({ claim }) => claim.isCurrent()); + const stale = liveUndos.filter(({ claim }) => !claim.isCurrent()); + liveUndos = liveUndos.filter((entry) => !stale.includes(entry)); + for (const entry of stale) entry.commit?.(); const latest = liveUndos.at(-1); if (!latest) { clearTimeout(expiry); @@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) { expiry = setTimeout(() => { const expired = liveUndos; liveUndos = []; - for (const { claim } of expired) claim.finish(); + for (const { claim, commit } of expired) { + claim.finish(); + commit?.(); + } refreshNotice(); }, 5_000); } diff --git a/apps/web/src/lib/discardComposerDraft.test.ts b/apps/web/src/lib/discardComposerDraft.test.ts new file mode 100644 index 000000000000..7f40b4ec874e --- /dev/null +++ b/apps/web/src/lib/discardComposerDraft.test.ts @@ -0,0 +1,89 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../components/ui/toast"; +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice"; +import { releaseDraftAttachments } from "./attachmentUploadQueue"; +import { discardComposerDraft } from "./discardComposerDraft"; + +vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: vi.fn() })); + +const environmentId = EnvironmentId.make("environment-local"); +const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); +const draftId = DraftId.make("draft-1"); +const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); + +function undoNotice() { + const notice = useThreadUndoNotice.getState().notice; + if (!notice) throw new Error("Undo notice is missing"); + return notice; +} + +beforeEach(() => { + vi.useFakeTimers(); + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); +}); +afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe("discardComposerDraft", () => { + it("restores a discarded new-thread draft with its project mapping", async () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId); + store.setPrompt(draftId, "half-written prompt"); + const before = useComposerDraftStore.getState(); + + discardComposerDraft(draftId); + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); + expect(undoNotice()).toMatchObject({ action: "Discarded", count: 1 }); + + await undoNotice().undo(); + const after = useComposerDraftStore.getState(); + expect(after.getDraftSession(draftId)).toEqual(before.getDraftSession(draftId)); + expect(after.getComposerDraft(draftId)?.prompt).toBe("half-written prompt"); + expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual( + before.logicalProjectDraftThreadKeyByLogicalProjectKey, + ); + vi.runAllTimers(); + expect(releaseDraftAttachments).not.toHaveBeenCalled(); + }); + + it("clears a thread draft for good and releases its uploads once undo expires", async () => { + useComposerDraftStore.getState().setPrompt(threadRef, "reply in progress"); + + discardComposerDraft(threadRef); + const notice = undoNotice(); + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe(""); + expect(releaseDraftAttachments).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5_000); + expect(useThreadUndoNotice.getState().notice).toBeNull(); + expect(releaseDraftAttachments).toHaveBeenCalledOnce(); + await notice.undo(); + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe(""); + }); + + it("keeps text typed after the discard and releases the old uploads", async () => { + useComposerDraftStore.getState().setPrompt(threadRef, "old reply"); + discardComposerDraft(threadRef); + useComposerDraftStore.getState().setPrompt(threadRef, "new reply"); + const addToast = vi.spyOn(toastManager, "add").mockReturnValue("error-toast"); + + await undoNotice().undo(); + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe("new reply"); + expect(releaseDraftAttachments).toHaveBeenCalledOnce(); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Failed to restore draft" }), + ); + }); +}); diff --git a/apps/web/src/lib/discardComposerDraft.ts b/apps/web/src/lib/discardComposerDraft.ts new file mode 100644 index 000000000000..1a6ffbff6133 --- /dev/null +++ b/apps/web/src/lib/discardComposerDraft.ts @@ -0,0 +1,79 @@ +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { + type ComposerThreadTarget, + composerDraftHasUserContent, + resolveComposerDraftKey, + useComposerDraftStore, +} from "../composerDraftStore"; +import { showThreadUndoNotice } from "../hooks/showThreadUndoNotice"; +import * as ThreadUndo from "../hooks/threadUndo"; +import { releaseDraftAttachments } from "./attachmentUploadQueue"; + +/** + * Discards a draft's unsent content behind the sidebar undo notice. A new-thread + * draft (DraftId) loses its whole session; a thread draft only loses its + * composer content. Uploads are released once the undo window closes. + */ +export function discardComposerDraft(target: ComposerThreadTarget): void { + const store = useComposerDraftStore.getState(); + const key = resolveComposerDraftKey(store, target); + const draft = key === null ? undefined : store.draftsByThreadKey[key]; + if (key === null || !draft) return; + const session = store.draftThreadsByThreadKey[key]; + const logicalProjectKeys = Object.entries(store.logicalProjectDraftThreadKeyByLogicalProjectKey) + .filter(([, draftKey]) => draftKey === key) + .map(([logicalProjectKey]) => logicalProjectKey); + const discardsSession = typeof target === "string"; + + const claim = ThreadUndo.begin("discard", key); + if (discardsSession) { + store.clearDraftThread(target); + } else { + store.clearComposerContent(target); + } + + showThreadUndoNotice({ + action: "Discarded", + claim, + failureTitle: "Failed to restore draft", + undo: async () => { + const current = useComposerDraftStore.getState().draftsByThreadKey[key]; + if (current && composerDraftHasUserContent(current)) { + releaseDraftAttachments([...draft.images, ...draft.files]); + return AsyncResult.failure(Cause.fail(new Error("The draft has new content."))); + } + useComposerDraftStore.setState((state) => { + const logicalProjectDraftThreadKeyByLogicalProjectKey = { + ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, + }; + for (const logicalProjectKey of logicalProjectKeys) { + logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] ??= key; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + // Removing a draft session revokes its image previews. + [key]: discardsSession + ? { + ...draft, + images: draft.images.map((image) => + image.previewUrl.startsWith("blob:") + ? { ...image, previewUrl: URL.createObjectURL(image.file) } + : image, + ), + } + : draft, + }, + draftThreadsByThreadKey: session + ? { ...state.draftThreadsByThreadKey, [key]: session } + : state.draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey, + }; + }); + return AsyncResult.success(undefined); + }, + commit: () => releaseDraftAttachments([...draft.images, ...draft.files]), + }); +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index e305b30510e1..9f1ef0626fdd 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -120,7 +120,8 @@ a shortcut. shortcut; assign one in **Settings → Keybindings**. `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the -bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive +bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a +draft. Consecutive actions of the same kind undo together. The notice remains available for five seconds after the latest action. The default shortcut skips text fields and terminals so native undo keeps working there. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a43896189d71..38c5caaa8255 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -48,7 +48,8 @@ Pin a thread from its menu to keep it above your active work. On web and desktop, unpinning, settling, snoozing, and archiving a thread each show a notification with **Undo** for five seconds. Undo restores the thread's previous state, including its pinned position, and reopens an archived thread you were -viewing. `mod+z` triggers the most recent Undo when no text field is focused; see +viewing. Discarding an unsent draft from the sidebar works the same way: Undo brings +back its text and attachments. `mod+z` triggers the most recent Undo when no text field is focused; see [Keybindings](./keybindings.md#commands-with-special-behavior). On web and desktop, you can also drag files from your computer onto any thread row: From 9efb01690065923820dfa889af5745cc34750091 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:09:08 -0700 Subject: [PATCH 81/82] fix(web): keep sidebar branding and build pills from clipping at varying font sizes and zoom levels (#12141) --- apps/web/src/components/AppSidebarLayout.tsx | 14 +-- .../src/components/sidebar/SidebarChrome.tsx | 86 +++++++++++++------ .../src/components/threadSidebarWidth.test.ts | 36 ++++++++ apps/web/src/components/threadSidebarWidth.ts | 23 +++-- 4 files changed, 125 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/threadSidebarWidth.test.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index c405b7a9efa9..918d28845068 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { + clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, + resolveThreadSidebarMinimumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, - THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; import { @@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // and a clamped drag ends with an unchanged width, which skips the re-render // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); + const [brandWidth, setBrandWidth] = useState(0); + const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); const resetSidebarWidth = () => { try { removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); @@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${sidebarWidth}px`, + "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } @@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 29d1ae115d92..ce2ef2629549 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -50,7 +50,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.
@@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ variant={backdropVariant ? "media-navigation" : "ghost"} className="relative top-auto z-10 translate-y-0 md:hidden" /> - - {pillLabel ? ( - - {pillLabel} - - ) : null} + {/* One visible line: the pill wraps onto the clipped second line once it no longer fits. + The padding keeps the brand's focus ring inside the clip. */} +
+ + {pillLabel ? ( +
+ + {pillLabel} + +
+ ) : null} +
); }); +// Measures the brand at its titlebar inset, plus the header's right padding and the +// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls. +export function SidebarBrandWidthProbe({ + onWidthChange, +}: { + onWidthChange: (width: number) => void; +}) { + const observeWidth = useCallback( + (probe: HTMLDivElement) => { + const observer = new ResizeObserver(([entry]) => { + if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); + }); + observer.observe(probe); + return () => observer.disconnect(); + }, + [onWidthChange], + ); + + return ( + + ); +} + function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { return ( - {/* Center the visible capitals, without the font's ascender/descender space. */} - - - - Code - - + ); } +function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) { + return ( + // Center the visible capitals, without the font's ascender/descender space. + + + + Code + + + ); +} + function SidebarUtilityItem({ icon, label, diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts new file mode 100644 index 000000000000..41f6b97c9283 --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + clampThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + resolveThreadSidebarMinimumWidth, + THREAD_SIDEBAR_MIN_WIDTH, +} from "./threadSidebarWidth"; + +describe("resolveThreadSidebarMinimumWidth", () => { + it("keeps the default minimum when the brand fits", () => { + expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); + + it("grows to a brand wider than the default, rounding up", () => { + expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238); + }); +}); + +describe("resolveThreadSidebarMaximumWidth", () => { + it("never drops below a raised minimum on a narrow viewport", () => { + expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238); + expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560); + }); +}); + +describe("clampThreadSidebarWidth", () => { + it("widens a stored width below a raised minimum", () => { + expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238); + }); + + it("keeps widths inside the range and caps wide ones", () => { + expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300); + expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560); + }); +}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index 27589d8f200c..ea9967c1f06d 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -3,11 +3,24 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; -export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { - return Math.max( - THREAD_SIDEBAR_MIN_WIDTH, - Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, - ); +// The brand's measured width can raise the minimum so "T3 Code" never clips. +export function resolveThreadSidebarMinimumWidth(brandWidth: number): number { + return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth)); +} + +export function resolveThreadSidebarMaximumWidth( + viewportWidth: number, + minimumWidth = THREAD_SIDEBAR_MIN_WIDTH, +): number { + return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH); +} + +export function clampThreadSidebarWidth( + width: number, + minimumWidth: number, + maximumWidth: number, +): number { + return Math.min(maximumWidth, Math.max(minimumWidth, width)); } export function resolveInitialThreadSidebarWidth( From 0c8112013760617230d382e55447ddb75627a997 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:11:20 -0700 Subject: [PATCH 82/82] fix(usage): model shares and order follow the selected metric (#11391) --- .../src/features/usage/UsageRouteScreen.tsx | 32 ++++++++++++---- apps/web/src/components/usage/UsagePage.tsx | 7 +++- .../components/usage/usageBreakdown.test.ts | 28 +++++++++++++- .../src/components/usage/usageBreakdown.ts | 9 +++++ packages/shared/src/usageMerge.test.ts | 38 +++++++++++++++++++ packages/shared/src/usageMerge.ts | 2 + 6 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3022b9fe2208..5a8a4b1a15d4 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -361,7 +361,7 @@ export function UsageRouteScreen() { /> - + )} @@ -781,14 +781,22 @@ function MetricCell(props: { ); } -function ModelsSection(props: { readonly merged: MergedUsage }) { - const { merged } = props; +function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { + const { merged, metric } = props; const colors = useProviderColors(); if (merged.models.length === 0) return null; + // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes + // doesn't ship the ES2023 method. + const ordered = [...merged.models].sort((a, b) => + metric === "cost" + ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens + : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd, + ); + return ( - {merged.models.map((model, index) => ( + {ordered.map((model, index) => ( - {isModelCostUnknown(model) - ? `no known rates · ${formatTokens(model.totalTokens)} tokens` - : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} + {metric === "tokens" + ? `${formatPercent(model.tokenShare)} of tokens · ${ + isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) + }` + : isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + {metric === "tokens" + ? formatTokens(model.totalTokens) + : isModelCostUnknown(model) + ? "Unpriced" + : formatUsd(model.costUsd)} ))} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 89bea86b7e0f..969ca5f4e907 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -82,6 +82,7 @@ import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog"; import { UsageShareBar } from "./UsageShareBar"; import { costTypeSegments, + modelShare, sortModelsByTokens, speedCostSegments, tokenTypeSegments, @@ -718,6 +719,10 @@ export function UsagePage() { breakdownModels.map((model, index) => { const key = `${model.provider}:${model.model}`; const value = metric === "tokens" ? model.totalTokens : model.costUsd; + const share = modelShare( + model, + metric === "tokens" ? "tokens" : "cost", + ); return ( - {isModelCostUnknown(model) ? "" : formatPercent(model.costShare)} + {share === null ? "" : formatPercent(share)} {formatTokens(model.totalTokens)} diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index ff0a611c7d45..a835ad4f953a 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,7 +1,12 @@ import type { ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { cacheHitRate, costPerMillionTokens, sortModelsByTokens } from "./usageBreakdown"; +import { + cacheHitRate, + costPerMillionTokens, + modelShare, + sortModelsByTokens, +} from "./usageBreakdown"; const model = ( name: string, @@ -24,6 +29,7 @@ const model = ( unpricedRecords: 0, unpricedTokens: 0, costShare: 0, + tokenShare: 0, ...overrides, }); @@ -44,6 +50,26 @@ describe("sortModelsByTokens", () => { }); }); +describe("modelShare", () => { + it("follows the selected metric", () => { + const priced = model("priced", 100, 9, { costShare: 0.9, tokenShare: 0.25 }); + + expect(modelShare(priced, "cost")).toBe(0.9); + expect(modelShare(priced, "tokens")).toBe(0.25); + }); + + it("has no cost share for an unknown cost but keeps its token share", () => { + const unpriced = model("unpriced", 300, 0, { + unpricedRecords: 1, + unpricedTokens: 300, + tokenShare: 0.75, + }); + + expect(modelShare(unpriced, "cost")).toBeNull(); + expect(modelShare(unpriced, "tokens")).toBe(0.75); + }); +}); + describe("model rates", () => { it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { const mixed = model("mixed", 4_000_000, 6, { diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index 0249eb26aff2..ab449af9fbbb 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -14,6 +14,15 @@ export function sortModelsByTokens(models: readonly ModelTotals[]) { ); } +/** + * A model's share of the selected metric, or `null` for a cost share of an + * unknown cost. An unpriced model still has a real token share. + */ +export function modelShare(model: ModelTotals, metric: "cost" | "tokens"): number | null { + if (metric === "tokens") return model.tokenShare; + return isModelCostUnknown(model) ? null : model.costShare; +} + /** * Share of a model's input read from cache, or `null` without input. Cache * writes count as misses: that input was processed in full. diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index eb50f2fb19b3..8208e5b7ed1c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -514,6 +514,44 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("derives model token shares independently of their cost shares", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 90 }), + bucket({ + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 10, + totals: { + uncachedInputTokens: 3 * 1160, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model])); + expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5); + expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5); + expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5); + expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5); + }); + it("marks a model with no known rates as unpriced rather than free", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 18c2c9f11c49..07718c099bef 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -51,6 +51,7 @@ export interface ModelTotals { */ readonly unpricedTokens: number; readonly costShare: number; + readonly tokenShare: number; } /** @@ -583,6 +584,7 @@ export function mergeUsage( unpricedRecords: totals.unpricedRecords, unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);